[Vue] Vue 模板编译原理解析 part 2

转换器

主要的目的是将模板的 AST 转换为 JS 的 AST,整个模板的编译过程如下:

// Vue 的模板编译器
function compile(template) {
  // 1. 得到模板的 AST
  const ast = parse(template);
  // 2. 将模板 AST 转为 JS AST
  transform(ast);
}

整个转换实际上可以分为两个大的部分:

  • 模板 AST 的遍历以及针对节点的操作能力
  • 生成 JavaScript AST

模板 AST 的遍历以及针对节点的操作能力

步骤一

先书写一个简单的工具方法,方便我们查看模板 AST 中节点的信息

// 打印 AST 节点
function dump(node, indent = 0) {
  const type = node.type;
  // 根据节点类型来构建描述信息
  const desc =
    node.type === "Root"
      ? ""
      : node.type === "Element"
      ? node.tag
      : node.content;

  // 接下来进行一个打印
  console.log(`${"-".repeat(indent)}${type}: ${desc}`);

  // 如果有子节点,递归打印
  if (node.children) {
    node.children.forEach((child) => dump(child, indent + 2));
  }
}

步骤二

接下来我们就需要遍历整棵模板的 AST 树,在遍历的时候就可以针对一些节点动一些手脚,例如我们要将所有的 p 修改为 h1

// 该方法就是用于遍历 AST 节点的
function traverseNode(ast) {
  // 获取当前节点
  const currentNode = ast;

  // 接下来我们就可以针对拿到的节点做一些事情
  if (currentNode.type === "Element" && currentNode.tag === "p") {
    // 如果是 p 标签,就将其转换为 h1 标签
    currentNode.tag = "h1";
  }

  // 如果有子节点,递归遍历
  const children = currentNode.children;
  if (children) {
    // 如果有子节点,那么我们就遍历
    for (let i = 0; i < children.length; i++) {
      traverseNode(children[i]);
    }
  }
}

// 负责将模板 AST 转换为 JavaScript AST
function transform(ast) {
  traverseNode(ast);
  console.log(dump(ast));
}

在上面的代码中,transform 是最终负责转换的方法,转换的核心逻辑是放在 transform 里面的。transform 里面决定了我整个转换操作,第一步做什么,第二步做什么。

traverseNode 负责遍历整个模板的 AST,并且在遍历的途中,我们还能够进行一些修改。

步骤三

目前为止,这个 traverseNode 方法既负责了遍历 AST 节点,又负责了转换的工作,假设我们有一个新的需求,例如要将文本全部转为大写,那么我们就必须要去修改 traverseNode

// 该方法就是用于遍历 AST 节点的
function traverseNode(ast) {
  // 获取当前节点
  const currentNode = ast;

  // 接下来我们就可以针对拿到的节点做一些事情
  if (currentNode.type === "Element" && currentNode.tag === "p") {
    // 如果是 p 标签,就将其转换为 h1 标签
    currentNode.tag = "h1";
  }

  if (currentNode.type === "Text") {
    // 如果是文本节点,就将其内容转换为大写
    currentNode.content = currentNode.content.toUpperCase();
  }

  // 如果有子节点,递归遍历
  const children = currentNode.children;
  if (children) {
    // 如果有子节点,那么我们就遍历
    for (let i = 0; i < children.length; i++) {
      traverseNode(children[i]);
    }
  }
}

这个时候,我们就需要让 遍历转换 进行一个解耦。

可以在 transform 里面维护一个上下文对象。

什么是上下文 ?

上下文是一个非常非常非常重要且常见的概念,所谓上下文,指的是一个环境信息。我们在执行代码的时候,我们是需要一些数据的,那你的这些个数据从哪里去获取?就是从上下文环境中去获取。

实际上在现实生活中也有类似的上下文环境的场景,比如你在厨房做饭,整个厨房就是你做饭的环境,厨房里面有你要做饭的时候用到的各种厨具,比如菜刀、案板、锅、碗,灶台,这些工具整体构成了一个环境(上下文环境),当你做饭的时候要用到某一样工具,直接从这个环境中去获取。

16697040882418

上下文在很多地方都很常见:

  • React 中可以使用 React.createContext 创建一个上下文,其他组件可以访问该上下文里面的数据。
  • Vue 里面也有类似的概念,provide/inject 被称之为依赖注入,本质上也是提供了一个上下文环境。
  • Koa 里面的中间件接收一个 context 参数,本质上也是一个上下文对象
  • 还有就是我们最最最熟悉的 JS 里面的执行上下文。

接下来修改 transform,在内部维护一个 context 上下文对象:

const context = {
  currentNode: null, // 用于存储当前正在转换的节点
  childIndex: 0, // 存储当前正在转换的子节点在父节点的 children 数组中的索引
  parent: null, // 存储当前正在转换的节点的父节点
  nodeTransforms: [transformElement, transformText], // 这里面会放置各种转换函数
};

步骤四

接下来我们可以继续完善 context 这个上下文对象,可以添加一些方法,例如替换节点的方法以及删除节点的方法,如下:

const context = {
  currentNode: null, // 用于存储当前正在转换的节点
  childIndex: 0, // 存储当前正在转换的子节点在父节点的 children 数组中的索引
  parent: null, // 存储当前正在转换的节点的父节点
  // 替换节点的方法
  replaceNode(node) {
    context.parent.children[context.childIndex] = node;
    context.currentNode = node;
  },
  // 删除节点的方法
  removeNode() {
    if (context.parent) {
      context.parent.children.splice(context.childIndex, 1);
      context.currentNode = null;
    }
  },
  nodeTransforms: [transformElement, transformText], // 这里面会放置各种转换函数
};

步骤五

最后我们还需要解决一个问题,那就是节点处理的次数问题。

目前我们使用的是深度优先遍历的方式来处理的节点。这种工作流方式有一个问题,在转换 AST 节点的过程中,往往需要根据子节点的情况来决定当前节点如何进行转换,这就要求父节点的转换操作必须等到子节点完毕后在执行。

这里我们可以对转换函数进行一个改造,让它返回一个方法,这个方法就是之后要再次处理的回调方法。

function transformText(node, context) {
  // ...

  // 返回一个回掉方法,这个回掉方法是在退出阶段执行的
  return () => {
    console.log("可以再次处理节点:", node.type, node.tag || node.content);
  };
}

之后最核心的是要对 traverseNode 方法进行一个改造:

// 该方法就是用于遍历 AST 节点的
function traverseNode(ast, context) {
  console.log("处理节点:", ast.type, ast.tag || ast.content);

  // 获取当前节点
  context.currentNode = ast;

  // 1. 新增一个在退出节点要执行的回调函数的数组
  const exitFns = [];

  // 拿到转换方法的数组
  const transforms = context.nodeTransforms;
  // 遍历数组中的方法,依次执行
  for (let i = 0; i < transforms.length; i++) {
    const onExit = transforms[i](context.currentNode, context);
    if (onExit) {
      exitFns.push(onExit);
    }
    // 如果执行的是删除操作,那么我们需要检查当前节点是否已经被删除了
    if (!context.currentNode) return;
  }

  // 如果有子节点,递归遍历
  const children = context.currentNode.children;
  if (children) {
    // 如果有子节点,那么我们就遍历
    for (let i = 0; i < children.length; i++) {
      // 在进行递归遍历之前,也需要更新上下文里面的 parent 以及 childIndex
      context.parent = context.currentNode;
      context.childIndex = i;
      traverseNode(children[i], context);
    }
  }

  // 3. 在节点处理的最后节点,执行缓存在 exitFns 数组中的所有回调函数
  let i = exitFns.length;
  while (i--) {
    exitFns[i]();
  }
}

通过这种方式,我们就可以在节点进入和退出的时候做处理。这个思想在很多地方也很常见:

  • React 中 beginWork 和 completeWork
  • Koa 中间件采用的洋葱模型
posted @ 2025-03-29 19:47  Zhentiw  阅读(34)  评论(0)    收藏  举报