前言
写本文的背景 《鸽了六年的某大厂面试题:你会手写一个模板引擎吗?》
阅读本文前请务必先阅读解析篇 《手写 Vue 模板编译(解析篇)》
复习
在前文 《手写 Vue 模板编译(解析篇)》 中,我们已经知道,模板编译的过程分为三步:解析、优化、生成。
- 解析 parse:在这一步,Vue 会解析模板字符串,并生成对应的 AST
- 优化 optimize:这个阶段主要是通过标记静态节点来进行语法树优化。
- 生成 generate:利用前面生成 AST(抽象语法树)转换成渲染函数(render function)。
在前文中,我们已经学习了如何生成 AST 接下来我们需要学习如何 optimize 和 generate。
optimize:优化 AST
第一步:标记静态节点
如上文所说,这个阶段主要是通过标记静态节点来进行语法树优化,在进行优化后,Vue 会在 AST 中标记某个节点是否为静态节点。
在上一节生成 AST 中,我们定义了节点类型:
- 元素节点 type=1
- 表达式节点 type=2
- 文本节点 type=3
在不存在子节点时:
- 首先文本节点必为静态节点,因为文本内容固定不变。
- 其次表达式则必不为静态节点,因为它的值依赖于引用的表达式。
- 最后普通节点,如果有
v-if或v-for指令就是动态节点,否则是静态节点 注:实际 Vue 中还有v-bind等指令也会让节点变为动态,这里简化处理
js
/**
* 判断一个节点是否为静态节点
*/
function isStatic(node) {
// 如果节点是表达式节点,则不是静态节点
if (node.type === 2) {
return false
}
// 如果节点是文本节点,则是静态节点
if (node.type === 3) {
return true
}
// 如果节点没有 v-if 和 v-for 指令,则是静态节点
return !node.if && !node.for
}
对于有子节点的情况:父节点必须满足以下两个条件才能成为静态节点:
- 自身是静态节点(没有
v-if、v-for等动态指令) - 所有子节点都是静态节点
接下来我们处理节点树
js
/**
* 标记一个节点是否为静态节点
*/
function markStatic(node) {
// 先用 isStatic 判断当前节点自身是否为静态
node.static = isStatic(node)
// 如果是元素节点,需要检查子节点
if (node.type === 1) {
// 遍历所有的子节点
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
// 先递归处理子节点
markStatic(child)
// 只要有一个子节点是动态的,父节点也必须是动态的
if (!child.static) {
node.static = false
}
}
}
}
第二步:标记静态根节点
接下来是 Vue 优化系统的另一个关键部分 markStaticRoots。
markStaticRoots 函数用于标记静态根节点,被标记为静态根节点的元素及其子树会在代码生成阶段被特殊处理:
- 提升为常量 :将其渲染代码提升到
staticRenderFns数组中,只生成一次 - 跳过 patch:更新时直接复用,不需要重新创建和对比 VNode
- 性能提升:减少运行时的计算开销
js
/**
* 标记静态根节点
* @param {Object} node - AST 节点
*/
function markStaticRoots(node) {
if (node.type === 1) {
// 只有元素节点才处理
// 判断是否为静态根节点:必须是静态节点 + 有子节点
if (node.static && node.children.length) {
node.staticRoot = true
// 找到静态根后直接返回,子节点会被整体提升,无需继续遍历
return
} else {
node.staticRoot = false
}
// 递归处理所有子节点
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
markStaticRoots(node.children[i])
}
}
// 处理 v-if 的其他分支(v-else-if、v-else)
// 注意:从 i=1 开始,因为 ifConditions[0] 就是当前节点
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
markStaticRoots(node.ifConditions[i].block)
}
}
}
}
现在我们来实现 optimize
js
function optimize(root) {
if (!root) return
// 第一步:标记静态节点
markStatic(root)
// 第二步:标记静态根
markStaticRoots(root)
}
举个例子来看看效果:
js
// 要处理的模板字符串
const str = `<div><h1 v-if="true">hello</h1><h2>cookie</h2></div>`
// 解析为 ast
const ast = parse(str, {
// ...
})
// 优化 ast
optimize(ast)
console.dir(ast, {
depth: null,
})
打印结果:
js
<ref *2> {
type: 1,
tag: 'div',
attrsList: [],
attrsMap: {},
children: [
<ref *1> {
type: 1,
tag: 'h1',
attrsList: [],
attrsMap: { 'v-if': 'true' },
children: [ { type: 3, text: 'hello', static: true } ],
if: 'true',
ifConditions: [ { exp: 'true', block: [Circular *1] } ],
parent: [Circular *2],
static: false,
staticRoot: false
},
{
type: 1,
tag: 'h2',
attrsList: [],
attrsMap: {},
children: [ { type: 3, text: 'cookie', static: true } ],
parent: [Circular *2],
static: true,
staticRoot: true
}
],
static: false,
staticRoot: false
}
可以观察到 <h2> 节点被标记了静态根节点。
生成代码前置知识
1、new Function
我们知道创建函数除了常用的函数声明、函数表达式、箭头函数以外,还有一个不常用的:构造函数。语法如下:
js
new Function(corpsFonction)
new Function(arg1, corpsFonction)
new Function(arg1, ...argN, corpsFonction)
// 例:
const addFn = new Function('a', 'b', 'return a + b')
const sum = addFn(1, 2) // 3
argN:可选,零个或多个,函数形参的名称,每个名称都必须是字符串corpsFonction:一个包含构成函数定义的 JavaScript 语句的字符串。
2、with 语句
with 可以将一个对象的属性添加到作用域链的顶部,让我们在代码块内直接访问对象的属性。
js
with (对象) {
// 在这里可以直接访问对象的属性
}
在 Vue 模板中我们写 {{message}},实际上访问的是 this.message。使用 with(this) 可以省略 this. 前缀,让生成的代码更简洁。
示例:
js
function foo() {
let name = 'other' // 局部变量
let obj = {
name: 'cookie', // 对象属性
}
with (obj) {
console.log(name) // 优先从 obj 中查找,输出 'cookie'
}
}
foo() // 输出: cookie
generate 生成代码
终于到了模板编译的最后一步,生成代码!在这一步,我们将根据前面得到的 AST 生成 render 函数。
1、整体入口:generate
generate 是代码生成的入口函数,负责将 AST 转换为可执行的渲染代码:
- 递归生成代码 :调用
genElement遍历 AST,生成类似_c('div', [...])的代码字符串 - 包装 with 作用域 :用
with(this){}包裹,让代码能访问 Vue 实例的属性 - 收集静态渲染函数 :将静态根节点单独提取到
staticRenderFns数组中
js
/**
* 代码生成器入口
* @param {Object} ast - 经过 parse 和 optimize 处理后的抽象语法树
* @returns {Object} 返回包含 render 函数和 staticRenderFns 数组的对象
*/
function generate(ast) {
// state 用于存储编译过程中的状态
// staticRenderFns: 用于收集静态根节点的渲染函数,这些函数只需要生成一次,后续渲染可以直接复用,提升性能
const state = { staticRenderFns: [] }
// 递归生成核心渲染代码字符串
// 例如:_c('div',[_v(_s(message))])
const code = genElement(ast, state)
return {
// render: 主渲染函数的代码字符串
// 使用 with(this) 包装后,模板中的变量(如 {{message}})能直接从 Vue 实例上获取
// with(this) 会将 Vue 实例添加到作用域链顶部
// 这样 message 会自动从 this.message 获取,而不需要在模板里写 this.message
render: `with(this){return ${code}}`,
// staticRenderFns: 静态根节点渲染函数的数组
staticRenderFns: state.staticRenderFns,
}
}
2、核心函数:genElement
genElement 是代码生成的调度中心,它根据节点类型来使用不同的处理函数。
js
/**
* 生成元素的渲染代码
* @param {Object} el - AST 元素节点
* @param {Object} state - 渲染状态,包含 staticRenderFns 数组
* @returns {string} 渲染代码字符串,如:_c('div', [...])
*/
function genElement(el, state) {
// 1:处理静态根节点
// el.staticRoot: 在 optimize 阶段标记的静态根节点
// el.staticProcessed: 防止重复处理的标记
if (el.staticRoot && !el.staticProcessed) {
// 静态根节点会被提升为单独的函数存储在 staticRenderFns 中
// 返回类似 _m(0) 的代码,0 是在 staticRenderFns 数组中的索引
return genStatic(el, state)
}
// 2:处理 v-for 指令
// el.for: 在 parse 阶段解析的 v-for 属性
// el.forProcessed: 防止递归时重复处理
else if (el.for && !el.forProcessed) {
return genFor(el, state)
}
// 3:处理 v-if 指令
// el.if: 在 parse 阶段解析的 v-if 条件表达式
// el.ifProcessed: 防止递归时重复处理
else if (el.if && !el.ifProcessed) {
return genIf(el, state)
}
// 4:处理普通元素
else {
// 生成标签名字符串,如 'div'
const tag = `'${el.tag}'`
// 递归生成所有子节点的代码
// 返回类似 [_v("hello"), _c('span')] 的数组代码
const children = genChildren(el, state)
// 最终调用 _c (createElement) 创建 VNode
// 生成代码示例:_c('div', [_v("hello")])
// 如果没有子节点,生成:_c('div')
return `_c(${tag}${children ? `,${children}` : ''})`
}
}
处理子节点:genChildren 和 genNode
genElement 中调用了 genChildren 来遍历子节点,genChildren 内部又使用 genNode 来处理每一个子节点,
js
/**
* 生成子节点数组的渲染代码
* @param {Object} el - 父元素节点
* @param {Object} state - 渲染状态
* @returns {string} 子节点数组代码,如:[_v("text"), _c('span')]
*/
function genChildren(el, state) {
const children = el.children
if (children.length) {
// 获取子节点数组需要的规范化类型
const normalizationType = getNormalizationType(children)
// 遍历所有子节点,为每个子节点生成代码
return `[${children.map((c) => genNode(c, state)).join(',')}]${
normalizationType ? `,${normalizationType}` : ''
}`
}
}
/**
* 根据节点类型调用对应的生成函数
* @param {Object} node - AST 节点
* @param {Object} state - 渲染状态
* @returns {string} 节点渲染代码
*/
function genNode(node, state) {
if (node.type === 1) {
// type === 1: 元素节点,递归调用 genElement
return genElement(node, state)
} else {
// type === 2/3: 文本节点或表达式节点,调用 genText
return genText(node)
}
}
为什么需要 getNormalizationType?
因为 v-for 会生成数组,导致 children 出现嵌套数组的情况:
html
<div>
<span>first</span>
<span v-for="item in [1,2,3]">{{item}}</span>
<span>last</span>
</div>
生成的子节点结构会是:
js
[span_first, [span_1, span_1, span_1], span_last]
Vue 的 createElement 需要知道如何处理这种嵌套,所以要告诉它规范化类型:
- 不需要规范化(没有嵌套)-
type = 0 - 简单规范化(一层嵌套,如组件)-
type = 1 - 完全规范化(多层嵌套,如
v-for)-type = 2
js
/**
* 确定子节点数组需要的规范化(normalization)类型
* @param {Array} children - 子节点数组
* @returns {number} 0 | 1 | 2 - 规范化类型
*/
function getNormalizationType(children) {
let res = 0 // 默认不需要规范化
// 遍历所有子节点,检测是否需要规范化
for (let i = 0; i < children.length; i++) {
const el = children[i]
// 跳过非元素节点(type=2 表达式节点、type=3 文本节点)
// 只有元素节点(type=1)才可能需要规范化处理
if (el.type !== 1) continue
// 检查是否需要完全规范化(优先级最高,返回 2)
if (
el.for !== undefined || // 当前节点有 v-for
(el.ifConditions && // 或者 v-if 条件分支中有v-for
el.ifConditions.some((c) => c.block.for !== undefined))
) {
// 需要完全规范化:因为 v-for 会返回数组,需要递归扁平化
// 例如:[_v(" "), _l(arr, ...), _v(" ")] 其中 _l 返回 [span1, span2, span3]
// 实际结构是:[_v(" "), [span1, span2, span3], _v(" ")] 需要扁平化为一维数组
res = 2
break // 找到一个需要完全规范化的就可以退出,不需要继续检查
}
// 检查是否需要简单规范化 返回 1
// 当前节点可能是组件时,组件可能返回多个根节点(数组)
// 但组件的 render 函数已经返回规范化的 VNode,所以只需要一层扁平化
// 我们的模板里不处理组件,这里也就不用考虑这种情况
// res = 1
}
return res // 返回 0、1 或 2
}
3、分类处理函数
Vue 内部函数
首先在 Vue 内部,有一些简写函数,在后续生成代码的时候会用到,列表如下:
| 缩写 | 全称 | 作用 | 示例 |
|---|---|---|---|
_c |
createElement |
创建元素 VNode | _c('div', [...]) |
_v |
createTextVNode |
创建文本 VNode | _v("hello") |
_s |
toString |
将变量转为字符串 | _v(_s(message)) |
_l |
renderList |
渲染列表(v-for) | _l(items, fn) |
_m |
renderStatic |
渲染静态内容 | _m(0) |
_e |
createEmptyVNode |
创建空节点(v-if 失败时) | _e() |
静态节点 (genStatic)
静态根节点会被提升为单独的函数,提升性能:
js
/**
* 生成静态根节点的渲染代码
* @param {Object} el - 静态根节点
* @param {Object} state - 渲染状态
* @returns {string} 静态节点引用代码,如:_m(0)
*/
function genStatic(el, state) {
// 标记已处理,防止重复处理
el.staticProcessed = true
// 递归生成静态节点的完整代码
const code = genElement(el, state)
// 将静态节点函数存储到 staticRenderFns 数组中
state.staticRenderFns.push(`with(this){return ${code}}`)
// 返回对静态函数的引用,_m(index) 表示调用 staticRenderFns[index]
// 索引是当前数组长度减 1
return `_m(${state.staticRenderFns.length - 1})`
}
静态渲染函数和主 render 函数(vm._render)独立,需要单独设置 with 作用域。
文本处理 (genText)
js
/**
* 生成文本节点的渲染代码
* @param {Object} text - 文本 AST 节点
* @returns {string} _v('...') 或 _v(_s(variable))
*/
function genText(text) {
// type 2 是带 {{}} 的表达式,type 3 是普通纯文本
// 表达式直接使用解析好的 expression 纯文本需要用 JSON.stringify 转义
// 比如 text 为 "hello":生成代码 `v(hello)` 此时 JS 会去找名为 hello 的变量,这会导致报错(ReferenceError)
// JSON.stringify("hello") 会返回 "\"hello\""(即带双引号的字符串)。生成的代码会变成:_v("hello")
const value = text.type === 2 ? text.expression : JSON.stringify(text.text)
return `_v(${value})`
}
条件渲染 (v-if)
在 Vue 中我们可以使用 v-if/else-if/else 指令,比如下面的模板:
html
<div v-if="a">A</div>
<div v-else-if="b">B</div>
<div v-else>C</div>
我们生成的 AST 为:
js
conditions = [
{ exp: 'a', block: { AST节点A } }, // v-if
{ exp: 'b', block: { AST节点B } }, // v-else-if
{ exp: undefined, block: { AST节点C } }, // v-else (没有exp)
]
v-if/else-if/else 的本质就是多重条件判断,在 JavaScript 中最适合用嵌套三元表达式来表达:
js
// 目标:生成这样的代码
// (a) ? A节点 : (b) ? B节点 : C节点
a ? _c('div', 'A') : b ? _c('div', 'B') : _c('div', 'C')
接下来是代码实现,我们处理 conditions 时,每次弹出第一个条件块,如果为真,则展示当前模块,如果为假,则递归处理剩余的 conditions,如果没有条件(v-else),则直接展示该模块。
js
/**
* 生成 v-if 指令的入口函数
* @param {Object} el - 带有 v-if 的 AST 节点
* @param {Object} state - 渲染状态
* @returns {string} 三元表达式形式的渲染代码
*/
function genIf(el, state) {
// 标记已处理,防止递归时重复处理
el.ifProcessed = true
// 使用 slice() 复制数组,避免 shift() 修改原始的 ifConditions 数组
return genIfConditions(el.ifConditions.slice(), state)
}
/**
* 生成 v-if/else-if/else 指令的渲染代码
* @param {Array} conditions - 条件数组
* @param {Object} state - 渲染状态
* @returns {string} 三元表达式形式的代码
*/
function genIfConditions(conditions, state) {
// 递归终止条件:所有分支都处理完了 返回空节点
if (!conditions.length) return '_e()'
// 取出第一个条件
const condition = conditions.shift()
if (condition.exp) {
// 有条件表达式:v-if 或 v-else-if
// 生成:(条件) ? 满足时的内容 : 递归处理剩余条件
return `(${condition.exp})?${genElement(
condition.block,
state
)}:${genIfConditions(conditions, state)}`
} else {
// 没有条件表达式:v-else
// 兜底分支,直接生成节点
return genElement(condition.block, state)
}
}
列表渲染 (v-for)
最后我们处理 v-for 指令,考虑下面的例子:
html
<div v-for="(item, index) in items">{{ item.name }}</div>
在 parse 阶段,这个指令会被解析为 AST 节点的属性:
js
{
for: "items", // 要遍历的数据源
alias: "item", // 当前项的别名
iterator1: "index" // 索引的别名(可选)
}
我们通过_l 来进行遍历。_l 是 Vue 的内部函数 renderList,它的作用是遍历数组/对象,为每一项调用渲染函数。我们要生成的代码格式是:
js
_l(数据源, function (item, index) {
return 每一项的渲染代码
})
代码实现:
js
/**
* 生成 v-for 指令的渲染代码
* @param {Object} el - 带有 v-for 的 AST 节点
* @param {Object} state - 渲染状态
* @returns {string} _l() 函数调用代码
*/
function genFor(el, state) {
// 1. 提取遍历的相关信息
const exp = el.for // 遍历的对象
const alias = el.alias // 别名 item
// 2. 处理索引参数(可选)
// 如果有索引,格式为 ",index";没有则为空字符串
const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''
// 3. 标记已处理,防止递归时重复处理
el.forProcessed = true
// 4. 生成 _l() 函数调用
return `_l((${exp}),function(${alias}${iterator1}${iterator2}){return ${genElement(
el,
state
)}})`
}
最后会转化为代码:
js
_l(items, function (item, index) {
return _c('div', [_v(_s(item.name))])
})
运行实例
我们已经写完了模板编译代码,完整的使用流程如下:
html
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
</head>
<body>
<div id="app"></div>
<script>
// 1. 模板字符串
const template = `
<div>
<h1>恭喜!编译成功!</h1>
<h1 v-if="true">随机数{{ Math.random() }}</h1>
<h1 v-if="false">这里不会被展示</h1>
<h2>
<span>{{message}}</span>
<span v-for="item in list">{{item}}</span>
</h2>
</div>
`
// 2. 三步编译:parse -> optimize -> generate
// (函数实现见上文,这里省略)
const ast = parse(template, {...})
optimize(ast)
const { render, staticRenderFns } = generate(ast)
// 3. 使用生成的 render 函数创建 Vue 实例
const vm = new Vue({
el: '#app',
data: {
message: 'Hello',
list: ['我不吃', '饼干', '🍪'],
},
render: new Function(render),
staticRenderFns: staticRenderFns.map(fn => new Function(fn)),
})
</script>
</body>
</html>
最终页面展示效果如下:

后记
以前看大佬ssh_晨曦时梦见兮的文章,特别喜欢他那种把问题深入浅出讲明白的文章。可是当我自己开始写的时候,才发现想写好真的好难。
有任何问题都可以在评论区提出,感谢大家看到这里。