手写 Vue 2 系列:初始渲染
原文试图从编译器产出的 render function 继续实现 Vue 2 的首次挂载,覆盖 render helper、VNode、patch、指令、组件和插槽。本文保留这条源码学习路线,并把抓取后粘连的代码整理为可读的教学伪实现。它对应 Vue 2.7 的历史架构,不是 Vue 3.5 的源码,也不能直接替代 Vue。
一、从模板到首次渲染
上一篇编译器文章完成了模板到 render function 的过程。得到 render function 后,首次渲染可以抽象为:
$mount
↓
创建渲染 watcher
↓
执行 updateComponent
↓
vm._render() 生成根 VNode
↓
vm._update() 调用 patch
↓
递归创建元素、文本和组件
↓
处理属性、事件、指令和插槽
↓
真实 DOM 插入容器并替换挂载元素
本文会实现或说明这些概念:
- render helper 和 VNode;
- 根组件的初始 patch;
- 元素、文本和组件的
createElm; v-model、v-bind、v-on的编译后形态;- 子组件实例和插槽的初始化。
文章中的
src/compiler等路径是教学项目的目录,不等于 Vue 官方仓库的精确路径。Vue 2 官方源码会把生命周期、渲染、平台 patch 模块拆到不同目录。
二、mount 和渲染 Watcher
1. mount
$mount 需要先确保组件有 render function。Vue 2 Runtime + Compiler 构建在只有 template 时会先编译模板;只有 Runtime 构建则要求调用者在构建阶段提供 render function。
export function mount(vm) {
if (!vm.$options.render) {
// Vue 2 Runtime + Compiler 构建:
// vm.$options.render = compileToFunctions(vm.$options.template)
throw new Error('当前构建需要 render function')
}
mountComponent(vm)
}
2. mountComponent
真正的首次渲染通常由渲染 watcher 驱动。Vue 2 源码中的 watcher 会在首次求值时执行 updateComponent,在 render 期间读取响应式数据,从而完成依赖收集。
export function mountComponent(vm) {
const updateComponent = () => {
vm._update(vm._render())
}
vm._watcher = new Watcher(
vm,
updateComponent,
() => {},
{ isRenderWatcher: true },
)
}
简化示例省略了 beforeMount、mounted、hydration、错误处理和异步更新。真实 Vue 2 会在 watcher 建立后调用相应生命周期,并把后续 watcher 更新交给 scheduler 批量处理。
三、vm._render 和 render helper
1. 执行 render function
render function 中的 this 必须指向当前组件实例。Vue 2 编译器生成的代码通常会使用 _c、_v、_s 等 helper;用户手写 render function 则会收到 h/createElement 参数。
Vue.prototype._render = function _render() {
const render = this.$options.render
const vnode = render.call(this, this.$createElement)
if (Array.isArray(vnode)) {
throw new Error('简化实现只允许一个根 VNode')
}
vnode.parent = this.$vnode || null
return vnode
}
Vue 2.7 的实际实现还要处理函数式组件、多个根节点兼容行为、错误占位 VNode、作用域 slot 和开发环境代理。这里仅保留主线。
2. 安装 helper
export function installRenderHelpers(Vue) {
Vue.prototype._c = function createCompiledElement(tag, data, children) {
return createElement(this, tag, data, children)
}
Vue.prototype._v = function createTextVNode(text) {
return new VNode(undefined, undefined, undefined, String(text), this)
}
Vue.prototype._s = value => {
if (value == null) return ''
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
}
编译器输出的 _c('div', data, children) 本质上是创建 VNode 的 helper。不要把 _c、_v 当成 Vue 3 的公共 API;Vue 3 编译结果和 runtime helper 体系已经不同。
3. createElement
function createElement(context, tag, data = {}, children = []) {
const normalizedChildren = normalizeChildren(children)
return new VNode(tag, data, normalizedChildren, undefined, context)
}
function normalizeChildren(children) {
if (!Array.isArray(children)) {
return children == null ? [] : [children]
}
return children.flat(Infinity).map(child => {
if (child instanceof VNode) return child
return new VNode(undefined, undefined, undefined, String(child))
})
}
真实 Vue 还会规范化数组、文本、组件、异步组件、Fragment 兼容节点和 key。教学实现中的 flat(Infinity) 也只是为了展示概念,不能直接当成 Vue 的 children normalization。
四、VNode 是什么
VNode 是描述节点的 JavaScript 对象,不是真实 DOM 元素:
class VNode {
constructor(tag, data, children, text, context) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.context = context
this.elm = undefined
this.parent = undefined
this.key = data?.key
this.componentOptions = undefined
this.componentInstance = undefined
}
}
常见字段的作用:
tag:元素或组件标签;data:attrs、props、on、directives、key、slot 等;children:子 VNode 数组;text:文本 VNode 的内容;elm:挂载后对应的真实 DOM;context:创建该 VNode 的组件实例;componentInstance:组件 VNode 挂载后的子组件实例;key:列表 Diff 时表达节点身份。
文本节点应通过 text != null 判断,不能用 if (text),因为空字符串也是合法文本内容。
五、vm._update:首次挂载与后续更新
Vue.prototype._update = function _update(vnode) {
const previousVNode = this._vnode
this._vnode = vnode
if (!previousVNode) {
// 根组件首次渲染:oldVnode 是真实挂载元素
this.$el = this.__patch__(this.$el, vnode)
} else {
// 组件更新或子组件再次渲染:oldVnode 是旧 VNode
this.$el = this.__patch__(previousVNode, vnode)
}
}
初始化时 this.$el 通常是 #app 对应的真实元素;首次 patch 会用 VNode 创建出的 DOM 替换它。后续 patch 则比较新旧 VNode,尽量复用已有 DOM。
Vue 2 还会同步更新父子组件的 $el、$vnode 和 parent/child 关系,并执行 beforeUpdate/updated 等生命周期。这里暂不展开更新流程,下一篇通常会专门介绍 Diff。
六、patch 的首次渲染分支
初始 patch 需要区分三种输入:
oldVnode是空值:创建一个子树;oldVnode是真实 DOM:创建新 VNode 对应 DOM,插入并移除旧元素;oldVnode是旧 VNode:进入更新/Diff。
export function patch(oldVnode, vnode) {
if (!vnode) {
if (oldVnode) removeVnode(oldVnode)
return undefined
}
if (!oldVnode) {
createElm(vnode)
return vnode.elm
}
const isRealElement = oldVnode.nodeType !== undefined
if (isRealElement) {
const oldElement = oldVnode
const parent = oldElement.parentNode
const anchor = oldElement.nextSibling
createElm(vnode, parent, anchor)
if (parent) parent.removeChild(oldElement)
return vnode.elm
}
// 初始渲染文章暂不展开:这里应该调用 patchVnode(oldVnode, vnode)
patchVnode(oldVnode, vnode)
return vnode.elm
}
原文把参考节点固定说成 script,这并不普遍。正确的参考节点是旧挂载元素的 nextSibling,也可能为 null;父节点也不一定是 body。
七、createElm:递归创建真实节点
function createElm(vnode, parent, anchor = null) {
if (createComponent(vnode, parent, anchor)) {
return vnode.elm
}
let elm
if (vnode.text != null && (!vnode.children || vnode.children.length === 0)) {
elm = document.createTextNode(vnode.text)
} else {
elm = document.createElement(vnode.tag)
vnode.elm = elm
setNodeData(vnode)
for (const child of vnode.children || []) {
createElm(child, elm)
}
}
vnode.elm = elm
if (parent) parent.insertBefore(elm, anchor)
return elm
}
真实 Vue 2 的 createElm 还会处理注释、SVG、namespace、Transition、指令钩子、inserted 队列、范围节点和组件 hooks。组件必须先尝试创建,否则自定义组件会被错误地当成原生元素。
八、指令和普通属性
模板中的指令在编译阶段通常会转换为 VNode data 或指令钩子,不是 createElm 简单遍历字符串后就能完整实现。下面是教学化的 data 结构:
const data = {
attrs: { id: 'app' },
domProps: { value: 'hello' },
on: {
input: event => {},
},
directives: [
{ name: 'focus', value: true },
],
}
function resolveValue(value, vm) {
return typeof value === 'function' ? value.call(vm) : value
}
function setNodeData(vnode) {
const { elm, data = {}, context: vm } = vnode
for (const [name, value] of Object.entries(data.attrs || {})) {
elm.setAttribute(name, String(resolveValue(value, vm)))
}
for (const [name, value] of Object.entries(data.domProps || {})) {
elm[name] = resolveValue(value, vm)
}
for (const [eventName, handler] of Object.entries(data.on || {})) {
elm.addEventListener(eventName, event => handler.call(vm, event))
}
// 真实实现还要执行 bind/inserted/update/componentUpdated/unbind
// 等 Vue 2 指令钩子,并维护 oldValue。
}
1. v-model
Vue 2 的 v-model 并不是一个神奇的 setter,它会根据元素类型展开为属性和事件:
<input v-model="name">
≈ :value="name" @input="name = $event.target.value"
<input type="checkbox" v-model="checked">
≈ :checked="checked" @change="checked = $event.target.checked"
<select v-model="selected">
≈ :value="selected" @change="selected = $event.target.value"
简化的原生 input 实现:
function setTextModel(elm, vm, path) {
elm.value = getPath(vm, path) ?? ''
elm.addEventListener('input', event => {
setPath(vm, path, event.target.value)
})
}
function getPath(object, path) {
return path.split('.').reduce((value, key) => value?.[key], object)
}
function setPath(object, path, value) {
const keys = path.split('.')
const last = keys.pop()
const target = keys.reduce((value, key) => value[key], object)
target[last] = value
}
这只覆盖最简单的文本输入。真实 Vue 2 还要处理 textarea、radio、checkbox 多选、select 多选、.lazy、.number、.trim、组件 value/input 契约和响应式更新后的回写。
2. v-bind
v-bind 需要区分 attribute 和 DOM property。比如表单 value、checked 通常要设置 property,而不是只调用 setAttribute:
function setBoundProps(elm, vm, bindings) {
for (const [name, expression] of Object.entries(bindings)) {
const value = evaluatePath(vm, expression)
if (name in elm) {
elm[name] = value
} else if (value == null || value === false) {
elm.removeAttribute(name)
} else {
elm.setAttribute(name, String(value))
}
}
}
3. v-on
function setEventListeners(elm, vm, listeners) {
for (const [eventName, methodName] of Object.entries(listeners)) {
const method = vm[methodName] || vm.$options.methods?.[methodName]
if (typeof method !== 'function') continue
elm.addEventListener(eventName, event => method.call(vm, event))
}
}
生产代码还要处理同一事件更新时移除旧 handler、修饰符、事件缓存、组件事件和 .native 等 Vue 2 行为。Vue 3 已移除 .native,组件事件应通过 emits 声明。
九、创建子组件
组件 VNode 和原生元素 VNode 的区别是:组件需要先创建一个新的组件实例,再让它执行自己的初始化和 render。
function createComponent(vnode, parent, anchor) {
const Ctor = vnode.componentOptions?.Ctor
if (!Ctor) return false
const child = vnode.componentInstance = new Ctor({
parent: vnode.context,
propsData: vnode.componentOptions.propsData,
_parentVnode: vnode,
})
child.$mount()
vnode.elm = child.$el
if (parent) parent.insertBefore(vnode.elm, anchor)
return true
}
这个代码只表达流程,真实 Vue 2 会通过组件 VNode 的 init hook 创建构造器实例、初始化 props、listeners、slots、provide/inject、生命周期和渲染 watcher。组件定义通常先通过 Vue.extend 得到构造器,不能简单把任意 options 直接当作 new Vue(options) 处理。
创建完成后,父组件 patch 不需要再次手动把子组件的 VNode 当普通元素递归追加;子组件的 $mount/patch 已经负责生成 $el,父级只需正确插入并维护组件 VNode 关系。
十、保留标签和组件解析
原文用一个固定数组判断原生标签:
const reservedTags = [
'div', 'span', 'input', 'select', 'option', 'p', 'button', 'template',
]
这只适合极简 demo。真实 Vue 2 使用平台配置中的 isReservedTag,需要覆盖完整 HTML/SVG 标签和平台差异。更重要的是:
- 原生标签交给平台 renderer 创建;
- 已注册组件按组件名解析;
- 未注册的自定义标签可能被当作未知元素,具体行为受平台和配置影响;
- Web Components、自定义元素和 Vue 组件不能只靠字符串是否包含大写来判断。
十一、插槽的原理
Vue 2 插槽的关键点是插槽内容由父组件编译和创建,但在子组件模板中渲染:
<!-- Parent.vue -->
<Child>
<template #title>
<strong>{{ parentTitle }}</strong>
</template>
</Child>
<!-- Child.vue -->
<template>
<section>
<slot name="title">默认标题</slot>
<slot>默认内容</slot>
</section>
</template>
可以把实现拆成三个阶段:
- parse:父模板解析组件子节点和 slot 名称;
- generate:组件 VNode data 中保存普通 slot VNode 或 scoped slot 函数;
- render:子组件执行
_t/renderSlot,取出父组件传入的内容,生成当前渲染上下文中的 VNode。
普通插槽可以抽象成:
function getSlotProps(vm) {
return vm.$props || {}
}
function renderSlot(vm, name, fallback) {
const slots = vm.$vnode?.data?.scopedSlots
if (slots && slots[name]) {
return slots[name](getSlotProps(vm))
}
return fallback
}
上面只是表达数据流,真实 Vue 2 的 $slots 是 VNode 数组,$scopedSlots 是返回 VNode 的函数;Vue 3 则统一以 slot function 为主,调用时机和内部 VNode 约定不同。
作用域插槽示例:
<!-- Child.vue -->
<slot name="row" :item="item">
<span>{{ item.name }}</span>
</slot>
<!-- Parent.vue,Vue 2.6+ / Vue 3 均支持类似模板语法,细节以版本为准 -->
<Child>
<template #row="slotProps">
<strong>{{ slotProps.item.name }}</strong>
</template>
</Child>
插槽不是把父节点的 DOM 直接搬到子组件,而是把父作用域中的 VNode/函数交给子组件指定位置渲染。
十二、初始渲染结果
当编译、render、patch、组件创建和 slot 处理都连通后,页面会从原始挂载元素变成由 VNode 创建的 DOM:

检查过程时可以依次打印:
console.log(vm.$options.render)
console.log(vm._vnode)
console.log(vm.$el)
console.log(vm._vnode.componentInstance)
如果出现以下问题,可以按顺序排查:
- render function 是否真的被调用;
- VNode 的
text、children是否混淆; vnode.elm是否在 createElm 后赋值;- 首次 patch 的 oldVnode 是否是真实元素;
- 组件构造器是否正确解析;
- 子组件是否执行了自己的
$mount; - slot data 是否在父组件 VNode 上保存;
- input/select 的 value 是否在子节点创建后设置。
十三、Vue 2 与 Vue 3 的差异
本文的 vm._render()、vm._update()、vm.__patch__、render watcher、_c 和 _v 都是 Vue 2 学习源码的语境。Vue 3 的公开入口是:
import { createApp, h, reactive } from 'vue'
const app = createApp({
setup() {
const state = reactive({ message: 'Vue 3' })
return () => h('p', state.message)
},
})
app.mount('#app')
Vue 3 使用 Proxy/reactive effect、组件更新 job、createVNode 和独立 renderer;支持 Fragment、Teleport、Suspense、SSR hydration 及编译器 Patch Flags。理解 Vue 2 的初始渲染有助于阅读历史源码,但不能把这些内部字段当作 Vue 3 的稳定 API。
总结
首次渲染的本质是:
响应式实例
→ render function
→ VNode
→ patch
→ create element/component/text
→ 真实 DOM
其中:
- render helper 负责创建 VNode;
vm._update区分首次挂载和后续更新;- patch 负责把真实挂载元素或旧 VNode 转换到新 VNode;
createElm递归创建元素、文本和组件;- 指令最终会落到 property、attribute、事件和指令钩子;
- 子组件要先创建实例并执行自己的渲染;
- 插槽传递的是父作用域创建的 VNode 或 slot function,而不是现成 DOM。
参考资料
原文作者:李永宁。原文关于 Vue 2 初始渲染、render helper、VNode、patch、指令、组件和插槽的主线予以保留;抓取代码粘连、宣传段落、硬编码挂载位置、错误组件实例化方式和过度简化的指令实现已清理或修正。