转换器
主要的目的是将模板的 AST 转换为 JS 的 AST,整个模板的编译过程如下:
// Vue 的模板编译器
function compile(template) {// 1. 得到模板的 ASTconst ast = parse(template);// 2. 将模板 AST 转为 JS ASTtransform(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 里面维护一个上下文对象。
什么是上下文 ?
上下文是一个非常非常非常重要且常见的概念,所谓上下文,指的是一个环境信息。我们在执行代码的时候,我们是需要一些数据的,那你的这些个数据从哪里去获取?就是从上下文环境中去获取。
实际上在现实生活中也有类似的上下文环境的场景,比如你在厨房做饭,整个厨房就是你做饭的环境,厨房里面有你要做饭的时候用到的各种厨具,比如菜刀、案板、锅、碗,灶台,这些工具整体构成了一个环境(上下文环境),当你做饭的时候要用到某一样工具,直接从这个环境中去获取。

上下文在很多地方都很常见:
- 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 以及 childIndexcontext.parent = context.currentNode;context.childIndex = i;traverseNode(children[i], context);}}// 3. 在节点处理的最后节点,执行缓存在 exitFns 数组中的所有回调函数let i = exitFns.length;while (i--) {exitFns[i]();}
}
通过这种方式,我们就可以在节点进入和退出的时候做处理。这个思想在很多地方也很常见:
- React 中 beginWork 和 completeWork
- Koa 中间件采用的洋葱模型