Vue3源码学习7——编译器compile基础

350 阅读11分钟

构建完了render渲染器之后,距离实际使用的Vue只剩最后一步编译器compiler

在Vue3中,编译的目的在于把模板代码转换成可以被render函数渲染的代码即可

const template = `<div>hello world</div>`

const renderFn = compile(template)

// 这里的renderFn就是render函数了,调用就可以渲染

因为Vue是一个 领域特定语言DSL 的编译器,所以这个转换只需要走以下四个步骤

  • 错误分析
  • 生成AST:parse
  • 生成JS AST:transform
  • 转换成函数:generate

前置知识

抽象语法树AST

抽象语法树是源代码语法结构的一种抽象表示

这部分有一点像之前的VNode或者说h函数,把每个节点的信息存储到一个对象中,只不过它包含的信息更多,例如Vue指令

这个AST描述了一段template模板的所有内容,包括但不限于下面这些属性

  • 模板类型(根节点、普通节点、组件等)
  • 子节点
  • 节点标签名称
  • 属性
{
  "type": 0,
  "children": [
    {
      "type": 1,
      "tag": "div",
      "tagType": 0,
      "props": [],
      "children": [
        {
          "type": 2,
          "content": "hello world"
        }
      ]
    }
  ],
  "loc": {}
}

JavaScript AST

这部分是Vue编译过程中生成的

AST生成之后,比起AST主要增加了一些JS代码的信息codegenNode,里面包含了一些和render函数生成相关的属性,例如标签子节点内容等等

{
  "type": 0,
  "children": [
    {
      ......
      "codegenNode": {
        "type": 13,
        "tag": "\"div\"",
        "props": [],
        "children": [
          {
            "type": 2,
            "content": "hello world"
          }
        ]
      }
    }
  ],
  "loc": {},
  "codegenNode": {
    "type": 13,
    "tag": "\"div\"",
    "props": [],
    "children": [
      {
        "type": 2,
        "content": "hello world"
      }
    ]
  },
  "helpers": [null],
  "components": [],
  "directives": [],
  "imports": [],
  "hoists": [],
  "temps": [],
  "cached": []
}

有限状态机

有限状态机(英语:finite-state machine,缩写FSM)又称有限状态自动机(英语:finite-state automaton,缩写FSA),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型

以上维基百科的说明比较难以理解,简单地说,有限状态机的要点有三

  • 状态是有限
  • 某一时刻处于某一种状态
  • 满足一定条件时,可以从一种状态切换到另一种状态

我们以下面这一段代码为例

<div>hello world</div>

这一段代码的有限状态机的状态转换过程分三步,包括

  • 起始标签解析
  • 文本解析
  • 终止标签解析

我们可以理解成,最初处于的状态都是初始状态,当识别到特定的字符后,会进入一个指定的状态,并且循环这个过程,直到所有字符都识别完成

有限状态机状态转换说明图.png

经过这一系列步骤解析,可以获取三个tokens

  • 开始标签:<div>
  • 文本节点:hello world
  • 结束标签:</div>

这个获取tokens的过程,也称之为模板的标记化

用tokens构建AST的方法

这里用了递归下降算法,用了一个数据结构“”构建AST

代码识别出的token.png

用栈构建AST过程.png

AST构建顺序-和栈步骤关联.png

代码实现

框架搭建

阅读Vue3源码可以知道,整个编译器compile本质上是调用了baseCompile这个方法

export function baseCompile(template: string, options) {
  // 1.生成ast对象
  const ast = baseParse(template);
  
  // 2.将ast对象转换成JS AST对象
  transform(ast, extend(options, {
    nodeTransforms: [transformElement, transformText]
  }));
  
  // 3.根据JS AST对象,生成render函数
  return generate(ast);
}

baseCompile这个方法又分成三个大步骤

  • AST对象生成:baseParse方法
  • AST转换成JavaScript AST:transform方法
  • 根据JavaScript AST生成render函数:generate方法

AST对象生成

AST的生成其实就是解析模板文本,获取各个token,并根据获取顺序生成树状结构的过程

阅读Vue3源码可知,整个AST对象生成包括下面这几步

  • 构建context实例(为了后续使用有限状态机解析token
  • 解析模板,扫描出token,生成AST结构
  • AST结构挂载在一个Root节点上返回

context实例构建

context实例的构建其实就是把模板包裹进对象中,拿到这个新包裹的对象,以便后续识别token

export interface ParserContext {
  source: string;
}

function createParserContext(content: string): ParserContext {
  return {
    // source即源代码,用来后续解析token用的
    source: content,
  };
}

function baseParse(context: string) {
  const context = createParserContext(content);
  
  return {}
}

这样处理之后,我们就可以拿到一个包裹源代码的ParserContext对象了,里面的source就是源代码

扫描token生成AST结构

这一步的核心是parseChildren方法,将从左到右逐个识别source,并根据有限状态机的理念,结合当前的状态,来判断具体节点类型

扫描token生成AST的逻辑.png

function parseChildren(context: ParserContext, ancestors) {
  const nodes = [];

  while (!isEnd(context, ancestors)) {
    const s = context.source;

    let node;

    if (startsWith(s, "{{")) {
      // TODO: 模板语法
    } else if (s[0] === "<") {
      // tag标签必须是在<后紧接着一串小写字母
      if (/[a-z]/i.test(s[1])) {
        node = parseElement(context, ancestors);
      }
    }

    if (!node) {
      node = parseText(context);
    }

    pushNode(nodes, node);
  }

  return nodes;
}

其中包含了一些判断方法处理方法,包括

  • 是否是结束标签isEnd
  • 是否是结束标签的开始,补充验证结束标签startsWithEndTagOpen
  • 通用的移动光标方法advanceBy(把已经识别出来的token去掉,等同于识别的光标右移)
// 判断当前标签是否为结束标签
function isEnd(context: ParserContext, ancestors) {
  const s = context.source;

  if (startsWith(s, "</")) {
    // 这里源码也说了效率不高,但是这一步必须补充验证,以防</divhello>这种标签的出现
    for (let i = ancestors.length - 1; i >= 0; i--) {
      if (startsWithEndTagOpen(s, ancestors[i].tag)) {
        return true;
      }
    }
  }

  return !s;
}

// 判断是否为结束标签的开始(例如</div,这一段完整的才是结束标签的开始)
function startsWithEndTagOpen(source: string, tag: string): boolean {
  // 三个条件
  // 1.以</开头
  // 2.从2-tag结束为止截出来的内容,和给的tag一样(确定了同名tag)
  // 3.后面要紧跟有效的结束内容,而不是继续有其他一些文字
  return (
    startsWith(source, "</") &&
    source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() &&
    /[\t\r\n\f />]/.test(source[2 + tag.length] || ">")
  );
}

function startsWith(source: string, searchString: string): boolean {
  return source.startsWith(searchString);
}

function advanceBy(context: ParserContext, numberOfCharacters: number) {
  const { source } = context;
  context.source = source.slice(numberOfCharacters);
}
节点内容的提取处理

节点内容的提取用了parseElement方法,用来解析开始/结束标签,并对节点内的内容用parseChildren递归处理(可能是文本元素

function parseElement(context: ParserContext, ancestors) {
  // 解析标签的tag
  const element = parseTag(context, TagType.Start);

  // 处理子标签
  ancestors.push(element);
  const children = parseChildren(context, ancestors);
  // 因为ancestors仅限于isEnd判断逻辑,所以结束以后要pop出来(参考tokens构建AST的递归下降算法)
  ancestors.pop();

  element.children = children;

  // 结束标签
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End);
  }

  return element;
}
文本内容的提取处理

文本内容提取用了parseText方法,就是把文本节点完整地取出来,之后光标右移做下一个token识别

function parseText(context: ParserContext) {
  // 如果遇到下方的,表示普通文本的结束(结束标签开始/模板语法开始)
  const endTokens = ["<", "{{"];

  // 临时用context的结尾当text的结尾,后面修正正确的结尾位置
  let endIndex = context.source.length;

  // 自后向前比对,找到正确的text结尾位置
  for (let i = 0; i < endTokens.length; i++) {
    const index = context.source.indexOf(endTokens[i], 1);

    if (index !== -1 && endIndex > index) {
      endIndex = index;
    }
  }

  // 提取文本内容,光标右移
  const content = parseTextData(context, endIndex);

  return {
    type: NodeTypes.TEXT,
    content,
  };
}

// 根据文本长度提取source中的文本,并光标右移处理
function parseTextData(context: ParserContext, length: number) {
  const rawText = context.source.slice(0, length);

  advanceBy(context, length);

  return rawText;
}

AST结构挂载到Root节点

这一步比较简单,直接在baseParse方法中,将生成的AST结构挂载到一个新创建的Root节点即可

function baseParse(content: string) {
  const context = createParserContext(content);

  const children = parseChildren(context, []);

  return createRoot(children);
}

function createRoot(children) {
  return {
    type: NodeTypes.ROOT,
    children,
    loc: {},
  };
}

AST转换为JavaScript AST

JavaScript AST和目前生成的AST,最大的区别就是codegen这个属性

而我们知道这个属性其实和render函数直接相关,决定了后面如何渲染

因此,在转换处理的时候要遵守这样两个规则

  • 深度优先:即优先处理子节点,然后才是父节点
    • 父节点的nodeType受到子节点影响,所以必须知道子节点的情况才能知道父节点应该是什么nodeType
  • 转换函数分离
    • 类似于render函数options,这里转换处理方法单独封装,以options传入transform中,便于扩展维护

此外,因为转换函数和transform本体分离,还要创建一个全局通用的上下文context对象

基本框架搭建

baseCompile方法先添加好transform占位,两个参数分别是之前处理好的AST以及optionsoptions中放入转换函数

转换函数包括

  • 节点转换:transformElement
  • 文本转换:transformText
function baseCompile(template: string, options = {}) {
  const ast = baseParse(template);

  transform(
    ast,
    extend(options, {
      nodeTransforms: [transformElement, transformText],
    })
  );
}

之后搭建transform方法框架,其中包括

  • 创建上下文对象context
  • 深度优先转换node节点
  • 处理根节点信息转化
function transform(root, options) {
  const context = createTransformContext(root, options);
  
  traverseNode(root, context);
  
  createRootCodegen(root);
}

上下文对象创建

上下文对象context里面包含了转换节点的方法列表,以及其他一些需要记录的信息(例如当前处理的节点父节点

interface TransformContext {
  root;
  parent: ParentNode | null;
  childIndex: number;
  currentNode;
  helpers: Map<symbol, number>;
  helper<T extends symbol>(name: T): T;
  nodeTransforms: any[];
}

function createTransformContext(root, { nodeTransforms = [] }) {
  const context: TransformContext = {
    nodeTransforms,
    root,
    helpers: new Map(),
    currentNode: root,
    parent: null,
    childIndex: 0,
    helper(name) {
      const count = context.helpers.get(name) || 0;
      context.helpers.set(name, count + 1);
      return name;
    },
  };

  return context;
}

深度优先处理节点

接下来需要深度优先遍历处理节点,核心方法就是traverseNodetraverseChildren

深度优先体现在traverseNodeswitch语句

只有当switch没有执行的时候(即当前处理节点不是ELEMENT/ROOT的时候),才会执行之前存储的节点处理方法,真正给当前节点添加codegenNode属性

function traverseNode(node, context: TransformContext) {
  context.currentNode = node;

  const { nodeTransforms } = context;

  // 存储处理节点的方法
  const exitFns: any = [];

  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context);

    if (onExit) {
      exitFns.push(onExit);
    }
  }

  // 这里先不急着处理当前节点,如果满足switch,先递归看子节点
  switch (node.type) {
    // 处理子节点
    case NodeTypes.ELEMENT:
    case NodeTypes.ROOT:
      traverseChildren(node, context);
      break;
  }

  // 退出阶段,使用存放好的转换节点方法,遍历调用,去处理当前的节点
  context.currentNode = node;
  let i = exitFns.length;
  while (i--) {
    exitFns[i]();
  }
}

// traverseChildren就是遍历子节点再走一遍traverseNode
function traverseChildren(parent, context: TransformContext) {
  parent.children.forEach((node, index) => {
    context.parent = parent;
    context.childIndex = index;
    traverseNode(node, context);
  });
}

转化方法的实现

真正转化节点的方法是最开始transform传入的transformElementtransformText两个方法,下面就实现这两个方法

function baseCompile(template: string, options = {}) {
  ......

  transform(
    ast,
    extend(options, {
      nodeTransforms: [transformElement, transformText],
    })
  );

  ......
}
transformElement实现

transformElement只对ELEMENT节点做处理,最后给节点新增上codegenNode属性

const transformElement = (node, context) => {
  return function postTransformElement() {
    node = context.currentNode;

    // 只有ELEMENT节点才能执行后续逻辑
    if (node.type !== NodeTypes.ELEMENT) {
      return;
    }

    const { tag } = node;
    let vnodeTag = `"${tag}"`;
    let vnodeProps = [];
    let vnodeChildren = node.children;

    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      vnodeChildren
    );
  };
};

createVNodeCall这个方法封装了所有codegenNode属性的创建,并在创建之前往helper里保存一份render函数需要调用的方法的名字

const CREATE_ELEMENT_VNODE = Symbol("createElementVNode");

function createVNodeCall(context, tag, props?, children?) {
  if (context) {
    // 最后触发的render函数中调用方法的名字
    context.helper(CREATE_ELEMENT_VNODE);
  }

  return {
    type: NodeTypes.VNODE_CALL,
    tag,
    props,
    children,
  };
}
transformText实现

transformText核心要处理的是把相邻文本内容给拼接起来成为表达式

例如:hello {{ msg }}要被拼接为hello + _toDisplayString(_ctx.msg)

const transformText = (node, context) => {
  if (
    [
      NodeTypes.ROOT,
      NodeTypes.ELEMENT,
      NodeTypes.FOR,
      NodeTypes.IF_BRANCH,
    ].includes(node.type)
  ) {
    return () => {
      const children = node.children;

      let currentContainer;
      // 遍历所有子节点
      for (let i = 0; i < children.length; i++) {
        const child = children[i];

        if (isText(child)) {
          // 从当前节点的后一个开始遍历
          for (let j = i + 1; j < children.length; j++) {
            const next = children[j];
            // 如果紧接着的节点也是文本节点,要把两个节点合并起来
            if (isText(next)) {
              // 还没有容器的时候,创建一个复合表达式节点
              if (!currentContainer) {
                currentContainer = children[i] = createCompoundExpression(
                  [child],
                  child.loc
                );
              }

              // 把之前的内容和当前的合并
              currentContainer.children.push(` + `, next);
              // 处理好了下一个child,把下一个child删了,光标左移
              children.splice(j, 1);
              j--;
            } else {
              // 紧接着的节点不是文本节点,不需要合并
              currentContainer = undefined;
              break;
            }
          }
        }
      }
    };
  }
};

function createCompoundExpression(children, loc) {
  return {
    type: NodeTypes.COMPOUND_EXPRESSION,
    loc,
    children,
  };
}

// 节点是否是Text的判断方法
function isText(node) {
  return [NodeTypes.INTERPOLATION, NodeTypes.TEXT].includes(node.type);
}

根节点转化

根节点转化其实就是把根节点的codegenNode属性和其childcodegenNode保持一致

function createRootCodegen(root) {
  const { children } = root;

  // Vue2只支持单个根节点,Vue3支持多个,这里只写了处理单个的
  if (children.length === 1) {
    const child = children[0];
    if (isSingleElementRoot(root, child) && child.codegenNode) {
      root.codegenNode = child.codegenNode;
    }
  }
}

// 是否单个根节点判断逻辑:children只有1个,而且children是ELEMENT节点
function isSingleElementRoot(root, child) {
  const { children } = root;

  return children.length === 1 && child.type === NodeTypes.ELEMENT;
}

另外,还需要往根节点上挂载好helpers的方法名称,后续生成render函数的时候需要用到

function transform(root, options) {
  ......
  
  createRootCodegen(root);
  
  root.helpers = [...context.helpers.keys()];
  // 下面的属性如果不添加可能报错,这里就是加上属性而已
  root.components = [];
  root.directives = [];
  root.imports = [];
  root.hoists = [];
  root.temps = [];
  root.cached = [];
}

render函数生成

完成JavaScript AST的构建后,我们只要生成render函数就可以完成编译了,后续就可以直接调用生成的render函数完成渲染

对于生成render函数这个过程,其实我们需要明确一个很重要的概念,就是

函数本身也是一段字符串

例如下面这样的函数

const _Vue = Vue

return function render(ctx, cache) {
  const { createElementNode: _createElementNode } = _Vue
  return _createElementNode("div", [], "hello world")
}

实际上可以分成四个部分

  • 前置内容:const _Vue = Vue
  • 函数名:function render
  • 函数参数:(ctx, cache)
  • 函数体

而这样的四个部分,我们完全可以考虑逐个拼接

至于我们日常写代码的缩进/换行/空格等等内容,可以用\n\t等等替代,甚至封装一些通用方法

明确了这一概念,render函数生成的整个逻辑也就完善了,大方向是两步

  • 拼接生成render函数字符串
  • render函数字符串转换成真正的函数

拼接render函数字符串

拼接render函数字符串的整个逻辑如下图所示

拼接render函数字符串逻辑.png

根据逻辑图我们可以知道,整个函数字符串的拼接可以分为以下几个部分

  • 前置代码生成
  • render方法名和参数拼接
  • render中使用的方法解构重命名
  • 重命名的方法的调用以及参数配置
  • 通用的换行/缩进/取消缩进方法

除此之外,阅读Vue3源码可知,在整个函数拼接开始前,要生成一个context上下文,用来存储当前已经拼接的函数字符串信息(包括通用的缩进/换行方法、缩进信息render函数内部要调用的方法名称等)

codegenContext上下文生成

上下文主要用来存储

  • 已经拼接好的render函数字符串
  • Vue运行时的全局变量名(前置代码生成用)
  • 通用的换行/缩进/取消缩进方法
  • 当前缩进级别
  • render函数内需要调用的方法名称
  • ……
function createCodegenContext(ast) {
  const context = {
    // render函数代码字符串
    code: "",
    // 运行时全局变量名
    runtimeGlobalName: "Vue",
    source: ast.loc.source,
    // 当前缩进的级别(默认0即从头开始,换行后也生效)
    indentLevel: 0,
    isSSR: false,
    helper(key) {
      return `_${helperNameMap[key]}`;
    },
    push(code) {
      context.code += code;
    },
    newline() {
      newline(context.indentLevel);
    },
    indent() {
      newline(++context.indentLevel);
    },
    deindent() {
      newline(--context.indentLevel);
    },
  };

  function newline(n: number) {
    context.code += "\n" + `  `.repeat(n);
  }

  return context;
}
前置代码生成

前置代码就是const _Vue = Vue这一部分了,源码用genFunctionPreamble方法实现,中间顺带把后面的return也放了进去

function genFunctionPreamble(context) {
  const { push, runtimeGlobalName, newline } = context;
  const VueBinding = runtimeGlobalName;
  push(`const _Vue = ${VueBinding}\n`);
  newline();
  push(`return `);
}
render方法名和参数拼接

这部分方法名和参数其实都是固定的,但是为了后续可扩展维护,用变量的形式处理

function generate(ast) {
  ......
  
  const functionName = `render`;
  const args = ["_ctx", "_cache"];
  const signature = args.join(", ");
  push(`function ${functionName}(${signature}) {`);
  indent();
  
  ......
}
render中使用的方法解构&重命名

在生成JavaScript AST的过程中,我们已经存储了render中需要使用的方法到helper中,现在需要的就是给它们取出来,并做一个重命名

重命名的方法可以统一封装,因为逻辑都是一样的,都是把XXX方法前面添加下划线

const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`;

获取方法名并解构的逻辑如下(因为解构完了后面就是方法调用了,return也是固定内容)

function generate(ast) {
  ......

  // 使用方法拼接
  const hasHelpers = ast.helpers.length > 0;
  if (hasHelpers) {
    push(`const { ${ast.helpers.map(aliasHelper).join(", ")} } = _Vue`);
    push(`\n`);
    newline();
  }
  
  // 拼接return的方法
  newline();
  push(`return `);

  ......
}
render中的方法的调用&参数配置

这一部分是最复杂的,也直接关系到render的调用和渲染

首先把这一部分的框架逻辑搭建好

function generate(ast) {
  ......

  // 防止报错,当JavaScript AST存在的时候才去拼接方法和参数
  if (ast.codegenNode) {
    genNode(ast.codegenNode, context);
  } else {
    push(`null`);
  }
  
  // 最后一行,取消缩进,并添加上函数体结束的右半括号
  deindent();
  push("}");

  // 输出拼接好的render函数字符串
  return {
    ast,
    code: context.code,
  };
}

拼接调用方法和参数的核心在于genNode,要根据不同的类型做不同处理

  • 文本类型:直接推入即可
  • 节点类型:因为最终要拼接的是createVNode方法,所以需要获取节点的参数子节点,做一个参数拼接
function genNode(node, context) {
  switch (node.type) {
    case NodeTypes.VNODE_CALL:
      genVNodeCall(node, context);
      break;
    case NodeTypes.TEXT:
      genText(node, context);
      break;
  }
}

// 生成vnode
function genVNodeCall(node, context) {
  const { push, helper } = context;
  const {
    tag,
    props,
    children,
    patchFlag,
    dynamicProps,
    directives,
    isBlock,
    disableTracking,
    isComponent,
  } = node;

  // 从helper拿到vnode生成的函数
  const callHelper = getVNodeHelper(context.isSSR, isComponent);
  push(helper(callHelper) + `(`);

  // 拿到vnode函数的参数
  const args = genNullableArgs([tag, props, children, patchFlag, dynamicProps]);

  // 把参数往vnode生成函数中填充
  genNodeList(args, context);

  push(")");
}

function genText(node, context) {
  context.push(JSON.stringify(node.content), node);
}

function getVNodeHelper(ssr: boolean, isComponent: boolean) {
  return ssr || isComponent ? CREATE_VNODE : CREATE_ELEMENT_VNODE;
}

// 剔除属性值为空的属性
function genNullableArgs(args: any[]) {
  let i = args.length;
  while (i--) {
    if (args[i] != null) break;
  }
  return args.slice(0, i + 1).map((arg) => arg || `null`);
}

// 生成属性list,为createVNode的第二个参数做准备
function genNodeList(nodes, context) {
  const { push, newline } = context;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];

    if (isString(node)) {
      push(node);
    } else if (isArray(node)) {
      genNodeListAsArray(node, context);
    } else {
      genNode(node, context);
    }

    if (i < nodes.length - 1) {
      push(", ");
    }
  }
}

function genNodeListAsArray(nodes, context) {
  context.push("[");
  genNodeList(nodes, context);
  context.push("]");
}
render函数字符串拼接完整实现
function generate(ast) {
  const context = createCodegenContext(ast);
  
  // code拼接的封装方法
  const { push, newline, indent, deindent } = context;

  // 前置代码生成
  genFunctionPreamble(context);

  // 方法名和参数生成拼接
  const functionName = `render`;
  const args = ["_ctx", "_cache"];
  const signature = args.join(", ");
  push(`function ${functionName}(${signature}) {`);
  indent();

  // 使用方法拼接
  const hasHelpers = ast.helpers.length > 0;
  if (hasHelpers) {
    push(`const { ${ast.helpers.map(aliasHelper).join(", ")} } = _Vue`);
    push(`\n`);
    newline();
  }

  // 拼接return的方法
  newline();
  push(`return `);

  // 拼接调用的方法和参数
  if (ast.codegenNode) {
    genNode(ast.codegenNode, context);
  } else {
    push(`null`);
  }

  deindent();
  push("}");

  return {
    ast,
    code: context.code,
  };
}

转换render函数字符串为真实函数

把拼接好的render函数字符串转换成真实函数,只需要用一个Function构造函数即可

new Function(code)

但是我们在使用Vue的时候,并不需要做这种手动处理,这是因为Vue3源码中内部封装好了这个转换过程,单独存放在了vue-compact模块中

import { compile } from "@vue/compiler-dom";

function compileToFunction(template, options?) {
  const { code } = compile(template, options);

  const render = new Function(code)();

  return render;
}

export { compileToFunction as compile };