transform
transform的作用是遍历AST的每个结点,进行某些修改操作。
比如我们可以指定,每个text结点都增加"mini-vue":
<!--反映到dom上面,变化是这样的:-->
<div>hello,</div>
=>
<div>hello,mini-vue</div>
在实现上,为了保持transform的灵活性,使用插件的思想,用户可以在options对象中传入一个插件数组,每个元素都是一个函数,函数中可以操纵node,在transform中,在每一个结点上,这些函数都会运行一次,修改node。
单测代码如下:
describe("transform", () => {
it("happy path", () => {
const ast = baseParse("<div>hi,{{message}}</div>");
const plugin = (node) => {
if (node.type === NodeTypes.TEXT) {
node.content = node.content + " mini-vue";
}
};
transform(ast, {
nodeTransforms: [plugin],
});
const nodeText = ast.children[0].children[0];
expect(nodeText.content).toBe("hi, mini-vue");
});
});
先生成context,然后dfs即可。
export function transform(root, options) {
const context = createTransformContext(root, options);
traverseNode(root, context);
}
function createTransformContext(root, options) {
return {
root,
nodeTransforms: options.nodeTransforms
};
}
// 递归
function traverseNode(node, context) {
const { nodeTransforms } = context;
for (const transform of nodeTransforms) {
transform(node);
}
traverseChildren(node, context);
}
function traverseChildren(node, context) {
const { children } = node;
if (children) {
for (const child of children) {
traverseNode(child, context);
}
}
}
代码生成
写到这里,我想起自己大二时面试字节前端实习生,三面的时候leader问我“如何实现一个jsx编译器”,即输入是jsx,输出是render函数。当时我也知道有AST,但是由于对整个流程不甚清楚,思路是直接根据jsx去生成render函数,也就是跳过了parse的过程。虽然答得不好,还是侥幸通过了。
现在我们来看看如何根据AST生成render函数。先上一个工具网站:Vue 3 Template Explorer。它可以直接看到template编译后的render函数。
string
只有字符串时,render的结构很简单,第一行函数签名的部分基本是固定的,最后一行的}也是固定的,只有中间的“hi”需要根据AST来生成。
先进行一些准备工作,把代码生成的部分称作codegen,codegen并不关心AST的结构,由职责分离原则,root.children[0]这样的代码不要出现在codegen里,因此在transform中,保存到结点的codegenNode属性。
function createRootCodegen(root) {
root.codegenNode = root.children[0];
}
export function transform(root, options = {}) {
const context = createTransformContext(root, options);
traverseNode(root, context);
// 类似的操作还有很多,为了职责分离而把任务交给transform
createRootCodegen(root);
}
和parse的时候把模板保存在context.source,并且使用advancedBy移动一样,codegen中把生成的代码保存在context.code,并且提供了push方法,本质是字符串拼接,用于往后添加代码。
function createCodegenContext() {
const context = {
code: '',
push(source) {
context.code += source;
}
};
return context;
}
generate是codegen的入口,先拼接出函数开头的部分,然后处理AST,最后加上后花括号。
export function generate(ast) {
const context = createCodegenContext();
const { push } = context;
push('export ');
const functionName = 'render';
// _ctx后面会用,_cache目前没实现什么
const args = ['_ctx', '_cache'];
const signature = args.join(', ');
push(`function ${functionName}(${signature}){`);
push('return ');
// 处理AST
genNode(ast.codegenNode, context);
push('}');
return {
code: context.code
};
}
只有字符串时,genNode的逻辑十分简单,拿出content即可。
function genNode(node, context) {
const { push } = context;
push(`"${node.content}"`);
}
插值
插值的情况要复杂一些,新增的点:
- import导入,只要遇到插值结点,就需要导入toDisplayString。我们实现的是
const { toDisplayString: _toDisplayString } = Vue,原理是一样的。后面element结点会用到函数createElementBlock,我们叫的是createElementVNode,也无伤大雅。 - 需要给toDisplayString传入参数,参数从表达式结点获取,处理AST更加复杂,genNode需要根据结点的类型,提供相应的处理。
- 表达式的content前要添加_ctx.。
导入
封装函数getFuctionPreamble(序言),并在generate中调用。它可以生成最开始的引入。
function genFunctionPreamble(ast, context) {
const { push } = context;
const VueBinging = 'Vue';
if (ast.helpers.length > 0) {
// 因为s是symbol,为了拿到字符串,提供了helperMapName,是一个对象
const aliasHelper = (s) => `${helperMapName[s]}: _${helperMapName[s]}`;
push(
`const { ${ast.helpers.map(aliasHelper).join(', ')} } = ${VueBinging}\n`
);
}
push('export ');
}
这些代码现在看不懂也没关系,我们首先思考,如何确定引入部分的括号内有哪些函数呢?只要用到了插值,就会需要toDisplayString。同理,只要用到了element,就会需要createElementVNode,codegen要怎么知道这些信息呢,如果想知道,必须遍历所有结点,那么不如直接交给transform完成。以toDisplayString为例:
在transform的context中添加helpers,并且提供helper方法,添加key。
function createTransformContext(root, options) {
const context = {
root,
nodeTransforms: options.nodeTransforms,
helpers: new Map(),
helper(key) {
// 只有key被用到,1是随便设置的
// 这里用set更好
context.helpers.set(key, 1);
}
};
return context;
}
在traverseNode中,如果当前结点是插值结点,就会调用helper,添加名为“toDisplayString”的key。
export const TO_DISPLAY_STRING = Symbol('toDisplayString');
export const helperMapName = {
[TO_DISPLAY_STRING]: 'toDisplayString'
};
function traverseNode(node, context) {
const { nodeTransforms } = context;
if (nodeTransforms) {
for (const transform of nodeTransforms) {
transform(node);
}
}
switch (node.type) {
case NodeTypes.INTERPOLATION:
// 常量,是Symbol类型
context.helper(TO_DISPLAY_STRING);
break;
case NodeTypes.ROOT:
case NodeTypes.ELEMENT:
traverseChildren(node, context);
break;
default:
break;
}
}
最后这些key的集合就是需要导入的方法,成为根结点的helpers属性。
export function transform(root, options = {}) {
const context = createTransformContext(root, options);
traverseNode(root, context);
createRootCodegen(root);
root.helpers = [...context.helpers.keys()];
}
现在再回头看看getFuctionPreamble,其实就是拿到ast.helpers,然后进行一些操作,拼接出导入语句。
AST处理
因为结点的种类多了,现在genNode用于判断->中转。 genInterpolation中会调用genNode,进而处理内部的表达式结点,然后返回genInterpolation,添加 )。
function genNode(node, context) {
switch (node.type) {
case NodeTypes.TEXT:
genText(node, context);
break;
case NodeTypes.INTERPOLATION:
genInterpolation(node, context);
break;
case NodeTypes.SIMPLE_EXPRESSION:
genExpression(node, context);
break;
default:
break;
}
}
function genText(node, context) {
const { push } = context;
push(`"${node.content}"`);
}
function genInterpolation(node, context) {
const { push, helper } = context;
push(`${helper(TO_DISPLAY_STRING)}(`);
genNode(node.content, context);
push(')');
}
function genExpression(node: any, context: any) {
const { push } = context;
push(`${node.content}`);
}
至于_ctx.,靠给transform提供plugin解决:
export function transformExpression(node) {
if (node.type === NodeTypes.INTERPOLATION) {
node.content = processExpression(node.content);
}
}
function processExpression(node: any) {
node.content = `_ctx.${node.content}`;
return node;
}
插件的使用:
it('interpolation', () => {
const ast = baseParse('{{message}}');
transform(ast, {
nodeTransforms: [transformExpression]
});
const { code } = generate(ast);
expect(code).toMatchSnapshot();
});
element
不实现 _openBlock()。
其实和getInterpolation差不多,只是函数变成了createElementVNode。而且,把调用context.helper的操作也变成了插件:
export function transformElement(node, context) {
if (node.type === NodeTypes.ELEMENT) {
context.helper(CREATE_ELEMENT_VNODE);
}
}
关于element,更详细的在“联合”部分介绍。
联合
<div>hi, {{ message }}</div> 生成的代码如下:
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
// 不管最后一个参数1
return (_openBlock(), _createElementBlock("div", null, "hi, " + _toDisplayString(_ctx.message), 1 /* TEXT */))
}
最大的难点在于" + "的生成,交给codegen是难以实现的,根本不知道怎么控制加号。因此交给transform,可以提供插件,把相邻的文本结点和插值结点合并成compoundElement(合成结点),它拥有children属性,存放合并前的结点,并且在children中,结点之间存在“+”成员。
export function transformText(node) {
if (node.type === NodeTypes.ELEMENT) {
// 先不要问为什么放进返回的函数中
return () => {
const { children } = node;
let currentContainer;
for (let i = 0; i < children.length; i++) {
const prevChild = children[i];
if (isText(prevChild)) {
for (let j = i + 1; j < children.length; j++) {
const nextChild = children[j];
// 把相邻的文本类结点变为合成结点,因为文本之间有" + "
// codegen不好处理,必须让transform来
if (isText(nextChild)) {
if (!currentContainer) {
// 要给children[i]赋值
currentContainer = children[i] = {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [prevChild]
};
}
currentContainer.children.push(' + ');
currentContainer.children.push(nextChild);
children.splice(j--, 1);
} else {
currentContainer = undefined;
break;
}
}
}
}
};
}
}
除了这个插件,为了导入语句的生成,transform要遍历到插值结点,然后给helpers添加key。因为插件是按照在数组中的次序依次在每个结点上生效的,如果合成结点在前,并且traverseNode没有判断合成结点类型(可以判断合成结点类型,并且遍历它的孩子,但这不是普遍适用的方法),那么就不会有机会访问到插值结点,对应的插件也就没机会生效了。
普适的方法是让插件进行两趟操作,假如每个插件都返回一个onExit函数,那么执行流会变成这样:
A -> B -> C
<- <-
所以可以把transformText生成合成结点的逻辑放在onExit函数中,这样就不会影响到其他插件。
在这个例子中用到三个插件:(插件的顺序有讲究,需要考虑每个插件到底会带来什么影响,以及onExit是怎么实现的)
it('element', () => {
const ast: any = baseParse('<div>hi, {{ message }}</div>');
transform(ast, {
// transformExpression:导入 toDisplayString
// transformText:生成合成结点
// transformElement:添加_ctx.
nodeTransforms: [transformExpression, transformText, transformElement]
});
const { code } = generate(ast);
expect(code).toMatchSnapshot();
});
既然已经有了合成结点,加号的处理就变得简单了,分类讨论其孩子结点的类型即可。
function genCompoundExpression(node, context) {
const { children } = node;
const { push } = context;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (isString(child)) {
// 加号直接加到代码后面
push(child);
} else {
// 递归孩子
genNode(child, context);
}
}
}
createElementVNode接收3个参数,分别是tag,props和children,props的null不应该写死,值未提供时,通过map映射为null,然后push到代码中。依次处理tag,props和childen,到children时,进入genNode。
function genElement(node, context) {
const { push, helper } = context;
// 在parse阶段应该给element node加props属性,不过之前的实现中没加
const { tag, props, children } = node;
const tagWithQuotation = `"${tag}"`;
push(`${helper(CREATE_ELEMENT_VNODE)}(`);
genNodeList(genNullable([tagWithQuotation, props, children]), context);
push(')');
}
function genNodeList(nodes, context) {
const { push } = context;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (isString(node)) {
push(node);
} else {
// children
for (let i = 0; i < node.length; i++) {
genNode(node[i], context);
}
}
// 使用逗号分隔
if (i < nodes.length - 1) {
push(', ');
}
}
}
function genNullable(args: any) {
return args.map((arg) => arg || 'null');
}