前端知库

Vue 模板编译的过程是怎样的?parse、transform、generate 各做什么?

Vue 把模板编译成返回虚拟 DOM 的渲染函数,分三步:parse 把模板字符串解析成模板 AST;transform 遍历 AST,处理 v-if、v-for、v-on 等指令,标记动态内容,并为每个节点准备代码生成用的信息;generate 把处理好的 AST 拼成渲染函数的代码字符串。编译可以在构建时完成,也可以在浏览器里用运行时编译器完成。

参考答案

简答

我们写的是 <p :class="cls">{{ msg }}</p> 这样的模板,浏览器不认识它,Vue 运行时也不直接执行它。Vue 官方文档的说法是,模板会先被编译成渲染函数,也就是返回虚拟 DOM 树的函数。这一步可以在构建时提前做,也可以在浏览器里用运行时编译器即时做。

编译分三步,源码 compiler-core 的 baseCompile 里就是依次调用这三个函数。baseParse 把模板字符串解析成一棵模板 AST,记录每个元素的标签、属性、指令、文本和插值。transform 遍历这棵 AST,用一组转换插件处理 v-if、v-for、v-on、v-bind、v-model 等语法,并给每个节点生成代码生成需要的描述。generate 再把处理好的 AST 拼接成渲染函数的代码字符串。

编译期优化也是在 transform 里做的:它判断哪些节点永远不变,可以缓存;哪些节点的哪部分会变,打上 PatchFlags 这样的标记;再用 Block 收集动态节点。运行时据此跳过不会变的内容。

要注意,用 .vue 单文件组件时,模板在构建阶段就编译好了,官方默认的工具链用的是不含编译器的运行时版本;只有在浏览器里直接写模板时,才需要带编译器的完整版本,官方文档说它会让体积增加约 14kb。

先看编译结果

用 @vue/compiler-dom 3.5.44 编译 <div><h1>标题</h1><p :class="cls">{{ msg }}</p></div>,选项为 mode: 'module'、prefixIdentifiers: true、hoistStatic: true,得到:

JavaScript
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, normalizeClass as _normalizeClass, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("h1", null, "标题", -1 /* CACHED */)),
    _createElementVNode("p", {
      class: _normalizeClass(_ctx.cls)
    }, _toDisplayString(_ctx.msg), 3 /* TEXT, CLASS */)
  ]))
}

模板变成了一个普通的 JavaScript 函数。静态的 <h1> 被存进 _cache[0],以后不再重新创建;<p> 末尾的 3 是 PatchFlags,表示只有文本和 class 会变;根节点用 _createElementBlock 创建,这就是 Block。这些都是三个阶段合作的产物,下面一个个看。

parse:把字符串变成树

parse 逐个字符扫描模板,认出开始标签、属性、文本、插值和结束标签,组装成一棵树。vuejs/core 里负责扫描的是 tokenizer.ts,文件头注明它改编自 htmlparser2。

用 3.5.44 的 baseParse 解析 <p v-if="ok" :class="cls">{{ msg }}</p>,根节点下的 p 是一个元素节点,props 里有两项,名字分别是 if 和 bind,子节点是一个插值节点,内容是 msg。这时的 AST 只是如实记录了模板写了什么,v-if 还只是一个属性,还没有变成条件判断,根节点的 codegenNode 也是 undefined。

transform:理解语法,准备生成代码

transform 才是编译器真正“理解”模板的地方。getBaseTransformPreset 返回的是一组节点转换插件,包括 transformIf、transformFor、transformElement、transformText 等,以及处理 v-on、v-bind、v-model 的指令转换插件。

traverseNode 遍历时,会对每个节点依次调用这些插件。插件可以返回一个退出函数,等这个节点的所有子节点都处理完,再按相反的顺序执行退出函数。为什么要等子节点?因为像 transformElement 要判断一个元素是不是静态的、该打什么 PatchFlags,必须先知道它的子节点处理成了什么样。

同一个 v-if 的例子,transform 之后根节点的 codegenNode 变成了条件表达式类型,对应生成代码里的 ok ? ... : ...。transform 还会记下渲染函数需要从运行时导入哪些函数,比如 toDisplayString、createElementBlock。传了 hoistStatic 时,遍历完成后还会调用 cacheStatic,把静态节点标记出来缓存。

generate:拼出代码

到了 generate,需要的信息都已经在 AST 的 codegenNode 上了,它做的事情比较机械:先写导入语句,再写 render 函数,然后按节点描述递归输出 _createElementVNode(...)、三元表达式、数组等代码,最后返回代码字符串。

下面用一个只支持“元素里放文本和插值”的玩具编译器把三步串起来。它和 Vue 的真实实现差得很远,只用来看每一步的输入和输出:

JavaScript
function parse(template) {
  const match = template.match(/^<(\w+)>(.*)<\/\1>$/);
  const children = match[2].split(/(\{\{.*?\}\})/).filter(Boolean).map((part) => {
    const exp = part.match(/^\{\{\s*(.+?)\s*\}\}$/);
    return exp ? { type: 'INTERPOLATION', content: exp[1] } : { type: 'TEXT', content: part };
  });
  return { type: 'ELEMENT', tag: match[1], children };
}

function transform(ast) {
  // 补上生成代码需要的信息:用到哪些运行时函数、节点有没有动态内容
  const helpers = new Set(['createElementVNode']);
  const dynamic = ast.children.some((child) => child.type === 'INTERPOLATION');
  if (dynamic) helpers.add('toDisplayString');
  ast.codegen = {
    helpers: [...helpers],
    patchFlag: dynamic ? '1 /* TEXT */' : null,
    children: ast.children.map((child) =>
      child.type === 'TEXT' ? JSON.stringify(child.content) : `toDisplayString(_ctx.${child.content})`,
    ),
  };
  return ast;
}

function generate(ast) {
  const { helpers, patchFlag, children } = ast.codegen;
  const args = [JSON.stringify(ast.tag), 'null', children.join(' + ')];
  if (patchFlag) args.push(patchFlag);
  return `import { ${helpers.join(', ')} } from "vue"\n` +
    `export function render(_ctx) {\n  return createElementVNode(${args.join(', ')})\n}`;
}

const ast = parse('<p>你好,{{ name }}</p>');
console.log(ast.children.map((child) => child.type).join(','));
console.log(generate(transform(ast)));

用 Node.js 22 运行,输出是:

text
TEXT,INTERPOLATION
import { createElementVNode, toDisplayString } from "vue"
export function render(_ctx) {
  return createElementVNode("p", null, "你好," + toDisplayString(_ctx.name), 1 /* TEXT */)
}

parse 只认出了两个子节点,一个文本、一个插值。transform 判断出有动态内容,于是记下要导入 toDisplayString,并给节点打上 TEXT 标记。generate 照着这些信息把代码拼出来。

编译发生在什么时候

Vue 官方文档说明,编译可以在构建时提前完成,也可以在运行时用编译器即时完成。用 Vite 加 @vitejs/plugin-vue 写单文件组件时,模板在构建阶段就被编译成了渲染函数,浏览器拿到的已经是 JavaScript,不需要再带编译器。

如果不用构建步骤,直接在 HTML 里写模板,或者用内联的 JavaScript 字符串写模板,就要在浏览器里编译。官方文档说明,文件名以 vue.runtime.* 开头的是运行时版本,不含编译器;不带 .runtime 的是完整版本,含编译器,体积大约多 14kb。

常见追问

为什么 Vue 要先编译,不直接写渲染函数?

也可以直接写渲染函数或 JSX。可是官方文档指出,模板的语法更确定,编译器容易做静态分析,所以能做很多编译期优化,这是 Vue 的虚拟 DOM 比纯运行时方案省事的原因。具体的优化见《Vue 模板编译如何减少运行时更新工作?》。

编译出来的渲染函数什么时候执行?

组件挂载时,运行时调用渲染函数得到虚拟 DOM 树并创建真实 DOM,这个过程放在一个响应式 effect 里,会追踪用到的数据。数据变了就重新执行渲染函数,得到新的虚拟 DOM 再和旧的比较,见《Vue 3 的快速 diff 是怎样用最长递增子序列减少移动的?》。