『手写Vue3』transform、生成render

166 阅读3分钟

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

image.png

只有字符串时,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.。

image.png

导入

封装函数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()

image.png

其实和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');
}