Vue渲染器之patch|原理篇
30 秒速记
- 核心判断:虚拟节点是对界面结构的描述,渲染器通过挂载与更新把声明式描述映射为宿主环境操作
- 原理主线:围绕 「基本原则」、「替换 VNode」、「更新标签元素」 建立输入、状态变化与输出之间的因果关系
- 文章范围:介绍了Vue渲染器中patch的原理和实现,包括新旧VNode的比对、DOM更新流程、核心patch逻辑及其在组件更新中的应用,帮助读者深入理解Vue高效渲染的底层机制。
- 边界与代价:
key、节点类型和稳定序列决定复用边界;虚拟 DOM 解决可预测更新,不保证永远比手写 DOM 快 - 工程落地:性能分析要看实际提交次数、节点移动和组件边界,不能只用“减少 DOM 操作”解释
patch 的作用是比较新旧 VNode,在保证结果正确的前提下尽量复用已有真实节点。 新旧节点类型不同就移除旧节点并挂载新节点;同为元素但 tag 不同也直接替换,因为继续比较没有实际意义。类型和标签一致时,新节点通过 nextVNode.el = prevVNode.el 复用 DOM,再更新 VNodeData 与子节点。像样式更新既要写入新增或变化的值,也要清掉新数据中已经不存在的旧值,这类逻辑适合统一收口到 patchData。
这篇文章不要按 API 清单来背。先用上面的 Mind Map 建立全局结构,再通过交互 DEMO 观察正常路径和边界路径如何改变状态;阅读正文时重点核对每一步的输入、负责执行的参与者、产生的中间状态以及最终可观察结果。遇到版本敏感结论,要把“历史实现”“当前行为”和“工程兼容策略”分开说明;遇到性能或架构取舍,则用实际指标、失败现象和验证手段支撑判断。
版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。
在上一章中我们讲解并实现了渲染器的挂载逻辑,本质上就是将各种类型的
VNode渲染成真实DOM的过程。渲染器除了将全新的VNode挂载成真实DOM之外,它的另外一个职责是负责对新旧VNode进行比对,并以合适的方式更新DOM,也就是我们常说的patch。本章内容除了让你了解基本的比对逻辑之外,还讲述了在新旧VNode比对的过程中应该遵守怎样的原则,让我们开始吧!
# 基本原则
通常重渲染(re-render)是由组件的更新开始的,因为在框架的使用层面开发者通过变更数据状态从而引起框架内部对UI的自动更新,但是组件的更新本质上还是对真实DOM的更新,或者说是对标签元素的更新,所以我们就优先来看一下如何更新一个标签元素。
我们首先回顾一下渲染器的代码,如下:
function render(vnode, container) {
const prevVNode = container.vnode
if (prevVNode == null) {
if (vnode) {
// 没有旧的 VNode,使用 `mount` 函数挂载全新的 VNode
mount(vnode, container)
// 将新的 VNode 添加到 container.vnode 属性下,这样下一次渲染时旧的 VNode 就存在了
container.vnode = vnode
}
} else {
if (vnode) {
// 有旧的 VNode,则调用 `patch` 函数打补丁
patch(prevVNode, vnode, container)
// 更新 container.vnode
container.vnode = vnode
} else {
// 有旧的 VNode 但是没有新的 VNode,这说明应该移除 DOM,在浏览器中可以使用 removeChild 函数。
container.removeChild(prevVNode.el)
container.vnode = null
}
}
}
如上高亮的两句代码所示,当使用 render 渲染器渲染一个全新的 VNode 时,会调用 mount 函数挂载该 VNode,同时让容器元素存储对该 VNode 对象的引用,这样当再次调用渲染器渲染新的 VNode 对象到相同的容器元素时,由于旧的 VNode 已经存在,所以会调用 patch 函数以合适的方式进行更新,如下代码所示:
// 旧的 VNode
const prevVNode = h('div')
// 新的 VNode
const nextVNode = h('span')
// 第一次渲染 VNode 到 #app,此时会调用 mount 函数
render(prevVNode, document.getElementById('app'))
// 第二次渲染新的 VNode 到相同的 #app 元素,此时会调用 patch 函数
render(nextVNode, document.getElementById('app'))
patch 函数会对新旧 VNode 进行比对,也就是我们所说的 diff,那么不同的两个 VNode 之间应该遵守怎样的比对规则呢?其实这个问题很容易回答,我们知道 VNode 有类型之分,不同类型的 VNode 之间存在一定的差异,所以不同的 VNode 之间第一个比对原则就是:只有相同类型的 VNode 才有比对的意义,例如我们有两个 VNode,其中一个 VNode 的类型是标签元素,而另一个 VNode 的类型是组件,当这两个 VNode 进行比对时,最优的做法是使用新的 VNode 完全替换旧的 VNode,换句话说我们根本就没有做任何比对的操作,因为这完全没有意义,所以根据这个思想我们实现的 patch 函数如下:
function patch(prevVNode, nextVNode, container) {
// 分别拿到新旧 VNode 的类型,即 flags
const nextFlags = nextVNode.flags
const prevFlags = prevVNode.flags
// 检查新旧 VNode 的类型是否相同,如果类型不同,则直接调用 replaceVNode 函数替换 VNode
// 如果新旧 VNode 的类型相同,则根据不同的类型调用不同的比对函数
if (prevFlags !== nextFlags) {
replaceVNode(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.ELEMENT) {
patchElement(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.COMPONENT) {
patchComponent(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.TEXT) {
patchText(prevVNode, nextVNode)
} else if (nextFlags & VNodeFlags.FRAGMENT) {
patchFragment(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.PORTAL) {
patchPortal(prevVNode, nextVNode)
}
}
如上代码所示,既然 patch 函数的作用是用来比对新旧 VNode,那么 patch 函数必然需要接收新旧 VNode 作为参数,我们使用 prevVNode 形参代表旧的 VNode,使用 nextVNode 形参代表新的 VNode,如上是很清晰的一段比对逻辑,首先我们需要拿到新旧 VNode 的类型(flags),接着是一连串的 if...else if 语句,其核心原则是:如果类型不同,则直接调用 replaceVNode 函数使用新的 VNode 替换旧的 VNode,否则根据不同的类型调用与之相符的比对函数,如下图所示:

# 替换 VNode
我们首先来研究一下如何替换 VNode,即 replaceVNode 函数应该做什么,我们先来复现需要替换 VNode 的场景,如下代码所示:
// 旧的 VNode 是一个 div 标签
const prevVNode = h('div', null, '旧的 VNode')
class MyComponent {
render () {
return h('h1', null, '新的 VNode')
}
}
// 新的 VNode 是一个组件
const nextVNode = h(MyComponent)
// 先后渲染新旧 VNode 到 #app
render(prevVNode, document.getElementById('app'))
render(nextVNode, document.getElementById('app'))
在如上代码中,我们先后渲染了新旧 VNode 到 #app 元素,由于新旧 VNode 具有不同的类型,所以此时会触发 VNode 的替换操作,替换操作并不复杂,本质就是把旧的 VNode 所渲染的DOM移除,再挂载新的 VNode,如下是 replaceVNode 函数的实现:
function replaceVNode(prevVNode, nextVNode, container) {
// 将旧的 VNode 所渲染的 DOM 从容器中移除
container.removeChild(prevVNode.el)
// 再把新的 VNode 挂载到容器中
mount(nextVNode, container)
}
TIP
完整代码&在线体验地址:https://codesandbox.io/s/jlxjk18vm5
看上去很简单,但实际上仅有这两行代码的话,是存在缺陷的。至于有何缺陷我们会在本章的后面讲解,因为目前我们的背景铺垫还不够。
# 更新标签元素
# 更新标签元素的基本原则
当新旧 VNode 的类型不同时,会调用 replaceVNode 函数直接使用新的 VNode 替换旧的 VNode。但如果新旧 VNode 的类型相同,则会根据不同的类型调用不同的比对函数,这一小节我们就来看看如何更新一个标签元素。
首先即使两个 VNode 的类型同为标签元素,但它们也可能是不同的标签,也就是说它们的 tag 属性值不尽相同。这就又引申出了一条更新原则:我们认为不同的标签渲染的内容不同,例如 ul 标签下只能渲染 li 标签,所以拿 ul 标签和一个 div 标签进行比对是没有任何意义的,这种情况下我们不会对旧的标签元素打补丁,而是使用新的标签元素替换旧的标签元素,这就需要用到我们前面讲过的 replaceVNode 函数,如下 patchElement 函数所示:
