Vue.js模板编译全流程详解

浏览器并不认识 v-if 和双花括号这些 Vue 语法。模板字符串怎么一步步变成可执行的 render 函数:解析、AST 生成、编译优化,以及运行时编译和预编译各自的适用场景。

位置
第 06 篇 / 共 12 篇
预计
16 分钟

浏览器拿到的从来不是模板,是一个函数

先看结果,再看过程。完整版 Vue 2 把编译器暴露成了 Vue.compile,在控制台里就能跑 (只有完整版有这个方法,运行时版本里它压根不存在,这一点最后一节会说清楚为什么):

浏览器控制台 · 完整版 Vue 2
const { render } = Vue.compile(
'<div id="app"><h1>{{ title }}</h1><button @click="handleClick">点击我</button></div>'
);
console.log(render.toString());

打印出来是这么一个函数(原始产物是一行,这里手工换了行):

Vue 2 · Vue.compile 的产物
function anonymous() {
with (this) {
return _c('div', { attrs: { "id": "app" } }, [
_c('h1', [_v(_s(title))]),
_c('button', { on: { "click": handleClick } }, [_v("点击我")])
])
}
}

三处翻译一眼就能看出来:静态属性 id 落进了 attrs@click 落进了 on,双花括号变成了 _s(title)_c _v _s 是运行时挂在组件实例上的几个短名字函数,最外层的 with (this) 让模板里写的 title 直接指向实例上的 title,不用加 this.

模板编译要干的事,就是把上面那段字符串变成下面这个函数。Vue 的模板编译过程可以分为三个主要阶段:

graph LR
A[模板字符串] --> B[解析 Parse]
B --> C[AST抽象语法树]
C --> D[优化 Optimize]
D --> E[优化后的AST]
E --> F[代码生成 Generate]
F --> G[渲染函数]

解析把字符串扫成一棵 AST;优化在这棵树上盖章,标出哪些节点永远不会变;代码生成把树拼成一段 JavaScript 源码字符串,再交给 new Function 变成真函数。Vue 3 的三步没变,换掉的是产物里那套 helper 的名字,后半段单独讲。Vue 3 那边还有个在线的 Template Explorer,左边贴模板右边直接出产物, 比在控制台里一行行 console.log 顺手。

解析:一边扫字符串,一边用栈拼出一棵树

解析器没有做词法分析加语法分析那一整套,它就是一个游标加一个栈:遇到 < 当标签处理,否则当文本; 开始标签入栈,闭合标签出栈,栈顶永远是「当前的父节点」。

Vue 2 的实现分在两个文件里(路径以 2.6 为准,2.7 把源码整个换成了 TypeScript,同名的 .ts)。 src/compiler/parser/html-parser.js 里的 parseHTML 只负责切,游标靠 advance(n) 往前挪, 切出来的东西通过 start / end / chars 三个回调交出去;src/compiler/parser/index.js 里的 parse 接住这些回调,用一个 stack 把父子关系接上,AST 就是这么长出来的。

下面这段是同一套骨架的简化版,砍掉了自闭合标签、注释、<pre> 这些分支:

简化版解析器 · 主循环与标签栈
class HTMLParser {
constructor(template) {
this.template = template;
this.currentIndex = 0;
this.stack = []; // 栈顶永远是当前的父节点
}
parse() {
const ast = { type: 'Root', children: [] };
this.stack.push(ast);
while (this.currentIndex < this.template.length) {
if (this.template[this.currentIndex] === '<') this.parseTag();
else this.parseText();
}
return ast;
}
handleOpeningTag(tagName) {
7 collapsed lines
const element = {
type: 'Element',
tag: tagName,
attributes: [],
directives: [],
children: []
};
// 省略:属性解析。v- 开头的进 directives,: 和 @ 各有一条分支
this.parseAttributes(element);
this.stack[this.stack.length - 1].children.push(element);
if (!this.isSelfClosing(tagName)) this.stack.push(element);
}
// 省略:parseTag 区分开闭标签、parseText 切文本、闭合标签把栈顶弹出
}

文本节点里的双花括号由另一支正则接手,Vue 2 把它放在 src/compiler/parser/text-parser.js,匹配用的是 /\{\{((?:.|\r?\n)+?)\}\}/g。它是非贪婪的, 所以 {{ a }} 和 {{ b }} 会被切成三段(表达式、纯文本、表达式),而不是从第一个 {{ 一路吃到 最后一个 }}

有一个坑和这个解析器无关,却总被记在它头上:模板如果直接写在 HTML 文件里(el 挂一个页面上已经 存在的节点),那它先被浏览器的 HTML 解析器过了一遍,才轮到 Vue。浏览器会把 <table> 里不认识的 自定义标签整个搬到表格外面去,也会把属性名统一抹成小写,myProp 到 Vue 手上已经变成了 myprop。 写在 .vue 文件或者 JS 字符串里的模板不经过这一步,所以同一段模板换个地方放,表现可能就不一样了。 表格那种位置受限的地方,用 is 绕开。

解析完的元素节点身上挂着后面两步要用的全部信息。真正决定产物长什么样的是这几个字段:

AST 字段 从哪来 谁在用
tag / attrsList / attrsMap 标签名和属性 genDataattrs
if / ifConditions v-if v-else-if v-else genIf 拼三元表达式
for / alias / iterator1 v-for="(item, i) in items" genFor_l(...)
key :key genData 写进 data.key
events / nativeEvents @click / @click.native genHandlerson:
static / staticRoot 下一步盖的章 genStatic 决定要不要 _m()

优化:给 AST 盖两种章

优化阶段的主要目的是标记静态节点,这样在后续的更新过程中可以跳过这些节点的比对:

Vue 2 · optimizer(简化)
// 第一遍,自底向上:一个子节点是动态的,父节点就不可能是静态的
markStatic(node) {
node.static = this.isStatic(node);
if (node.type !== 'Element') return;
for (const child of node.children) {
this.markStatic(child);
if (!child.static) node.static = false;
}
// 省略:v-else-if / v-else 的分支块要各走一遍
}
isStatic(node) {
if (node.type === 'Interpolation') return false; // 插值一定是动态的
if (node.type === 'Text') return true; // 纯文本一定是静态的
return !!(
!node.hasBindings && // 没有 v- : @ 开头的绑定
!node.if && !node.for && // 没有 v-if / v-for
!isBuiltInTag(node.tag) && // 不是 slot / component
isPlatformReservedTag(node.tag) && // 是 div 这类平台标签,不是组件
!isDirectChildOfTemplateFor(node) && // 不是 template + v-for 的直接子节点
Object.keys(node).every(isStaticKey) // 身上没有别的动态字段
);
}
// 第二遍,自顶向下:只有一个文本子节点的静态节点,不算根
markStaticRoots(node) {
if (node.static && node.children.length &&
!(node.children.length === 1 && node.children[0].type === 'Text')) {
node.staticRoot = true;
return; // 已经是根了,子树不必再找
}
node.staticRoot = false;
for (const child of node.children) this.markStaticRoots(child);
}

两遍扫的分工很清楚:第一遍标 static,回答「这个节点会不会变」;第二遍标 staticRoot,回答 「从哪个节点开始整棵提出去」。第二遍那个额外的条件是全篇最值得琢磨的一行 —— 一个静态节点如果只包着一段文本,提升它省下的只是一个文本 VNode,换来的却是 staticRenderFns 里多一项、多一次函数调用和一份缓存。不划算,所以宁可每次重建 —— 源码里那行注释写得很直白: 「the cost of hoisting out will outweigh the benefits」。

staticRoot 的用处在代码生成那一步:这棵子树会被单独编译成一个函数塞进 staticRenderFns 数组, 原地只留一个 _m(index)_m 第一次执行时创建整棵子树,之后从 _staticTrees 缓存里取回同一个 VNode 对象 —— 注意是同一个对象,不是一份拷贝。所以下次 patch 走到这里时, patchVnode 开头那句 if (oldVnode === vnode) return 直接就把整棵子树跳过了,连比都不用比。

Vue 3 没有这么一遍独立的 optimizer。静态分析被折进了 transform 阶段,和指令处理、静态提升一起 走完,做的事也从「在 AST 上盖章」变成了「直接改产物长什么样」。后面两节讲的就是它改出来的两样东西。

代码生成:AST 拼成一段字符串,再交给 new Function

最后一步是把 AST 拼成一段字符串。注意它拼出来的不是 AST 的翻译,而是一串嵌套的函数调用:

Vue 2 · codegen(简化)
generate() {
const code = this.ast ? this.genElement(this.ast) : '_c("div")';
return { render: `with(this){return ${code}}`, staticRenderFns: this.staticRenderFns };
}
genElement(el) {
if (el.staticRoot && !el.staticProcessed) return this.genStatic(el);
if (el.for && !el.forProcessed) return this.genFor(el); // v-for 比 v-if 先展开
if (el.if && !el.ifProcessed) return this.genIf(el);
const data = this.genData(el);
const children = this.genChildren(el);
return `_c('${el.tag}'${data ? `,${data}` : ''}${children ? `,${children}` : ''})`;
}
genStatic(el) {
el.staticProcessed = true; // 不打这个标记,下一行会无限递归回自己
this.staticRenderFns.push(`with(this){return ${this.genElement(el)}}`);
return `_m(${this.staticRenderFnIndex++})`;
}

genElement 的分支顺序不是随便排的:v-for 排在 v-if 前面,意味着同一个元素上两个都写时, 展开出来的是「循环在外、判断在内」—— 每一项都要跑一遍判断。这就是那条「别在同一个元素上同时用 v-forv-if」的建议真正的出处,它是编译顺序决定的,不是风格问题。

产物里那些一个字母的函数,是运行时装在组件实例上的 —— src/core/instance/render-helpers/index.js 里的 installRenderHelpers 一口气挂十几个:

产物里 真名 干什么
_v createTextVNode 建文本 VNode
_s toString 插值求完值转字符串,null 转成空串
_e createEmptyVNode 建注释节点,v-if 不成立时占位
_m renderStatic staticRenderFns[i],带缓存
_l renderList v-for
_t renderSlot <slot>
_u resolveScopedSlots 作用域插槽

出现次数最多的 _c 反而不在这张表里。它是在 src/core/instance/render.jsinitRender 里 单独挂的,和手写 render 函数拿到的 $createElement 是同一个 createElement, 只差最后一个参数:_c 传的是 alwaysNormalize: false$createElementtrue。 编译器知道自己生成了什么形状的 children,用不着运行时再规范化一遍;手写的就没这个保证。

genDatadata 对象一个字段一个字段拼出来,事件那一支值得单看:@click="handleClick" 是个光秃秃的方法名,产物里就直接引用 handleClick;写成 @click="count++" 这种表达式, 才会包一层 function($event){ count++ }。两者的差别只有多不多这一层包装,没有别的。

拼完的还只是字符串,最后一步是 new Function(code) 把它变成函数。Vue 2 在这里加了一层缓存 (compileToFunctions),key 就是模板字符串本身,同一段模板编译一次就够了。也正是这一句 new Function 决定了运行时编译的一个硬限制 —— 它是动态求值,最后一节会回到这件事。

v-if 编译成三元,v-for 编译成一次 map

v-ifv-for 在运行时根本没有对应的实现,它们在编译期就被展开成了普通的 JavaScript。 v-model 是另一种:一半在编译期展开,一半留给运行时。

Vue 2 · 三个常用指令的产物
// <p v-if="score >= 90">优秀</p><p v-else-if="score >= 60">及格</p><p v-else>不及格</p>
(score >= 90)
? _c('p', [_v("优秀")])
: (score >= 60)
? _c('p', [_v("及格")])
: _c('p', [_v("不及格")])
// <li v-for="(item, index) in items" :key="item.id">{{ index }}: {{ item.name }}</li>
_l((items), function (item, index) {
return _c('li', { key: item.id }, [_v(_s(index) + ": " + _s(item.name))])
})
// <input v-model="message">
_c('input', {
directives: [{ name: "model", rawName: "v-model", value: (message), expression: "message" }],
domProps: { "value": (message) },
on: { "input": function ($event) {
if ($event.target.composing) return;
message = $event.target.value;
} }
})

v-if 就是一个三元表达式。写了 v-else 就填对面那一支,没写的话填的是 _e() —— 一个空注释节点,浏览器里 <!----> 那一行就是它,占着位置好让下次 diff 认得出该在哪里插回来。 顺带对照一下 v-show:它根本不进这个三元,产物里只多一条 directives 记录,运行时改的是 style.display,元素从头到尾都待在 DOM 里。两者的取舍就落在这一行差别上。

v-model 是「语法糖」这个词最实在的一个例子:一半变成 domProps.value,一半变成 on.input,两边合起来才是双向绑定。composing 那一行是给输入法留的门 —— 拼音打到一半时 input 事件照样在触发,不挡住的话,候选词还没选中就把半成品写回数据了。 另外还留了一条 directives 记录,是给运行时那个真正的 v-model 指令用的 —— <select> 那种要等 option 都渲染出来才能同步选中项的情况,domProps 表达不了,归它管。

静态提升:Vue 3 把不变的节点挪出了 render 函数

Vue 2 的 _m() 已经是缓存,但缓存这个动作发生在运行时 —— 第一次渲染仍然要执行一遍那棵子树的 渲染函数。Vue 3 把它挪到了编译期:静态节点的创建语句直接被提到 render 函数外面, 成为模块作用域里的一个常量。

graph TB
A[模板代码] --> B{静态分析}
B --> C[静态节点]
B --> D[动态节点]
C --> E[提升到渲染函数外部]
D --> F[保留在渲染函数内部]
E --> G[只创建一次]
F --> H[每次渲染都创建]

同一段模板,两边的产物:

同一段模板在 Vue 2 / Vue 3 下的产物(节选)
// 模板:<div><span class="static-text">静态文本</span><p>{{ dynamicText }}</p></div>
// Vue 2:span 是静态节点,但它只包着一段文本,够不上 staticRoot,
// 所以没有 _m(),每次 render 都重新建一遍
with (this) {
return _c('div', [
_c('span', { staticClass: "static-text" }, [_v("静态文本")]),
_c('p', [_v(_s(dynamicText))])
])
}
// Vue 3(3.2 ~ 3.4 的形态):span 的创建语句被提到了 render 外面,函数里只剩一个引用
const _hoisted_1 = /*#__PURE__*/ _createElementVNode(
"span", { class: "static-text" }, "静态文本", -1 /* HOISTED */
)
function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1,
_createElementVNode("p", null, _toDisplayString(_ctx.dynamicText), 1 /* TEXT */)
]))
}

提升之后,_hoisted_1 在模块加载时创建一次,render 每次执行都返回同一个 VNode 对象,于是 patch 开头那句 if (n1 === n2) return 把它跳过 —— 和 Vue 2 靠 _m 缓存达成的是同一件事, 只是省掉了第一次的构建。/*#__PURE__*/ 是写给打包器看的,意思是这个调用没有副作用, 整个组件被摇掉时它可以一起摇掉。

顺带说一句 Vue 3 产物里没有 with。ES 模块是严格模式,with 在里面是语法错误,所以模板里的 表达式被改写成了显式的 _ctx.xxx。Vue 2 走 vue-loader 预编译时也做同样的改写, 只不过前缀叫 _vm —— 本文开头那段带 with 的产物,只有在浏览器里现编时才长那样。

这段产物的形状跟版本关系不小,对着自己项目里的版本看才不会对不上号。3.2 之前元素和组件共用 _createVNode_createElementVNode 是 3.2 才拆出来的;3.5 起这件事又换了做法 —— 静态 vnode 不再提到模块顶层,改成在 render 函数里用 _cache[0] 惰性建一次,尾巴上的注释 也从 HOISTED 变成了 CACHED(静态 props 对象倒是照旧提升,还叫 _hoisted_1)。 换的是时机,不换目的:这个 vnode 全程只创建一次。

优化的边界倒是一直没变:节点身上有任何一个绑定就提不动了,加一个 :class 就退回函数内部。 连成一片的静态节点还会被进一步拼成 HTML 字符串,用 _createStaticVNode 一次性塞进去 —— 触发条件同样跟版本有关,别当成稳定行为来依赖。

PatchFlag 把「哪里会变」写进了产物

Vue 2 的运行时不知道一个节点哪里会变,patch 的时候 class、style、attrs、domProps、事件得挨个比 一遍。Vue 3 在编译期就知道了 —— 模板是静态可分析的,哪个属性是绑定的编译器一清二楚, 于是它把结论编成一个整数跟在 VNode 后面。

名字 意思
1 TEXT 只有文本内容会变
2 CLASS 只有 class 会变
4 STYLE style 会变
8 PROPS 有动态属性,具体哪几个跟在后面的数组里
16 FULL_PROPS 属性的 key 本身是动态的,只能全量比
64 STABLE_FRAGMENT 子节点顺序不会变的 fragment
128 KEYED_FRAGMENT 带 key 的 fragment
-1 HOISTED 静态提升出去的节点(3.5 起改叫 CACHED
-2 BAIL 放弃优化,退回全量比对

值都是 2 的幂,为的是能用位或叠起来:产物里看到 10,就是 CLASS | PROPS。负数不参与叠加, 它们是两个哨兵,读到就走另一条路。名字则改过几轮 —— 32 那一位在 3.3.9 之前叫 HYDRATE_EVENTS,之后叫 NEED_HYDRATION,翻旧文章时对不上号多半是这个原因。

Vue 3 · 带 PatchFlag 的产物(节选)
// 模板:<div :id="divId" :class="divClass">
// <span>静态文本</span><p>{{ dynamicText }}</p>
// </div>
function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", {
id: _ctx.divId,
class: _normalizeClass(_ctx.divClass)
}, [
_hoisted_1,
_createElementVNode("p", null, _toDisplayString(_ctx.dynamicText), 1 /* TEXT */)
], 10 /* CLASS, PROPS */, ["id"]))
}

运行时拿到 10["id"],就只比 class 和 id 两样,别的碰都不碰;拿到 1 就只更新文本内容。 _openBlock()_createElementBlock 是配套的另一半:它俩把这个块里所有带 PatchFlag 的后代 收集进一个扁平的 dynamicChildren 数组,patch 时直接遍历这个数组,中间那些纯静态的层级根本不用 递归下去。

代价写在 FULL_PROPSBAIL 这两行里。一旦模板出现 v-bind="obj" 这种编译期看不出 key 的写法, 标记就退化成全量比对;而手写 render 函数和 JSX 从一开始就拿不到这份收益 —— 它们绕过了编译器, 运行时只能当作最坏情况处理。用 JSX 换来的是表达能力,换掉的是这一整套编译期优化。

运行时编译还是预编译,取决于模板在什么时候才确定

graph LR
A[Vue模板] --> B{编译时机}
B --> C[运行时编译]
B --> D[预编译]
C --> E[浏览器中编译]
C --> F[包含编译器代码]
C --> G[体积较大]
C --> H[首次渲染慢]
D --> I[构建时编译]
D --> J[不含编译器]
D --> K[体积较小]
D --> L[渲染性能好]

运行时编译要引完整版,编译器跟着一起发到浏览器:

运行时编译 · 模板留到浏览器里才编
import Vue from 'vue/dist/vue.js'; // 完整版,带编译器
new Vue({
el: '#app',
template: '<div><h1>{{ title }}</h1></div>',
data: { title: '运行时编译' }
});

预编译则是让 vue-loader 在构建时把 .vue 文件里的 <template> 编掉,产物里没有模板字符串:

预编译 · vue-loader 的产物(示意)
var render = function () {
var _vm = this, _c = _vm._self._c;
return _c('div', [_c('h1', [_vm._v(_vm._s(_vm.title))])])
};
var staticRenderFns = [];
export default { render, staticRenderFns, data() { return { title: '预编译' } } };

两边的取舍:

  • 运行时编译的代价有三层:包里带着整个编译器;首屏多一次编译;产物是 new Function 造出来的,页面的 CSP 禁掉 unsafe-eval 时它直接报错,这一条最容易到上线才发现。
  • 预编译换来的限制是模板必须在构建时就已经定死template 选项里拼字符串、 从接口拉一段模板回来渲染,这两件事只有完整版做得到。
  • 体积差多少:Vue 2 的安装文档给的口径是运行时版本比完整版「roughly 30% lighter-weight」。 想要你这个版本的确切数字,去 node_modules/vue/dist 底下 ls -lvue.min.jsvue.runtime.min.js 的差额就是编译器的重量。 Vue 3 里这条界线在入口文件上:默认引到的是不带编译器的那份,要运行时编译得显式指到 vue/dist/vue.esm-bundler.js

绝大多数项目用的是预编译,编译器只在构建机器上跑过一次,浏览器拿到的从头到尾就是一串建 VNode 的函数调用。执行一遍得到一棵 VNode 树 —— 这棵树和上一次那棵怎么比、差异怎么落到真实 DOM 上, 是 diff 的事。

这篇是 Vue.js 内部机制深度解析的第 6 篇。前一篇是 Virtual DOM 实现详解,后一篇是 Diff 算法深度剖析 - Vue.js 的 DOM 更新魔法