Vue2 源码简介

模板编译:template 如何变成 render 函数

掌握模板编译 parse、optimize、generate 三个阶段,理解模板到 render 函数的完整转换过程。

🎯 引言

我们在 Vue2 课程 里写过无数次 {{ message }}v-for,但 Vue 并不认识这些模板语法,它真正执行的是 render 函数。中间这道"翻译"工作,就是模板编译

学完这篇,你能说清 <div>{{ message }}</div> 这样的模板是如何经过 parse、optimize、generate 三步变成 render 函数的,也能讲明白完整版和 runtime-only 版 Vue 的区别。


🗺 总览:模板编译三步

模板编译的入口在 src/compiler/index.js,核心逻辑就三行(简化自源码,去掉 Flow 类型注解):

src/compiler/index.js
// 简化自 src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile(template, options) {
    // 1. parse:模板字符串 → AST
    const ast = parse(template.trim(), options);
    // 2. optimize:标记静态节点
    optimize(ast, options);
    // 3. generate:AST → render 代码字符串
    const code = generate(ast, options);
    return {
        ast,
        render: code.render,
    };
});

三个阶段各司其职:

  • parse:把模板字符串解析成 AST(抽象语法树)。
  • optimize:遍历 AST,标记静态节点,为后续更新做优化。
  • generate:把 AST 转成 _c('div', ...) 这样的代码字符串,最终通过 new Function 变成 render 函数。

你可以把它想象成翻译一篇文章:先读懂原文断句(parse),再划出不用每次重翻的固定段落(optimize),最后写出译文(generate)


🧱 parse:模板 → AST

parse 的源码在 src/compiler/parser/index.js,有几百行,但核心思路很朴素:用正则从头到尾逐段扫描模板字符串,遇到开始标签、属性、结束标签、文本就分别处理,边解析边用一个维护父子关系。

简化后的工作流程:

src/compiler/parser/index.js
// 简化自 src/compiler/parser/index.js 的核心思路
function parse(template) {
    const stack = []; // 栈:维护当前解析到哪一层
    let root; // AST 根节点

    while (template) {
        if (template 以结束标签开头) {
            // 如 </div>:栈顶元素出栈,表示这一层解析完了
            stack.pop();
        } else if (template 以开始标签开头) {
            // 如 <div id="app">:创建元素节点
            const element = { type: 1, tag: 'div', attrsList: [...], children: [] };
            if (!root) root = element;
            // 挂到栈顶元素的 children 里,然后自己入栈
            stack.push(element);
        } else {
            // 文本:创建文本节点,挂到栈顶元素下
            // 带 {{}} 的文本 type 为 2,纯文本 type 为 3
        }
        // 每处理完一段,就把这段从 template 中截掉,继续循环
    }
    return root;
}

栈的作用是记住"当前元素的父亲是谁":遇到开始标签就入栈,遇到结束标签就出栈,栈顶永远是当前正在解析的父元素。

AST 节点的三种 type

parse 产出的 AST 节点用一个 type 字段区分类型,只有三种:

type含义例子
1元素节点<div><p>
2带表达式的文本节点{{ message }}
3纯文本节点hello

一个简单模板的 AST 示意

模板:

<div id="app">
    <p>{{ message }}</p>
    hello
</div>

对应的 AST 大致长这样:

{
    type: 1,
    tag: 'div',
    attrsList: [{ name: 'id', value: 'app' }],
    children: [
        {
            type: 1,
            tag: 'p',
            children: [
                { type: 2, expression: '_s(message)', text: '{{ message }}' },
            ],
        },
        { type: 3, text: 'hello' },
    ],
}

可以看到 AST 就是一棵普通的 JS 对象树,模板里的嵌套结构变成了对象的 children 嵌套,每个节点都记录了标签名、属性、文本或表达式。


✨ optimize:标记静态节点

optimize 的源码在 src/compiler/optimizer.js,做的事情只有一件:递归遍历 AST,给不依赖响应式数据的节点打上 static: true 标记

什么叫"静态"?就是这个节点的内容永远不会因为数据变化而改变。比如 <p>hello</p>,无论 message 怎么变,它都长这样,这就是静态节点。

简化后的核心逻辑:

src/compiler/optimizer.js
// 简化自 src/compiler/optimizer.js
function optimize(root) {
    markStatic(root);
}

function markStatic(node) {
    // 纯文本节点、没有动态绑定的元素 → 静态
    node.static = isStatic(node);
    if (node.type === 1) {
        for (const child of node.children) {
            markStatic(child);
            // 只要有一个子节点是动态的,父元素就不是静态的
            if (!child.static) node.static = false;
        }
    }
}

function isStatic(node) {
    if (node.type === 2) return false; // 带表达式的文本,动态
    if (node.type === 3) return true; // 纯文本,静态
    // 元素节点:没有 v-if、v-for、动态绑定等才是静态
    return !node.if && !node.for && !node.hasBindings /* ... */;
}

另外还会标记静态根staticRoot):一个静态节点如果本身有子节点、且不是只有一个文本子节点,就可以作为静态根。

标记了有什么用

这些标记是给后面的 patch 阶段用的:

  • 静态子树直接跳过:数据变化触发重新渲染时,patch 发现新旧节点都是静态的,就不用对比它们的子树了,整棵跳过。
  • 静态节点可缓存复用:静态内容渲染一次后可以缓存起来,下次直接复用,不用重新创建。

也就是说,optimize 是在编译期提前做功课,把"哪些部分永远不变"这个信息算好存起来,让运行时的 Diff 少干活。这和我们在 Snabbdom 课程 里看到的"逐节点对比"形成互补:Vue 先用编译时信息剪枝,再做运行时对比。


🛠 generate:AST → render 代码

generate 的源码在 src/compiler/codegen/index.js,负责把 AST 转成一串代码字符串。还是上面的例子:

<div id="app">
    <p>{{ message }}</p>
    hello
</div>

生成结果大致是:

with (this) {
    return _c('div', { attrs: { id: 'app' } }, [_c('p', [_v(_s(message))]), _v('hello')]);
}

这串字符串再经过 new Function 包装,就变成了真正的 render 函数。执行后返回一棵虚拟节点树(vnode),交给 patch 渲染成真实 DOM。

常用的渲染辅助函数

代码里的 _c_v_s 不是编译器发明的黑话,而是 Vue 挂在组件实例上的一组辅助函数的简写:

简写完整函数作用
_ccreateElement创建元素节点的 vnode,对应 Snabbdom 的 h()
_vcreateTextVNode创建文本节点的 vnode
_stoString把值转成字符串({{ message }} 编译成 _s(message)
_lrenderList渲染列表,v-for 会编译成 _l 的循环

比如一个 v-for 模板:

<ul>
    <li v-for="item in list">{{ item.name }}</li>
</ul>

编译后大致是:

with (this) {
    return _c('ul', _l(list, function (item) {
        return _c('li', [_v(_s(item.name))]);
    }));
}

_l(list, fn) 会遍历 list,对每个 item 执行回调生成 vnode,最后收集成一个数组作为 ul 的子节点。看到这里的 h() 风格调用,是不是和 Snabbdom 课程 里手写的 h('li', ...) 对上了?模板编译的最终产物,就是一棵用代码描述的虚拟 DOM。


💡 with(this) 的作用

你一定注意到了,生成的 render 函数外面包了一层 with (this)。它的作用是:this(组件实例)设为作用域查找的第一站

有了它,模板里写的 message 在 render 函数里就会先去 this 上找,等价于 this.message。这就是为什么我们写模板时可以直接写 {{ message }},而不用写 {{ this.message }}

with 语句在 JavaScript 严格模式('use strict')下是被禁用的,这也是 Vue2 模板编译生成的代码不使用严格模式的原因之一。Vue3 改用了其他方案,不再依赖 with

另外 with 也有代价:它会让作用域查找变慢,而且变量来源不明确。不过模板里的变量一般就几个,实际影响有限。


⚖️ 完整版 vs runtime-only

既然编译器能把模板转成 render 函数,那是不是每次运行都要编译一遍?不一定,这取决于你用的是哪个版本的 Vue。

通过 npm 安装 Vue 时(本地环境:Node.js v22,vue@2.7.16):

# Node.js v22
npm install vue@2.7.16

默认导出的构建(vue.runtime.common.js / 打包工具默认解析的 vue.runtime.esm.js)是 runtime-only 版,不含模板编译器。它假设你的模板已经在构建阶段被 vue-loader(处理 .vue 文件)或 vue-template-compiler 提前编译成了 render 函数,运行时只管渲染,体积更小。

只有显式引用完整版vue/dist/vue.js,编译器 + 运行时),才能在浏览器里实时编译 template 选项或 el 挂载点的 HTML:

// 完整版才能这样用:运行时编译 template
new Vue({
    el: '#app',
    template: '<div>{{ message }}</div>',
    data: { message: 'hello' },
});
实际项目里基本都走构建工具,模板在打包时就被编译好了,所以默认用 runtime-only 版即可。这也是为什么你在 .vue 文件里写 <template> 不需要关心编译器:构建时已经替你编译完了。

一句话总结:编译器和运行时是分开的。完整版 = 运行时 + 编译器,可以随时编译模板;runtime-only 版 = 只有运行时,模板必须在构建时编译。


🧾 小节总结

  • 模板编译分三步:parse(模板 → AST)、optimize(标记静态节点)、generate(AST → render 代码字符串)。
  • parse 用正则逐段扫描模板,用栈维护父子关系,产出 AST;AST 节点分三种 type:1 元素、2 带表达式的文本、3 纯文本。
  • optimize 标记不依赖响应式数据的静态节点和静态根,让 patch 时可以跳过或复用静态子树。
  • generate 产出 _c('div', [_v(_s(message))]) 这样的代码字符串,_c 建元素、_v 建文本、_s 转字符串、_l 渲染列表。
  • 生成的 render 函数包在 with(this) 里,所以模板中可以直接写变量名。
  • npm 安装的 vue@2.7.16 默认是 runtime-only 版(不含编译器),运行时编译模板需要完整版 vue/dist/vue.js

❓ 知识问答

Q1:AST 和虚拟 DOM(vnode)是一回事吗?

A:不是。AST 是编译期的中间产物,描述模板的结构和表达式;vnode 是运行时的产物,由 render 函数执行后生成,描述要渲染的节点。流程是:模板 → AST → render 函数 → vnode → 真实 DOM。

Q2:optimize 标记静态节点,是不是静态节点就不会出现在 vnode 里?

A:会。静态节点照常生成 vnode、照常渲染,只是 patch 对比时认出它是静态的,就跳过子树的精细对比,并用缓存复用。标记是优化手段,不改变渲染结果。

Q3:为什么 {{ message }} 编译后是 _s(message) 而不是直接用 message

A:因为插值需要的是字符串。_s(toString)负责把数字、对象等各种类型安全地转成字符串,避免直接把对象拼进文本出现 [object Object]

Q4:runtime-only 版遇到 template 选项会直接报错吗?

A:在开发环境下,runtime-only 版检测到传入了 template 且没有对应 render 函数时,会给出警告提示你改用完整版或改用 render 函数。正常走构建工具的项目不会遇到这个问题,因为模板早已被编译。

Q5:模板编译发生在什么时候?

A:看版本。完整版在浏览器运行 new Vue 时实时编译;runtime-only 版的项目里,编译发生在打包构建阶段(由 vue-loader 完成),浏览器拿到的已经是 render 函数。


🧪 小练习

不看上文,手写下面这个模板编译后的 render 代码(用 _c_l_v_s):

<div>
    <h1>{{ title }}</h1>
    <ul>
        <li v-for="todo in todos">{{ todo.text }}</li>
    </ul>
</div>
with (this) {
    // 请在这里编写代码
}

写完后对照检查:h1 里的插值用 _v(_s(title))ul 的第二个参数是 _l(todos, function (todo) { ... }),回调里返回 _c('li', [_v(_s(todo.text))])


🎉 恭喜你已经掌握了模板编译的完整流程!从模板字符串到 AST,再到 render 函数,Vue 的"模板魔法"其实就是这三步踏实的转换。