Vue.js模板编译全流程详解
浏览器并不认识 v-if 和双花括号这些 Vue 语法。模板字符串怎么一步步变成可执行的 render 函数:解析、AST 生成、编译优化,以及运行时编译和预编译各自的适用场景。
浏览器拿到的从来不是模板,是一个函数
先看结果,再看过程。完整版 Vue 2 把编译器暴露成了 Vue.compile,在控制台里就能跑
(只有完整版有这个方法,运行时版本里它压根不存在,这一点最后一节会说清楚为什么):
const { render } = Vue.compile( '<div id="app"><h1>{{ title }}</h1><button @click="handleClick">点击我</button></div>');console.log(render.toString());打印出来是这么一个函数(原始产物是一行,这里手工换了行):
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 |
标签名和属性 | genData 拼 attrs |
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 |
genHandlers 拼 on: |
static / staticRoot |
下一步盖的章 | genStatic 决定要不要 _m() |
优化:给 AST 盖两种章
优化阶段的主要目的是标记静态节点,这样在后续的更新过程中可以跳过这些节点的比对:
// 第一遍,自底向上:一个子节点是动态的,父节点就不可能是静态的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 的翻译,而是一串嵌套的函数调用:
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-for 和 v-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.js 的 initRender 里
单独挂的,和手写 render 函数拿到的 $createElement 是同一个 createElement,
只差最后一个参数:_c 传的是 alwaysNormalize: false,$createElement 传 true。
编译器知道自己生成了什么形状的 children,用不着运行时再规范化一遍;手写的就没这个保证。
genData 把 data 对象一个字段一个字段拼出来,事件那一支值得单看:@click="handleClick"
是个光秃秃的方法名,产物里就直接引用 handleClick;写成 @click="count++" 这种表达式,
才会包一层 function($event){ count++ }。两者的差别只有多不多这一层包装,没有别的。
拼完的还只是字符串,最后一步是 new Function(code) 把它变成函数。Vue 2 在这里加了一层缓存
(compileToFunctions),key 就是模板字符串本身,同一段模板编译一次就够了。也正是这一句
new Function 决定了运行时编译的一个硬限制 —— 它是动态求值,最后一节会回到这件事。
v-if 编译成三元,v-for 编译成一次 map
v-if 和 v-for 在运行时根本没有对应的实现,它们在编译期就被展开成了普通的 JavaScript。
v-model 是另一种:一半在编译期展开,一半留给运行时。
// <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[每次渲染都创建]同一段模板,两边的产物:
// 模板:<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,翻旧文章时对不上号多半是这个原因。
// 模板:<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_PROPS 和 BAIL 这两行里。一旦模板出现 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> 编掉,产物里没有模板字符串:
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 -l,vue.min.js和vue.runtime.min.js的差额就是编译器的重量。 Vue 3 里这条界线在入口文件上:默认引到的是不带编译器的那份,要运行时编译得显式指到vue/dist/vue.esm-bundler.js。
绝大多数项目用的是预编译,编译器只在构建机器上跑过一次,浏览器拿到的从头到尾就是一串建 VNode 的函数调用。执行一遍得到一棵 VNode 树 —— 这棵树和上一次那棵怎么比、差异怎么落到真实 DOM 上, 是 diff 的事。
这篇是 Vue.js 内部机制深度解析的第 6 篇。前一篇是 Virtual DOM 实现详解,后一篇是 Diff 算法深度剖析 - Vue.js 的 DOM 更新魔法。