Virtual DOM实现详解

VNode 是个普通对象,真正碰 DOM 的是 patch。这篇拆 Vue 2 的 VNode 字段、createElement 的两档规范化、patch 建树和销毁的顺序,再看 Vue 3 怎么把 diff 缩到编译期挑出来的那几个节点上。

位置
第 05 篇 / 共 12 篇
预计
25 分钟

VNode 就是一个普通对象,字段少到能一屏列完

先把一个打印出来。下面这段在完整版 Vue 2(带编译器的那个 vue.js)的控制台里可以直接跑:

浏览器控制台 · 完整版 Vue 2
const vm = new Vue({
data: { name: 'Vue' },
template: '<div id="app" class="box">Hello {{ name }}</div>'
}).$mount('#app');
const vnode = vm._vnode;
console.log(vnode.tag); // div
console.log(vnode.data); // { staticClass: 'box', attrs: { id: 'app' } }
console.log(vnode.children.length); // 1
console.log(vnode.children[0].tag); // undefined —— 文本节点没有 tag
console.log(vnode.children[0].text); // Hello Vue
console.log(vnode.elm === vm.$el); // true
console.log(vnode.context === vm); // true

一个元素节点靠 tag + data + children 描述,一个文本节点只有 textelm 指向已经建好的真实 DOM, context 指向渲染出它的那个组件实例。整个类就是这么点东西:

Vue 2 · VNode 构造函数(src/core/vdom/vnode.js,节选)
constructor (tag, data, children, text, elm, context, componentOptions, asyncFactory) {
this.tag = tag // 标签名;组件 VNode 的 tag 是拼出来的,见最后一节
this.data = data // 编译产物里的第二个参数:attrs / staticClass / on / hook …
this.children = children // 子 VNode 数组
this.text = text // 文本节点和注释节点用它,元素节点是 undefined
this.elm = elm // 对应的真实 DOM —— 全类唯一一个碰浏览器的字段
this.context = context // 在哪个实例的作用域里渲染出来的
this.key = data && data.key // key 不是构造参数,是从 data 上取的
this.componentOptions = componentOptions // 只有组件 VNode 有值
this.componentInstance = undefined // 组件实例,patch 里的 init 钩子跑完才被填上
this.parent = undefined // 组件根 VNode 反向指回它的占位 VNode
this.isStatic = false // 编译期标记为静态的节点
this.isComment = false // 注释节点
this.isCloned = false
// 省略:ns / fnContext / fnOptions / fnScopeId / raw / isRootInsert / isOnce /
// asyncFactory / asyncMeta / isAsyncPlaceholder
}

第八个参数 asyncFactorykey 是两个容易记错的地方:key 从来不出现在参数列表里, 它是构造时从 data.key 上取下来的;写 :key="item.id" 之所以生效,是因为编译器把它编进了 data

配套的工厂只有三个,都在同一个文件里:

Vue 2 · VNode 的三个工厂(src/core/vdom/vnode.js)
export const createEmptyVNode = (text = '') => {
const node = new VNode()
node.text = text
node.isComment = true // 空 VNode 就是一个注释节点,页面上那个 <!----> 是它
return node
}
export function createTextVNode (val) {
return new VNode(undefined, undefined, undefined, String(val))
}
export function cloneVNode (vnode) {
const cloned = new VNode(
vnode.tag, vnode.data,
vnode.children && vnode.children.slice(), // #7975:拷一份数组,免得改到原来那棵树
vnode.text, vnode.elm, vnode.context, vnode.componentOptions, vnode.asyncFactory
)
// 省略:ns / isStatic / key / isComment / fnContext / fnOptions / fnScopeId / asyncMeta 逐个抄过去
cloned.isCloned = true
return cloned
}

cloneVNode 上面那句源码注释写了它存在的理由:静态节点和插槽节点会跨多次渲染被复用, 而 patch 会往 VNode 上写 elm。同一个对象被两处用作插入参照点,写进去的 elm 就会互相覆盖。 克隆是浅的 —— data 直接共用同一个引用,只有 children 数组被复制了一层。

v-if 落到假分支时渲染出来的就是 createEmptyVNode()。它不是「什么都不渲染」, 是真的往文档里插了一个注释节点,占住位置,下次条件变真时好知道该插在哪。

还有一件容易忽略的事:VNode 是可变的。patch 会往它身上写 elm、写 componentInstanceupdateChildren 甚至会把旧数组里用过的槽位直接改成 undefined。 所以「新旧两棵树」这个说法有点误导 —— 新树在 patch 过程中被就地填上了对真实 DOM 的引用, patch 一结束它就是下一轮的旧树了(_updatevm._vnode = vnode 这一句排在调用 __patch__ 之前)。 createElm 开头那句「已经有 elm 又来自数组的先克隆一份」防的正是同一个 VNode 对象被两轮渲染共用, 后一轮把前一轮的 elm 覆盖掉。静态节点和插槽节点最容易撞上这件事,所以 cloneVNode 才存在。

虚拟 DOM 换来的不是速度

上面那段代码没有碰过一次浏览器 API。造一棵 VNode 树就是造一批普通对象,代价是内存和一次遍历, 和 DOM 无关。DOM 那一侧的代价则是另一回事:

graph LR
A[JavaScript操作] --> B[DOM API调用]
B --> C[浏览器解析]
C --> D[样式计算]
D --> E[布局/重排]
E --> F[绘制/重绘]
F --> G[合成]
G --> H[显示]

图上从「DOM API调用」往右那一串,是每次写 DOM 之后浏览器可能要重做的事。真正贵的是后面那几步, 不是调用本身;而它们贵不贵,取决于你改了什么、改在哪一层,不取决于你用没用虚拟 DOM。

所以这句话得说清楚:虚拟 DOM 不比手写 DOM 操作快。同一次更新,虚拟 DOM 要多做两件事 —— 造一棵新树,再把它和旧树比一遍 —— 然后才发出和你手写时一样的那几个 DOM 调用。 一个知道自己该改哪个元素的人写出来的代码,永远比它少做事。

它换来的是别的东西。

你写的是「界面应该长什么样」,不是「怎么从上一个样子改到这个样子」。 后者的分支数随状态数量涨得很快 —— 三个布尔量就是八种组合,每种组合到每种组合的迁移都得有人写对。 手写 DOM 真正难维护的地方在这儿,不是慢,是改到第七个条件时改错,而且错的表现是「某个元素没被清理掉」, 不会报错,只在特定操作顺序下出现。声明式的写法把这一整类问题换成了「每次都重新描述一遍目标状态」。

渲染目标可以换。 VNode 上除了 elm,没有一个字段和浏览器有关。 Vue 2 的 web 版 patch 只有一行 —— src/platforms/web/runtime/patch.js 里的 export const patch = createPatchFunction({ nodeOps, modules })

nodeOpssrc/platforms/web/runtime/node-ops.js)导出的全是最原始的那几件事: createElementcreateElementNScreateTextNodecreateCommentinsertBeforeremoveChildappendChildparentNodenextSiblingtagNamesetTextContentsetStyleScopemodules 是八个模块,每个模块导出 create / update / destroy 之类的钩子: web 平台的 attrsclasseventsdomPropsstyletransition,加上跨平台的 refdirectives。 把这两样换成别的实现,同一份 core/vdom/patch.js 就渲到别的地方去了 —— 它本身一行不用改。 这不是设想:2.6.14 的 src/platforms/ 底下就是两个目录,webweex,各自一份 node-ops.js 和一份 modules/,共用中间那份 patch。 Vue 3 把这件事做得更直白:createRenderer(options) 是公开 API,runtime-dom 只是官方那份 options。

更新的粒度被框架统一收口。 一个组件一次重渲染只产生一次 patch,这次改了十个数据也还是一次。 手写代码要做到这一点,得自己维护一个「本轮要改哪些地方」的队列。

反过来,什么时候它是净亏损?已知只有一个元素要改、而且知道改哪个的时候。 一个每秒刷新的计时器,el.textContent = t 一句话的事,走一遍 render + patch 就是白花的开销。 Vue 自己也承认这一点,v-once 和 Vue 3 的静态提升做的正是「这块别再进 diff 了」。 它们不是锦上添花的优化开关,是在给虚拟 DOM 的固定成本打补丁。

至于「快」,Vue 3 确实在这条路上拿回了一些,但拿回的方式不是把 diff 写得更快, 而是让 diff 根本不去看大部分节点。那是最后一节的事。

createElement 造 VNode,规范化分两档

createElement 就是 h,模板编译出来的 _c 也是它。同一个函数有两个入口,区别只在最后一个参数: vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)。 前者给编译产物用,后者给你手写的 render(h) 用。

外层的 createElement 只做两件事:data 的位置上传的是数组或者字符串数字时,把参数整体右移一位 (所以 h('div', [child])h('div', {}, [child]) 都能写);alwaysNormalize 为真时把 normalizationType 顶成 ALWAYS_NORMALIZE。干活的是 _createElement

Vue 2 · _createElement(src/core/vdom/create-element.js,简化)
export function _createElement (context, tag, data, children, normalizationType) {
// 省略:data 是响应式对象时的警告、data.is 改写 tag、key 不是原始值时的警告、
// children 是单个函数时当作默认作用域插槽
if (!tag) {
return createEmptyVNode() // :is 绑到了假值,返回一个注释节点占位
}
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// div、span 这些平台内置标签,直接造一个普通 VNode
vnode = new VNode(config.parsePlatformTagName(tag), data, children, undefined, undefined, context)
} else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// 在 $options.components 上查到了:是组件,交给 createComponent
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 查不到:当成未知标签原样造出来,命名空间留到运行时再定
vnode = new VNode(tag, data, children, undefined, undefined, context)
}
} else {
vnode = createComponent(tag, data, context, children) // tag 直接是选项对象或构造器
}
// 省略:结果是数组时直接返回、applyNS、registerDeepBindings
return isDef(vnode) ? vnode : createEmptyVNode()
}

resolveAsset 查的是 context.$options.components,而 $options 是原型链合并出来的 —— 局部注册的组件在自己身上,全局注册的在 Vue.options 上,一次查找同时覆盖两种。 查不到不报错,走最后那条分支当未知标签造出来,控制台里那句 Unknown custom element 是 patch 建 DOM 时才发出的。

两档规范化的区别在这里:

Vue 2 · 两档规范化(src/core/vdom/helpers/normalize-children.js)
// 一档:编译产物里出现了组件。函数式组件可能返回数组,拍平一层就够
export function simpleNormalizeChildren (children) {
for (let i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
// 二档:手写 render / JSX,或者模板里有 v-for、<template>、<slot>。什么都可能出现
export function normalizeChildren (children) {
return isPrimitive(children)
? [createTextVNode(children)] // 'hello' → 一个文本 VNode
: Array.isArray(children)
? normalizeArrayChildren(children) // 递归拍平、合并相邻文本、给 v-for 的项补默认 key
: undefined
}

编译产物凭什么能走简化版?因为编译器在生成代码时就已经把答案算出来了。 getNormalizationTypesrc/compiler/codegen/index.js)遍历子节点: 出现 v-for<template><slot> 就返回 2,出现可能是组件的标签就返回 1,都没有就是 0。 这个数字原样跟在 _c(...) 的第四个参数上,运行时照着它选分支 —— 0 连规范化函数都不调。 纯 HTML 标签的模板因此一次规范化都不做,因为编译器保证了它产出的一定是扁平的 Array<VNode>

这个数字自己就能看:完整版 Vue 2 里跑 Vue.compile('<div><span>a</span></div>').render.toString(), 产物里 _c 后面没有第四个参数;把里面换成带 v-for 的一行再编译一次,数字就出现了。

手写 render(h) 拿不到这个保证,所以 $createElementalwaysNormalize 写死成 true, 每次都走全套。这是模板比手写 render 便宜的地方之一 —— 差的不是那一次函数调用, 是 normalizeArrayChildren 对每个子节点的递归、类型判断和相邻文本合并, 一个有几十项的列表每次重渲染都要重来一遍。

patch 第一次被调用时,旧节点是页面上那个真的元素

graph TB
A[组件状态变化] --> B[生成新的Virtual DOM树]
B --> C[与旧Virtual DOM树对比]
C --> D[计算最小差异集]
D --> E[批量更新真实DOM]
subgraph "Virtual DOM层"
B
C
D
end
subgraph "真实DOM层"
E
end

这张图是常见的讲法,但中间那步在 Vue 里并不存在。没有「差异集」这个中间产物 —— patch 一边比一边就把 DOM 改了,比到某个属性不一样就当场调 setAttribute, 比到文本不一样就当场 setTextContent。所以图上第三、第四个方框其实是同一件事的两个说法, 真实的顺序是「递归比对,顺手改」。patchVnode 里那句 nodeOps.setTextContent(elm, vnode.text) 是直接执行的,不是记下来等会儿一起执行。

想自己确认,在那一句上打个断点,然后改一次数据:断点停下来时页面上的文字已经是新的了, 而这次 patch 还没走完 —— 后面并没有一个「统一应用差异集」的阶段。 有些虚拟 DOM 实现确实会产出一个 patch 列表再统一执行,Vue 不是。 被攒起来批量处理的是更新本身(一个 tick 内的多次数据变化合并成一次渲染),不是 DOM 操作。 这两件事经常被混成一句「虚拟 DOM 会批量更新 DOM」,实际上前者归调度器管,和虚拟 DOM 没关系。

入口长这样:

Vue 2 · patch(src/core/vdom/patch.js,简化)
return function patch (oldVnode, vnode, hydrating, removeOnly) {
5 collapsed lines
if (isUndef(vnode)) {
// 新树是空的:这是一次销毁,只跑钩子,不建任何东西
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 子组件第一次渲染走这条:没有容器,先把 DOM 造出来,插哪儿由父组件决定
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
if (isRealElement) {
// new Vue({ el: '#app' }) 的第一次:oldVnode 是页面上那个真的 <div id="app">
// 省略:带 data-server-rendered 属性时改走 hydrate
oldVnode = emptyNodeAt(oldVnode) // 拿它的 tagName 包一个空 VNode,好走下面的通用逻辑
}
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 新的先建好、插在旧元素后面
createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))
// 省略:向上把各层占位 VNode 的 elm 换成新的
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0) // 旧的到这一步才被删掉
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}

isRealElement 那条分支是首次挂载的全部秘密。vm.$elmountComponent 里被赋成了你传进来的那个 DOM 元素,于是 _update 第一次执行时 vm.__patch__(vm.$el, vnode) 的第一个参数是个 Element 而不是 VNode。 emptyNodeAt 把它包成 new VNode(tagName, {}, [], undefined, elm),后面的代码就不用再分两种情况了。 #app 这个元素本身会被替换掉,不是被填充 —— 这就是文档里说「el 指向的元素会被渲染结果替换」的实现。

顺序也值得看一眼:新元素先建好并插入,旧元素后删除。中间有一小段两者同时在文档里的时刻。 <transition> 的进入和离开动画能同时播,靠的就是这个顺序。

insertedVnodeQueue 是贯穿整次 patch 的一个数组。createElm 每建出一个带 data.hook.insert 的 VNode 就往里推一份,最后由 invokeInsertHook 统一回调。为什么要攒着?因为 insert 钩子的语义是 「这个节点已经在文档里了」,而 createElm 建它的那一刻整棵树还没接进文档,当场调就是撒谎。 组件的 mounted 就是从这个钩子里发出去的。

isInitialPatch 处理的是另一种情况:子组件自己那次 patch 结束时,它的根元素还没被父组件插进去。 这时队列不在本轮清空,而是整个挂到占位 VNode 的 data.pendingInsert 上, 等父组件那一轮的 initComponent 把它接过去,并进父组件的队列里。 父子组件 mounted 的先后顺序就是这么排出来的 —— 不是谁先谁后地各喊各的,是一个自底向上汇总的队列。

判断「是不是同一个节点」的是 sameVnode

Vue 2 · sameVnode 与 sameInputType(src/core/vdom/patch.js)
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
(
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
return typeA === typeB || (isTextInputType(typeA) && isTextInputType(typeB))
}

isDef(a.data) === isDef(b.data) 只比「有没有 data」,不比内容 —— 一个带任何属性的 <div> 和一个光秃秃的 <div> 不是同一个节点,会被整个重建。sameInputType 则相反, 它认为 textpasswordemailsearch 这些是同一类,改 type 不重建元素, 免得输入框里的内容和光标位置丢掉。

sameVnodepatch 用的,也是 updateChildren 里那套双端比较用的同一个判据。 两个列表怎么比、key 怎么参与、Vue 3 又怎么用最长递增子序列减少移动,都在 diff 那篇里,这里不重复。

createElm 先把整棵子树建完,最后才把自己插进去

Vue 2 · createElm(src/core/vdom/patch.js,简化)
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) {
// 省略:vnode 已经有 elm 且来自数组时先 cloneVNode 一份
vnode.isRootInsert = !nested
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return // 是组件 VNode,走 data.hook.init 那条路,这里就结束
}
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
// 省略:开发环境下的未知标签警告
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode) // 元素造出来了,还在文档外面
setScope(vnode)
createChildren(vnode, children, insertedVnodeQueue) // 递归:整棵子树先建完
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue) // attrs / class / style / 事件在这一步落上去
}
insert(parentElm, vnode.elm, refElm) // 最后才把自己挂进父节点
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}

createChildren 对每个子节点再调一次 createElmparentElm 传的是刚造出来的 vnode.elm。 于是整个建树过程是这样的:往下递归到叶子,叶子插进它的父元素,父元素插进祖父元素…… 一路回到最外层,最后那一次 insert 才真的把这棵树接进文档。

在那之前的每一次 insert,目标元素都还没进文档,浏览器不会为它们计算样式和布局。 这是虚拟 DOM 顺带得到的好处,但不是它独有的 —— 手写代码用 DocumentFragment 或者「先建后插」也是这个效果。 值得记住的是顺序本身:属性是在子节点之后才设的,所以 create 钩子里能拿到已经填好内容的元素。

更新走的是 patchVnode,它的分支顺序是这一段最容易记反的地方:

Vue 2 · patchVnode 的分支(src/core/vdom/patch.js,节选)
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 两边都有子节点:交给 updateChildren,也就是双端比较那一段
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 只有新的有子节点:旧的是文本就先清空,再把新子树整批建出来
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 只有旧的有子节点:整批删掉
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}

先问的是「新节点有没有 text」,不是「有没有 children」。 有 text 的节点根本不看子节点, 一行 setTextContent 结束;四种子节点情况全挤在 isUndef(vnode.text) 里面。 而且判据是 isUndef,不是真值判断 —— 空字符串的文本节点也是文本节点, 写成 if (vnode.text) 就会把它错送进 children 那条分支,把 elm 的内容当成子树去比。

这段之前还有两件事:组件 VNode 的 data.hook.prepatch 在这里被调用(新的 props 是在那一刻灌进子组件的), 以及 cbs.update 里那一串模块钩子,属性、class、style、事件的更新都在里面。 patchVnode 自己只管文本和子节点两件事。

删掉一棵子树:钩子跑 N 次,DOM 只删 1 次

Vue 2 · removeVnodes 与 invokeDestroyHook(src/core/vdom/patch.js)
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch) // 一条线:把这个元素从文档里摘下来
invokeDestroyHook(ch) // 另一条线:递归地跑销毁钩子
} else {
removeNode(ch.elm) // 文本节点,没有钩子可跑
}
}
}
}
function invokeDestroyHook (vnode) {
let i, j
const data = vnode.data
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode) // 组件在这里被 $destroy
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
}
if (isDef(i = vnode.children)) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j]) // 递归,子节点一个都不漏
}
}
}

两条线分得很开,这是它最值得记的地方。invokeDestroyHook 沿着 children 递归到底, 子树里每一个组件 VNode 的 data.hook.destroy 都会跑到 —— beforeDestroydestroyed 就是从这条路上发出去的,指令的 unbind 也是。而 DOM 那边,removeNode 只对最外层那个元素调了 removeChild,后代跟着它一起离开文档,没有人逐个删。

所以删掉一棵有五十个节点的子树,是「跑了若干次钩子」加「做了一次 DOM 操作」。 真正会拖慢这件事的从来不是 DOM,是子树里组件数量对应的那一串销毁逻辑。

这条线也是清理的唯一入口。解绑 watcher、把实例从父组件的 $children 里摘掉、 跑 beforeDestroydestroyed、执行指令的解绑,全在钩子这一侧,和元素有没有离开文档无关。 反过来说,绕过 patch 直接 el.remove() 掉一个组件的根元素,页面上是干净了, 实例还活着、还订阅着数据、下次数据变化时它照样重渲染 —— 只是渲染到一个不在文档里的元素上。 这也是 v-ifv-show 的实际区别:前者走这两条线,后者只改一个 display,实例从头到尾没动过。

removeAndInvokeRemoveHook 里还有一处设计:它把 cbs.remove 的数量加一算成一个计数器 (createRmCb),每个 remove 钩子跑完减一,减到零才真的调 removeNode<transition> 的离开动画就是靠它把删除推迟到动画结束的 —— 元素在动画期间还在文档里。

组件 VNode 上只多了两样东西

组件 VNode 和元素 VNode 是同一个类,区别只有两处:componentOptions 有值,data.hook 上挂了四个函数。

Vue 2 · createComponent 造出来的占位 VNode(src/core/vdom/create-component.js,节选)
installComponentHooks(data) // 往 data.hook 上装 init / prepatch / insert / destroy
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, // tag 是拼出来的,不是 'child'
data,
undefined, // children 是 undefined —— 插槽内容不放这儿
undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children }, // 这一坨就是 componentOptions
asyncFactory
)

children 为什么是 undefined?因为对 patch 来说,组件 VNode 是一片不透明的叶子。 插槽内容被塞进了 componentOptions.children,等子组件自己 render 时才用得上; patch 在这一层看到的就是「一个没有子节点的东西」,它只负责调 init 把实例造出来。

前面 createElm 第一行那个 createComponent(vnode, ...) 就是这条路的入口: 它发现 data.hook.init 存在就调下去,initnew 出实例并 $mount, 子组件自己那棵树的 patch 在那一刻递归地跑完,回来时 vnode.componentInstance 已经有了。 组件实例是在 patch 走到它身上的那一刻才存在的,父组件 render 完的时候还没有。

一个组件因此同时和两个 VNode 有关,名字很容易混:

浏览器控制台 · 占位 VNode 与组件根 VNode
const Child = { template: '<p>hi</p>' };
const vm = new Vue({
components: { Child },
template: '<div><child /></div>'
}).$mount('#app');
const child = vm.$children[0];
console.log(child.$vnode.tag); // vue-component-1-child
console.log(child._vnode.tag); // p
console.log(child._vnode.parent === child.$vnode); // true
console.log(child.$vnode.elm === child.$el); // true
console.log(vm._vnode.children[0] === child.$vnode); // true

$vnode父组件树里的那个占位节点_vnode这个组件自己 render 出来的根节点, 两者靠 _vnode.parent 连着,elm 指向同一个 DOM 元素。 记法:带 $ 的属于外面那棵树,带下划线的是自己内部的。

这两个引用不是摆设。patch 里被我省略掉的那段「向上把各层占位 VNode 的 elm 换成新的」, 走的就是 vnode.parent 这条链:一个组件的根元素被整个换掉时(比如根节点从 <div> 变成 <span>), 外面每一层占位 VNode 记着的 elm 都过期了,得沿着链挨个改过来。 组件根节点又是组件的高阶组件套了几层,这条链就有几节。 _update 末尾那个 while 循环干的是同一件事的另一半 —— 沿着 $parent$el 往上同步。

也正因为占位 VNode 和组件根 VNode 共用一个 elmthis.$el 在父子两侧拿到的是同一个元素。 组件上写 class="a" 能落到子组件根元素上,是同一件事的结果:那个 class 记在占位 VNode 的 data 里, 而 data 的更新最后作用在 vnode.elm 上。

四个钩子各自干了什么、prepatch 怎么在不重建实例的前提下换掉 props、keep-alive 又怎么劫持 initdestroy,是组件系统那篇的内容。这里只要记住 patch 认得 data.hook 这个约定: 它在建、更新、插入、删的四个时刻回调,组件、指令、<transition> 全都挂在这套约定上。

Vue 3 让 diff 只走编译期挑出来的那几个节点

graph TB
A[Virtual DOM性能优化] --> B[编译时优化]
A --> C[运行时优化]
A --> D[算法优化]
B --> B1[静态节点标记]
B --> B2[静态属性提升]
B --> B3[内联事件处理]
C --> C1[异步更新队列]
C --> C2[组件级更新]
C --> C3[函数式组件]
D --> D1[双端比较算法]
D --> D2[key值优化]
D --> D3[最长递增子序列]

图上三列,Vue 2 时代它们是三件独立的事:编译器在 AST 上盖静态标记,运行时把更新攒进队列, 算法层把比对做快。Vue 3 真正的改动是把左边那列的结论直接交给中间那列 —— 编译期已经知道哪些节点会变,就没必要在运行时再走一遍算法把它找出来。

先看数据结构的变化。Vue 3 的 VNode(packages/runtime-core/src/vnode.ts)字段换了名字,也多了三个:

Vue 2 Vue 3 换名之外还变了什么
tag type 字符串、组件对象,或 Text / Comment / Fragment / Static 这几个 Symbol
data props attrs、props、事件不再分家,全在一个对象里
children children 是什么类型不再靠猜,由 shapeFlag 的位标出来
elm el ——
—— shapeFlag 这个 VNode 是什么、子节点是什么形态,一个整数
—— patchFlag 编译期算出来的「哪些东西会变」
—— dynamicChildren 子树里所有会变的节点,拍平成一个数组

shapeFlag 是位掩码:

Vue 3 · ShapeFlags(packages/shared/src/shapeFlags.ts)
export enum ShapeFlags {
ELEMENT = 1,
FUNCTIONAL_COMPONENT = 1 << 1, // 2
STATEFUL_COMPONENT = 1 << 2, // 4
TEXT_CHILDREN = 1 << 3, // 8
ARRAY_CHILDREN = 1 << 4, // 16
SLOTS_CHILDREN = 1 << 5, // 32
TELEPORT = 1 << 6, // 64
SUSPENSE = 1 << 7, // 128
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

一个整数同时装下「我是什么」和「我的子节点是什么形态」,判断是一次 &COMPONENT 是两个位或起来的复合掩码,shapeFlag & ShapeFlags.COMPONENT 一次就能判完有状态和函数式两种。 Vue 2 里对应的判断是 isDef(vnode.componentOptions) 加上一串 typeof —— 换成位运算是这里最直白的提速。

patch 于是变成一次分派:

Vue 3 · patch 的分派(packages/runtime-core/src/renderer.ts,简化)
const patch = (n1, n2, container, anchor = null, /* … */ optimized = !!n2.dynamicChildren) => {
if (n1 === n2) return
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null // 类型不同:旧的直接卸载,下面按新建处理
}
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false // -2:放弃优化,退回全量比对
n2.dynamicChildren = null
}
const { type, ref, shapeFlag } = n2
switch (type) {
case Text: processText(n1, n2, container, anchor); break
case Comment: processCommentNode(n1, n2, container, anchor); break
case Static: /* 省略:静态节点整块插入,更新时什么都不做 */ break
case Fragment: processFragment(n1, n2, container, anchor, /* … */); break
default:
if (shapeFlag & ShapeFlags.ELEMENT) processElement(n1, n2, container, /* … */)
else if (shapeFlag & ShapeFlags.COMPONENT) processComponent(n1, n2, container, /* … */)
else if (shapeFlag & ShapeFlags.TELEPORT) type.process(n1, n2, container, /* … */)
else if (shapeFlag & ShapeFlags.SUSPENSE) type.process(n1, n2, container, /* … */)
}
}
function isSameVNodeType (n1, n2) {
return n1.type === n2.type && n1.key === n2.key
}

isSameVNodeType 只有两个条件,Vue 2 的 sameVnode 有五个。少掉的那几条在 Vue 3 里不需要了: 注释和文本各自是独立的 type Symbol,不用再比 isCommentdata 有没有的问题随着 patchFlag 的引入消失了 —— 属性会不会变编译期就写在 VNode 上,不必靠「有没有 data」猜。 inputtype 变化在 Vue 3 里则是走 props 更新,不再触发重建。

TeleportSuspense 那两条分支值得看一眼:它们的 type 自己带 process 方法, renderer 只是把内部 API 传进去让它自己处理。这是 Vue 3 能把这两个特性做成可 tree-shake 的原因 —— 不用就不打进包里,renderer 里没有它们的代码。

真正省事的是 dynamicChildren。编译器为每个「结构不会变」的区域开一个 block:

Vue 3 · block 的收集(packages/runtime-core/src/vnode.ts,节选)
const blockStack = []
let currentBlock = null
export function openBlock (disableTracking = false) {
blockStack.push(currentBlock = disableTracking ? null : [])
}
function setupBlock (vnode) {
// 收集期间攒下来的动态节点,整个挂到这个 block 根节点上
vnode.dynamicChildren = isBlockTreeEnabled > 0 ? currentBlock || EMPTY_ARR : null
closeBlock()
if (isBlockTreeEnabled > 0 && currentBlock) currentBlock.push(vnode)
return vnode
}
// createBaseVNode 的末尾:有 patchFlag 或者是组件,就登记进当前 block
if (isBlockTreeEnabled > 0 && !isBlockNode && currentBlock &&
(vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
vnode.patchFlag !== PatchFlags.NEED_HYDRATION) {
currentBlock.push(vnode)
}

编译产物里那句 (_openBlock(), _createElementBlock("div", …)) 就是在开一个 block。 这东西在 Vue SFC Playground 里能直接看:贴一段模板进去,右侧切到编译结果那一栏, _openBlock() 出现在哪几层、哪些节点后面跟着 /* TEXT */ 这样的补丁标记注释,一眼对得上。 createBaseVNode 每造出一个带 patchFlag 的节点就往 currentBlock 里推一份引用, block 关闭时这个数组被整个挂到根节点的 dynamicChildren 上。 它是扁平的 —— 嵌套多深的动态节点都躺在同一层数组里。

更新时分岔就在这儿:

Vue 3 · patchElement 的分岔与 patchBlockChildren(简化)
const { patchFlag, dynamicChildren } = n2
if (dynamicChildren) {
// 是 block:只比编译期挑出来的那些节点,静态的一个都不看
patchBlockChildren(n1.dynamicChildren, dynamicChildren, el, parentComponent, /* … */)
} else if (!optimized) {
// 手写 render / JSX:没有 block,老老实实全量比子节点
patchChildren(n1, n2, el, null, parentComponent, /* … */)
}
const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, /* … */) => {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
// 省略:挑 container —— Fragment、类型变了、组件这几种情况要取真实父节点
patch(oldVNode, newVNode, container, null, /* … */) // 下标一一对应,不需要 key
}
}

patchBlockChildren 就是一个 for:两个数组下标一一对应,第 i 个比第 i 个。 不用 key,不用双端,不用最长递增子序列 —— 因为一个 block 里的动态节点数量和顺序由编译产物固定, 它们本来就对得上。一百个静态节点里夹着一个 {{ msg }},Vue 3 更新时只碰那一个。

这条优化的边界要说清楚:

  • 只对模板有效。 手写 h() 造出来的 VNode 没有 patchFlag,也不在任何 block 里, optimized 为假,走的是完整的 patchChildren。这是 Vue 3 里模板明显优于手写 render 的地方, 和 Vue 2 是同一个方向,但差距拉大了。
  • v-ifv-for 各开一个新 block。 它们会改变结构,块内节点数不再固定, 所以边界在这里断开,各自那一段仍然要走完整的子节点比对 —— 这也是 v-for 上的 key 依然要写的原因。
  • patchFlag === -2BAIL)时整套优化关掉。 dynamicChildren 被置空,退回全量比对。 编译器碰到无法静态分析的情况就打这个标记。
  • 收益和树的规模有关。一个只有几个节点、大半都是动态的组件,block 省不下什么; 真正拉开差距的是「静态骨架很大、动态点很少」的那类页面。

回过头看,Vue 2 和 Vue 3 的 patch 骨架几乎一样:判断是不是同一个节点、不是就换掉、 是就往下比子节点、建 DOM 时先递归子树最后插自己。变的是运行时手上有多少编译期送来的信息 —— Vue 2 的 patch 拿到的是一棵什么都不知道的树,Vue 3 的 patch 拿到的是一棵已经标好了「只有这几个会变」的树。

这篇是 Vue.js 内部机制深度解析的第 5 篇。前一篇是 Vue.js 依赖收集与追踪机制深度剖析,后一篇是 Vue.js 模板编译全流程详解