Skip to content

** 完整的 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 就是 componentUpdateFnqueueJob 确保了即使你在 1ms 内修改了 10 次 state.countcomponentUpdateFn 也只会被推入队列一次

** queueFlushnextTick**

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 在创建组件实例时会分配一个递增的 uidcomparator 会根据 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 开始的完整流程:

  1. 起点state.count++ 触发响应式系统。
  2. 调度ReactiveEffect 并未立即执行,而是将更新任务 componentUpdateFn 交给 queueJob
  3. 批处理:调度器通过 queueFlushPromise 微任务,将所有同步修改去重、排序,并合并为一次异步更新。
  4. 重渲染flushJobs 执行,componentUpdateFn 被调用,它重新 render() 得到新 VNode 树
  5. Diff 入口patch(prevTree, nextTree) 被调用,进入 patchChildren
  6. 双端 DiffpatchKeyedChildren 通过高效的双端同步(步骤 1、2)快速处理了列表两端不变的节点,并通过(步骤 3、4)处理了简单的- 新增和删除。

在下一节中,我们将深入第六点和第七点,彻底搞懂 Vue 3 是如何利用最长递增子序列算法,来最小化 DOM 移动,完成“未知序列下”的高效 Diff。


微信公众号二维码

Last updated: