Appearance
** 完整的 DOM Diff 流程是怎样的?(上)**
在上一节,我们了解了 VNode 是 DOM 的一种 JavaScript 对象表示。那么,当我们的数据发生变化时,比如执行 state.count++,Vue 是如何以最高效的方式,找出新旧两棵 VNode 树的差异,并仅更新发生变化的部分。
这个过程称为 DOM Diff。但在 Diff 真正开始之前,Vue 必须执行一系列精确的前置调度工作。本节将完整追踪从数据变更到 patch 函数(Diff 算法入口)被调用的全过程。
响应式触发
当我们修改一个响应式数据时:
javascript
const state = reactive({ count: 0 })
state.count++ // 信号发出!Proxy 会监听到这个变化,并触发一个在“依赖收集”阶段(track)收集到的副作用(effect)。在组件的上下文中,这个副作用就是那个负责更新整个组件的函数:componentUpdateFn。
这个函数是在组件挂载时,通过 setupRenderEffect 创建的。它的定义揭示了更新的核心:
typescript
// core/packages/runtime-core/src/renderer.ts
// 这是在 setupRenderEffect 中创建的
const effect = new ReactiveEffect(
componentUpdateFn, // 当数据变化时,要执行的主体
() => queueJob(update), // 关键!不是立即执行,而是“调度”执行
instance.scope
)这里是 Vue 性能的第一个关键:数据变化时,componentUpdateFn 并不会“立即”同步执行。而是调用了 () => queueJob(update),它将更新任务扔给了“调度器”。
** 调度系统 (Scheduler)**
为什么需要调度器?试想一个场景:你在一个函数里连续修改了 10 个数据。
javascript
function handleClick() {
state.a = 1
state.b = 2
state.c = 3
// ... 连续10次修改
}如果没有调度器,组件将愚蠢地重新渲染 10 次。调度器(Scheduler)就是为了解决这个问题而存在的,它扮演着“异步更新命令中心”的角色,负责去重、排序和批量处理更新任务。
** queueJob:任务入队与去重**
queueJob 是调度器的入口。它的逻辑很简单:
typescript
// core/packages/runtime-core/src/scheduler.ts
export function queueJob(job: SchedulerJob) {
// 1. 检查队列中是否“已经有”这个任务了
if (!queue.includes(job, /* ... */)) {
// 2. 如果没有,就把它放进队列
queue.push(job)
// 3. 安排一次“清空队列”的动作
queueFlush()
}
}这个 job 就是 componentUpdateFn。queueJob 确保了即使你在 1ms 内修改了 10 次 state.count,componentUpdateFn 也只会被推入队列一次。
** queueFlush:nextTick**
queueJob 只是把任务放进篮子,queueFlush 才是那个“稍后处理”的承诺。
typescript
// core/packages/runtime-core/src/scheduler.ts
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// 关键!使用 Promise.then() 将 flushJobs 放入“微任务”队列
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}resolvedPromise.then(flushJobs) 就是 nextTick 的核心实现。它利用了 Promise 微任务的特性,保证 flushJobs 会在当前所有同步代码执行完毕后、浏览器下次绘制前执行。
而这就是为什么你修改了数据,DOM 不会立即更新,而是在 nextTick 之后才会更新。
** flushJobs:更新的开始**
flushJobs 是真正执行更新的工人。它会遍历 queue 队列并执行所有 job(即 componentUpdateFn)。
typescript
// core/packages/runtime-core/src/scheduler.ts
function flushJobs(seen?: CountMap) {
isFlushing = true
// 关键!执行前进行排序
queue.sort(comparator)
try {
// 遍历队列,执行所有更新任务
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
job() // 运行 componentUpdateFn
}
} finally {
// ... 清理工作
}
}为什么需要排序? 这里藏着一个重要优化:保证父组件先于子组件更新。
Vue 在创建组件实例时会分配一个递增的 uid。comparator 会根据 uid 排序,uid 小的父组件会排在 uid 大的子组件前面。这避免了子组件不必要的重复渲染(因为父组件更新可能导致子组件接收的 props 变化,如果子组件先更新,父组件更新后它可能还得再更新一次)。
** 更新的执行:componentUpdateFn**
现在,调度器终于调用了 componentUpdateFn。这个函数是 Diff 流程的真正入口。让我们看看它的核心逻辑(已简化):
typescript
// core/packages/runtime-core/src/renderer.ts
const componentUpdateFn = () => {
if (!instance.isMounted) {
// 首次挂载...
} else {
// --- 这是更新逻辑 ---
// 1. 获取新的 VNode 数据 (next 存在于 instance.next 上)
const { next, vnode } = instance
if (next) {
updateComponentPreRender(instance, next) // 更新 props, slots 等
}
// 2. 重新执行 render(),获取“新的子树 VNode”
const nextTree = renderComponentRoot(instance)
// 3. 获取“旧的子树 VNode”
const prevTree = instance.subTree
// 4. 更新实例上的子树引用
instance.subTree = nextTree
// 5. 核心:将新旧两棵树交给 patch 函数!
patch(
prevTree, // 旧树
nextTree, // 新树
container,
anchor,
instance
)
}
}至此,我们才来到了 DOM Diff 的核心—— patch 函数。
** Diff 的入口:patchChildren**
patch 函数是一个总指挥,它会根据 VNode 类型(元素、组件、文本...)分发任务。当它要对比一个元素的子节点时,它会调用 patchChildren。
patchChildren 的工作是决策:该用什么策略来更新子节点?
typescript
// core/packages/runtime-core/src/renderer.ts
const patchChildren = (n1, n2, container, ...) => {
const c1 = n1 && n1.children // 旧子节点
const c2 = n2.children // 新子节点
const { shapeFlag, patchFlag } = n2
// 1. 编译器的“作弊”:PatchFlags 快速通道
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 告诉 Vue:这是个有 key 的列表,直接用最强 Diff
patchKeyedChildren(c1, c2, container, ...)
return
}
if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 告诉 Vue:这是个没有 key 的列表,用简单 Diff
patchUnkeyedChildren(c1, c2, container, ...)
return
}
}
// 2. 常规 Diff 决策
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 新子节点是文本
// ... (卸载旧的,设置新文本)
} else {
// 新子节点是数组 (多个 VNode)
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧子节点也是数组:这是最经典的情况,执行完整 Diff
patchKeyedChildren(c1, c2, container, ...)
} else {
// 旧子节点是文本或空:直接挂载新子节点
mountChildren(c2, container, ...)
}
}
}patchChildren 像是一个分诊台。它首先查看编译器给的 patchFlag“小抄”,如果能走快速通道就绝不迟疑。如果不行,它会根据新旧子节点的类型(文本、数组、空)来决定是卸载、挂载,还是执行最核心的 patchKeyedChildren。
** Diff 的核心:patchKeyedChildren (双端 Diff)**
patchKeyedChildren 是 Vue 3(借鉴 Inferno.js)Diff 算法的精髓所在。它不再是 Vue 2 那样简单的“首尾交叉对比”,而是采用了一种更高效的“双端比较 + 中间乱序”策略。
它的执行分为 5 个步骤:
typescript
// core/packages/runtime-core/src/renderer.ts
const patchKeyedChildren = (c1, c2, container, ...) => {
let i = 0 // 头部指针
const l2 = c2.length
let e1 = c1.length - 1 // 旧子节点尾部指针
let e2 = l2 - 1 // 新子节点尾部指针
// 步骤 1:自左向右同步 (头部)
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[i], c2[i])) {
patch(c1[i], c2[i], container, ...) // 递归 patch
} else {
break // 遇到不一样的,停止
}
i++
}
// 步骤 2:自右向左同步 (尾部)
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[e1], c2[e2])) {
patch(c1[e1], c2[e2], container, ...)
} else {
break
}
e1--
e2--
}
// --- 经过前两步,头部和尾部的“不动”节点都已处理完毕 ---
// 步骤 3:挂载新节点 (旧列表已遍历完,新列表有剩余)
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
if (i > e1) {
if (i <= e2) {
// i 到 e2 之间的都是新节点,逐个挂载
while (i <= e2) {
patch(null, c2[i], container, ...)
i++
}
}
}
// 步骤 4:卸载旧节点 (新列表已遍历完,旧列表有剩余)
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
else if (i > e2) {
while (i <= e1) {
// i 到 e1 之间的都是旧节点,逐个卸载
unmount(c1[i], ...)
i++
}
}
// 步骤 5:处理中间的“未知序列” (最复杂的情况)
// [i ... e1]: a b [c d e] f g
// [i ... e2]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
else {
// 这里是 Diff 算法的“深水区”
// Vue 需要在这里找出“c, d, e” 和 “e, d, c, h” 之间的关系
// 找出可复用的、需要移动的、需要新增的、需要删除的
// 这依赖于“最长递增子序列”算法 (LIS)
}
}小结
现在让我们回顾一下从数据变更到 Diff 开始的完整流程:
- 起点:
state.count++触发响应式系统。 - 调度:
ReactiveEffect并未立即执行,而是将更新任务componentUpdateFn交给queueJob。 - 批处理:调度器通过
queueFlush和Promise微任务,将所有同步修改去重、排序,并合并为一次异步更新。 - 重渲染:
flushJobs执行,componentUpdateFn被调用,它重新render()得到新 VNode 树。 - Diff 入口:
patch(prevTree, nextTree)被调用,进入patchChildren。 - 双端 Diff:
patchKeyedChildren通过高效的双端同步(步骤 1、2)快速处理了列表两端不变的节点,并通过(步骤 3、4)处理了简单的- 新增和删除。
在下一节中,我们将深入第六点和第七点,彻底搞懂 Vue 3 是如何利用最长递增子序列算法,来最小化 DOM 移动,完成“未知序列下”的高效 Diff。
