跳到主要内容
预计阅读 61 分钟

响应式原理与虚拟DOM

恭喜你进入精通篇!前面的章节中,你已经完成了从基础到实战的全部历程,能够熟练地使用 Vue 3 构建应用。但”会用”和”精通”之间,还隔着一层对底层原理的理解。从本章开始,我们将深入 Vue 3 的引擎室,看看那些 API 背后到底发生了什么。

本章是精通篇的第一章,我们将从两大核心机制切入:响应式系统虚拟 DOM。响应式系统解决的是”数据变了,谁需要更新”的问题;虚拟 DOM 解决的是”需要更新时,如何高效操作真实 DOM”的问题。二者协同,构成了 Vue 3 的渲染引擎。


📋 开篇自测

在正式开始之前,先检验一下你的前置知识:

  1. Object.definePropertyProxy 分别可以拦截哪些操作?二者在使用上有什么本质区别?
  2. JavaScript 的事件循环中,微任务(microtask)和宏任务(macrotask)的执行顺序是什么?
  3. 为什么在 v-for 中使用列表渲染时,Vue 要求提供 key 属性?

如果这三个问题你都能清晰回答,本章的学习会非常顺畅。如果有模糊的地方,也不用担心,我们会在过程中逐步讲清。


一、响应式系统设计——从 defineProperty 到 Proxy

1.1 为什么需要响应式?

先想一个最基本的问题:当我们修改一个 JavaScript 变量时,视图怎么知道要更新?

let msg = 'hello'
// 修改 msg 之后,页面上显示的内容怎么自动变化?
msg = 'world'

普通的变量赋值不会产生任何”通知”。要实现数据驱动视图,我们必须有一种机制能够拦截数据的读取和修改,在读取时记录”谁在用这个数据”,在修改时通知”所有使用者去更新”。这就是响应式系统的核心诉求。

1.2 Vue 2 的方案:Object.defineProperty

Vue 2 使用 Object.defineProperty 来实现数据拦截。我们用一段简化代码来理解其核心思想:

// 迷你版 Vue 2 响应式
function defineReactive(obj, key, val) {
  // 每个属性对应一个依赖收集器
  const deps = new Set()

  Object.defineProperty(obj, key, {
    get() {
      // 读取时:收集当前正在执行的副作用函数
      if (activeEffect) {
        deps.add(activeEffect)
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      // 写入时:通知所有依赖方执行更新
      deps.forEach(effect => effect())
    }
  })
}

function observe(obj) {
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

这套方案能工作,但存在三个先天缺陷:

缺陷一:无法检测属性的新增和删除。 Object.defineProperty 只能拦截已有属性的读写,对象上新增一个属性不会被拦截。这就是 Vue 2 中需要 Vue.set() 的原因。

缺陷二:数组的变异方法需要特殊处理。 对数组使用索引赋值(如 arr[0] = 'x')或修改 length 无法被检测到,Vue 2 不得不重写了 pushpopsplice 等 7 个数组方法。

缺陷三:初始化时需要递归遍历整个对象树。 无论某些深层属性是否会被用到,都要在初始化阶段全部转换为响应式,在大型对象上会产生不必要的性能开销。

1.3 Vue 3 的方案:Proxy

Proxy 是 ES6 提供的元编程能力,它可以对目标对象创建一个代理层,拦截几乎所有操作:

const handler = {
  get(target, key, receiver) { /* 拦截读取 */ },
  set(target, key, value, receiver) { /* 拦截写入 */ },
  deleteProperty(target, key) { /* 拦截 delete */ },
  has(target, key) { /* 拦截 in 操作符 */ },
  ownKeys(target) { /* 拦截 Object.keys / for...in */ }
}

const proxy = new Proxy(target, handler)

对比 Object.definePropertyProxy 的优势一目了然:

+---------------------+--------------------+--------------------+
|       能力          | defineProperty     | Proxy              |
+---------------------+--------------------+--------------------+
| 拦截属性读写        |       Yes          |       Yes          |
| 拦截属性新增/删除   |       No           |       Yes          |
| 拦截 in / for...in  |       No           |       Yes          |
| 拦截数组索引操作    |   需 hack          |       Yes          |
| 惰性递归(按需)    |       No           |       Yes          |
| 需要递归初始化      |       Yes          |       No           |
+---------------------+--------------------+--------------------+

其中惰性递归是一个关键优化:Vue 3 只在属性被访问时才对其子对象创建 Proxy,而不是像 Vue 2 那样在初始化时一次性递归到底。这意味着如果某个深层嵌套的属性从未被模板或计算属性读取,它就不会被代理,节省了启动时间和内存。


二、响应式核心实现——reactive 与 ref

2.1 从零构建 reactive

理解了 Proxy 的优势后,我们来从零构建一个迷你版的 reactive

// 缓存已创建的代理,避免重复代理同一个对象
const reactiveMap = new WeakMap()

function reactive(target) {
  // 只代理对象类型
  if (typeof target !== 'object' || target === null) {
    return target
  }

  // 如果已经代理过,直接返回缓存
  const existingProxy = reactiveMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)

      // 依赖收集
      track(target, key)

      // 惰性递归:访问到子对象时才递归代理
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },

    set(target, key, value, receiver) {
      const oldValue = target[key]
      const result = Reflect.set(target, key, value, receiver)

      // 值真正变化时才触发更新
      if (oldValue !== value) {
        trigger(target, key)
      }
      return result
    },

    deleteProperty(target, key) {
      const hadKey = key in target
      const result = Reflect.deleteProperty(target, key)
      if (hadKey && result) {
        trigger(target, key)
      }
      return result
    }
  })

  reactiveMap.set(target, proxy)
  return proxy
}

这里有几个值得注意的设计要点:

  1. 使用 WeakMap 缓存代理对象。同一个原始对象只会创建一个代理,并且当原始对象被垃圾回收时,缓存也会自动释放。
  2. 使用 Reflect 而非直接操作 targetReflect.get/set 能正确处理 receiver(即代理对象自身),确保 this 指向正确,在原型链继承场景下不会出问题。
  3. 惰性递归get 中对子对象调用 reactive(result),只有真正访问到的嵌套属性才会被代理。

2.2 ref 的实现原理

reactive 基于 Proxy,而 Proxy 只能代理对象。对于原始类型(stringnumberboolean),Vue 3 提供了 ref,其核心思想是用一个对象包裹原始值

function ref(rawValue) {
  const refObject = {
    _value: rawValue,
    get value() {
      // 依赖收集
      track(refObject, 'value')
      return this._value
    },
    set value(newValue) {
      if (newValue !== this._value) {
        this._value = newValue
        // 触发更新
        trigger(refObject, 'value')
      }
    }
  }
  return refObject
}

上面是简化的概念实现。Vue 3 的实际源码使用 class RefImpl 来实现 ref,通过类的 getter/setter 实现依赖追踪,并在内部区分了原始类型值和对象值(对象值会被进一步传递给 reactive() 处理)。感兴趣的读者可以查看 vue/core 源码中的 ref.ts

这就是为什么使用 ref 时需要通过 .value 来访问和修改值——因为底层必须依靠对象属性的 getter/setter 来触发依赖收集和更新通知。而在模板中使用时,Vue 的编译器会自动帮我们解包 .value,这是一个编译时的便利。


三、副作用系统——effect 与依赖追踪

3.1 什么是副作用函数

在响应式系统中,“副作用函数”(effect)是指读取了响应式数据的函数。当响应式数据变化时,这些函数需要重新执行。组件的渲染函数就是一个典型的副作用函数。

我们来构建迷你版的 effect

// 当前正在执行的副作用函数
let activeEffect = null

function effect(fn) {
  const effectFn = () => {
    // 执行前,将自身设为"当前活跃 effect"
    activeEffect = effectFn
    // 执行 fn 时会触发响应式数据的 get,从而收集依赖
    const result = fn()
    activeEffect = null
    return result
  }

  // 立即执行一次,完成初始的依赖收集
  effectFn()
  return effectFn
}

使用方式:

const state = reactive({ count: 0 })

effect(() => {
  console.log('count is:', state.count)
})
// 打印: count is: 0

state.count = 1
// 自动打印: count is: 1

effect 执行回调函数 fn 时,fn 内部访问了 state.count,触发了 Proxy 的 get 拦截器。此时 activeEffect 指向当前的 effectFn,于是 track 函数就把 effectFn 记录为 state.count 的一个依赖。之后当 state.count 被修改时,trigger 函数找到所有依赖并重新执行它们。

3.2 依赖追踪的数据结构

tracktrigger 的核心是维护一套精巧的数据结构,把”哪个对象的哪个属性”映射到”哪些副作用函数”:

targetMap (WeakMap)
  |
  |-- target1 (Map)
  |     |-- key1 --> Set { effectA, effectB }
  |     |-- key2 --> Set { effectC }
  |
  |-- target2 (Map)
        |-- key1 --> Set { effectA }

用 ASCII 图更清晰地表示这个三层结构:

 WeakMap                Map                 Set
+----------+      +-----------+      +----------------+
| target --|----->| 'count' --|----->| effectFn1      |
|          |      | 'name'  --|--+   | effectFn2      |
+----------+      +-----------+  |   +----------------+
| target2--|--+                  |
+----------+  |                  +-->+----------------+
              |                      | effectFn3      |
              +-->+-----------+      +----------------+
                  | 'age'  ---|----->+----------------+
                  +-----------+      | effectFn1      |
                                     +----------------+

为什么选择这三种数据结构?

  • WeakMap(第一层):以原始对象为 key。当对象不再被引用时,相关的依赖信息可以被自动垃圾回收,不会造成内存泄漏。
  • Map(第二层):以属性名为 key,因为属性名是字符串或 Symbol,普通 Map 即可。
  • Set(第三层):存放 effect 函数,天然去重,同一个 effect 不会被重复收集。

下面是 tracktrigger 的实现:

const targetMap = new WeakMap()

function track(target, key) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }

  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }

  deps.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)
  if (!deps) return

  // 创建副本执行,避免在遍历过程中集合被修改导致无限循环
  const effectsToRun = new Set(deps)
  effectsToRun.forEach(effectFn => effectFn())
}

3.3 嵌套 effect 与 effect 栈

在真实场景中,effect 是可以嵌套的。比如父组件的渲染 effect 中嵌套了子组件的渲染 effect:

effect(() => {
  // 父组件的渲染
  console.log('parent:', state.a)

  effect(() => {
    // 子组件的渲染
    console.log('child:', state.b)
  })

  // 回到父组件,此时 activeEffect 应该指向父组件的 effect
  console.log('parent continues:', state.c)
})

如果只用一个 activeEffect 变量,当内层 effect 执行完后,activeEffect 会被置为 null,导致外层 effect 后续的依赖收集失败。

解决方案是使用一个栈结构来管理 effect 的嵌套:

let activeEffect = null
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn
    // 入栈
    effectStack.push(effectFn)

    const result = fn()

    // 出栈,恢复外层 effect
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null

    return result
  }

  effectFn()
  return effectFn
}

这样,无论嵌套多少层,每一层 effect 执行完毕后都能正确恢复到外层的 activeEffect。Vue 3 源码中使用了类似的 parent 指针方案,本质上是同样的思路——用链表或栈来维护 effect 的嵌套关系。

3.4 依赖清理:避免无效更新

考虑这样一个场景:

const state = reactive({ show: true, a: 1 })

effect(() => {
  if (state.show) {
    console.log('a:', state.a)
  }
})

// 隐藏后,a 的变化不应再触发 effect
state.show = false
state.a = 2  // 理想情况下不应触发 effect

第一次执行 effect 时,state.showstate.a 都被读取了,所以两者都收集了该 effect。但当 state.show 变为 false 后,state.a 的分支不再执行,此时 state.a 的依赖集合中仍然保留着这个 effect,导致修改 state.a 时会产生无效的重新执行。

Vue 3 的解决思路是:每次 effect 重新执行前,先把自身从所有依赖集合中移除,然后在执行过程中重新收集依赖。 这样,不再被访问的属性自然就不会再包含该 effect。

function effect(fn) {
  const effectFn = () => {
    // 清理:从所有依赖集合中移除自身
    cleanup(effectFn)

    activeEffect = effectFn
    effectStack.push(effectFn)
    const result = fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null
    return result
  }

  // effectFn 记录自身被哪些依赖集合收集
  effectFn.deps = []
  effectFn()
  return effectFn
}

function cleanup(effectFn) {
  effectFn.deps.forEach(depSet => {
    depSet.delete(effectFn)
  })
  effectFn.deps.length = 0
}

// track 中也要反向记录
function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let deps = depsMap.get(key)
  if (!deps) depsMap.set(key, (deps = new Set()))

  deps.add(activeEffect)
  // 反向记录:让 effect 知道自己被哪些 Set 收集了
  activeEffect.deps.push(deps)
}

Vue 3.2 对这一机制做了进一步优化,使用二进制标记位来区分”已有依赖”和”新依赖”,避免了每次都全量清除再全量重建的开销。但核心思想不变:依赖关系是动态的,每次执行都可能不同,必须及时清理过期依赖。


四、批量更新与调度

4.1 问题:频繁更新的性能灾难

来看一个典型场景:

const state = reactive({ count: 0 })

effect(() => {
  document.title = `Count: ${state.count}`
})

// 点击按钮时循环修改 1000 次
for (let i = 0; i < 1000; i++) {
  state.count++
}

如果每次 state.count++ 都立即触发 effect 执行,DOM 就会被更新 1000 次。但用户最终看到的只是 Count: 1000,中间的 999 次更新全是浪费。

4.2 调度器与任务队列

Vue 3 的解决方案是将更新任务推入队列,在当前同步代码执行完毕后统一批量处理。这个机制的核心是 scheduler(调度器):

// 更新队列,用 Set 天然去重
const queue = new Set()
// 标记是否已经安排了刷新
let isFlushing = false

function queueJob(job) {
  queue.add(job)  // Set 自动去重

  if (!isFlushing) {
    isFlushing = true
    // 在微任务中批量执行
    Promise.resolve().then(flushJobs)
  }
}

function flushJobs() {
  // 按优先级排序:父组件先于子组件
  const jobs = [...queue].sort((a, b) => a.id - b.id)
  queue.clear()

  for (const job of jobs) {
    job()
  }

  isFlushing = false

  // 如果在执行过程中又有新任务加入,递归处理
  if (queue.size > 0) {
    flushJobs()
  }
}

在 Vue 3 中,组件的渲染 effect 并不直接执行 componentUpdateFn,而是通过调度器将其推入队列:

// 伪代码:组件的 effect 创建
const renderEffect = new ReactiveEffect(
  componentUpdateFn,
  () => queueJob(update)  // 这就是 scheduler
)

当响应式数据变化时,trigger 发现 effect 有 scheduler,就调用 scheduler 而不是直接调用 effect.run()。scheduler 将 update 推入队列,同一个组件的多次数据修改只会在队列中保留一条记录。

4.3 nextTick 的本质

nextTick 的作用是让用户代码可以在 DOM 更新完成之后执行。它的实现其实非常简单——返回的就是队列刷新完成后的 Promise:

function nextTick(fn) {
  // currentFlushPromise 是 queueFlush 中创建的 Promise
  const p = currentFlushPromise || Promise.resolve()
  return fn ? p.then(fn) : p
}

这就是为什么 await nextTick() 能确保在 DOM 更新后执行代码。整个流程如下:

同步代码执行            微任务队列
+-----------------+    +------------------------+
| state.count++   |    |                        |
| state.count++   |--->| flushJobs()            |
| state.count++   |    |  --> componentUpdateFn  |
| await nextTick()|    |  --> nextTick callback  |
+-----------------+    +------------------------+
       |                          |
       v                          v
  1000次++只产生           DOM只更新一次
  一个队列任务            count: 0 -> 1000

4.4 更新的三个阶段

Vue 3 的更新过程分为三个阶段:

  1. Pre 阶段(更新前):watchwatchEffect 的回调默认在此阶段执行(flush: 'pre'),在组件 DOM 更新之前触发。需要注意,当父子组件之间存在交叉数据监听时,watcher 的执行顺序可能更复杂——Pre 队列中的 watcher 按创建顺序执行,而非严格按组件层级排序。
  2. Flushing 阶段(更新中):按照组件 ID 从小到大排序执行更新队列,保证父组件先于子组件更新。
  3. Post 阶段(更新后):mountedupdated 等生命周期钩子,以及 flush: 'post' 的侦听器在此阶段执行。

五、虚拟 DOM 与 VNode

5.1 为什么需要虚拟 DOM

直接操作真实 DOM 是昂贵的。一个 div 元素上挂载了数百个属性和方法,创建和修改 DOM 节点涉及浏览器的布局计算、样式计算、绘制等一系列开销。

虚拟 DOM 的核心思想是:用轻量的 JavaScript 对象来描述 DOM 结构,在内存中完成新旧对比,最后只将差异部分应用到真实 DOM 上。

除了性能优化之外,虚拟 DOM 还带来了一个重要能力:跨平台渲染。由于 VNode 只是普通的 JavaScript 对象,与具体平台无关,通过替换不同的渲染后端,可以将同样的组件树渲染到浏览器 DOM、服务端 HTML 字符串、原生移动端控件等不同目标。

5.2 VNode 的结构设计

一个 VNode 本质上是一个描述节点信息的对象。我们来定义一个简化版:

// 简化版 VNode 结构
const vnode = {
  type: 'div',          // 标签名、组件对象或 Fragment/Text 等特殊类型
  props: {              // 属性:class、style、事件等
    class: 'container',
    onClick: handleClick
  },
  children: [           // 子节点:字符串(文本)或 VNode 数组
    {
      type: 'span',
      props: null,
      children: 'Hello World'
    }
  ],
  key: null,            // diff 算法使用的唯一标识
  el: null,             // 对应的真实 DOM 引用(挂载后填充)
  shapeFlag: 0          // 位运算标记:元素/组件/文本等类型
}

Vue 3 使用 shapeFlag 进行位运算标记,高效判断节点类型:

// 通过位运算组合标记
const ShapeFlags = {
  ELEMENT:              1,        // 0001  — 普通 DOM 元素
  FUNCTIONAL_COMPONENT: 1 << 1,  // 0010  — 函数式组件(Vue 3 源码中有实际用途,此处简化示例未涉及)
  STATEFUL_COMPONENT:   1 << 2,  // 0100  — 有状态组件
  TEXT_CHILDREN:        1 << 3,  // 1000
  ARRAY_CHILDREN:       1 << 4,  // 10000
}

// 判断时用按位与
if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
  // 是普通 DOM 元素
}

位运算的好处是一个数字可以同时携带多种标记信息,且判断速度极快。

5.3 h() 函数与 VNode 创建

h() 函数(hyperscript 的缩写)是创建 VNode 的工厂函数:

function h(type, props, children) {
  // 确定 shapeFlag
  let shapeFlag = 0
  if (typeof type === 'string') {
    shapeFlag = ShapeFlags.ELEMENT
  } else if (typeof type === 'object') {
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT
  }

  // 确定 children 类型并组合标记
  if (typeof children === 'string') {
    shapeFlag |= ShapeFlags.TEXT_CHILDREN
  } else if (Array.isArray(children)) {
    shapeFlag |= ShapeFlags.ARRAY_CHILDREN
  }

  return {
    type,
    props,
    children,
    key: props?.key ?? null,
    el: null,
    shapeFlag
  }
}

// 使用示例
const vnode = h('div', { class: 'app' }, [
  h('h1', null, 'Title'),
  h('p', null, 'Content')
])

模板中的 HTML 会在编译阶段被转换为 h() 函数调用。例如:

<div class="app">
  <h1>Title</h1>
  <p>{{ msg }}</p>
</div>

编译后大致等价于:

function render(ctx) {
  return h('div', { class: 'app' }, [
    h('h1', null, 'Title'),
    h('p', null, ctx.msg)
  ])
}

5.4 挂载流程

有了 VNode 之后,需要将其渲染成真实 DOM。挂载流程的核心逻辑如下:

function mount(vnode, container) {
  if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
    mountElement(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    mountComponent(vnode, container)
  }
}

function mountElement(vnode, container) {
  // 1. 创建真实 DOM 元素
  const el = (vnode.el = document.createElement(vnode.type))

  // 2. 设置属性
  if (vnode.props) {
    for (const key in vnode.props) {
      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key])
      } else {
        el.setAttribute(key, vnode.props[key])
      }
    }
  }

  // 3. 处理子节点
  if (vnode.shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    el.textContent = vnode.children
  } else if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    vnode.children.forEach(child => mount(child, el))
  }

  // 4. 插入容器
  container.appendChild(el)
}

整个渲染流程可以用下图概括:

  createApp(App)
       |
       v
  createVNode(App)          // 创建根组件 VNode
       |
       v
  render(vnode, container)  // 渲染入口
       |
       v
  patch(null, vnode, container)  // 初始化 = 全量 patch
       |
       +-- 组件类型 --> mountComponent
       |                    |
       |              setup() + render()
       |                    |
       |              生成 subTree (子树 VNode)
       |                    |
       |              patch(null, subTree, container)
       |                    |
       +-- 元素类型 --> mountElement
                            |
                      创建 DOM + 设置属性
                            |
                      递归挂载子节点
                            |
                      插入到容器中

六、Diff 算法——高效更新的核心

6.1 Patch 的入口

当响应式数据变化触发组件重渲染时,会生成新的 VNode 树(nextTree),需要与旧的 VNode 树(prevTree)进行比对,找出差异并更新 DOM。这个过程就是 patch

function patch(n1, n2, container) {
  // n1: 旧 VNode,n2: 新 VNode

  // 类型完全不同,直接卸载旧的,挂载新的
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  if (!n1) {
    // 旧节点不存在,直接挂载
    mount(n2, container)
  } else {
    // 新旧节点类型相同,执行更新
    if (n2.shapeFlag & ShapeFlags.ELEMENT) {
      patchElement(n1, n2)
    } else if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      patchComponent(n1, n2)
    }
  }
}

对于元素节点的更新,需要做两件事:更新属性、更新子节点。子节点的更新是 diff 算法的核心所在。

6.2 子节点更新的情况分析

新旧子节点各有三种可能:文本、数组、空。因此组合起来有 9 种情况:

              新子节点
           文本   数组   空
旧  文本  | 替换 | 清文本 | 清文本 |
子        |      | 挂新组 |        |
节  数组  | 卸旧 | DIFF | 卸旧   |
点        | 设文本|       |        |
    空    | 设文本| 挂新组 | 无操作 |

最复杂也最值得研究的是新旧子节点都是数组的情况,这就是 diff 算法要解决的核心场景。

6.3 Vue 3 的快速 Diff 算法

Vue 3 的 diff 算法分为五个步骤,我们逐一拆解。

步骤一:从头比对相同前缀

旧: [a] [b] [c] [d] [e] [f] [g] [h]
新: [a] [b] [e] [c] [d] [i] [g] [h]
     ^   ^
     i -->  当 c !== e 时停止, i = 2

从头部开始,逐个比较新旧节点的 typekey。相同则 patch 更新,不同则停止。

步骤二:从尾比对相同后缀

旧: [a] [b] [c] [d] [e] [f] [g] [h]
                                 ^   ^
新: [a] [b] [e] [c] [d] [i] [g] [h]
                                 ^   ^
     e1 <--  当 f !== i 时停止, e1 = 5
     e2 <--  e2 = 5

从尾部开始,逐个比较。相同则 patch 更新,不同则停止。

步骤三:简单新增

如果 i > e1 && i <= e2,说明旧节点已经比完了但新节点还有剩余,这些是新增节点:

旧: [a] [b] [c]
新: [a] [b] [d] [e] [c]
              ~~~  新增 d, e

步骤四:简单删除

如果 i > e2 && i <= e1,说明新节点已经比完了但旧节点还有剩余,这些节点需要卸载:

旧: [a] [b] [d] [e] [c]
新: [a] [b] [c]
              ~~~  删除 d, e

步骤五:处理未知序列(核心)

当步骤三、四都不满足时,中间会剩下一段”乱序”区间,这是最复杂的部分。以下面的例子为例:

经过步骤 1、2 后:
  旧中间段: [c] [d] [e] [f]     (i=2, e1=5)
  新中间段: [e] [c] [d] [i]     (i=2, e2=5)

处理过程分三步:

(a) 构建新节点的 key-index 映射

// keyToNewIndexMap = { e: 2, c: 3, d: 4, i: 5 }
const keyToNewIndexMap = new Map()
for (let j = s2; j <= e2; j++) {
  keyToNewIndexMap.set(newChildren[j].key, j)
}

(b) 遍历旧节点,匹配并记录位置

// newIndexToOldIndexMap 记录新节点在旧序列中的位置 (0 表示新增)
// 对于 [e, c, d, i],在旧序列中的位置是:
// e -> 旧序列 index 4 (存 5, 因为+1 避免 0 歧义)
// c -> 旧序列 index 2 (存 3)
// d -> 旧序列 index 3 (存 4)
// i -> 不存在 (存 0, 表示新节点)
// newIndexToOldIndexMap = [5, 3, 4, 0]

在遍历旧节点的过程中,还会标记是否存在需要移动的节点:如果旧节点在新序列中的索引出现了递减,说明有节点需要移动。同时,旧节点中不存在于新序列中的节点(如 f)会被立即卸载。

(c) 利用最长递增子序列(LIS)最小化移动

这是 Vue 3 diff 算法最精妙的一步。对 newIndexToOldIndexMap = [5, 3, 4, 0],求其最长递增子序列:

数组: [5, 3, 4, 0]
LIS:     [3, 4]     --> 索引为 [1, 2]

[1, 2] 对应的是新序列中的 cd——它们在旧序列中的相对顺序已经是正确的,不需要移动。只需要:

  • e 移动到 c 前面
  • d 后面插入新节点 i
操作前: [c] [d] [e]  (f 已删除)

  1. 新增 i 到 d 后面:  [c] [d] [i] [e]
  2. 移动 e 到 c 前面:  [e] [c] [d] [i]

这样,4 个节点的更新只需要 1 次移动 + 1 次新增,而不是暴力重排所有节点。

6.4 最长递增子序列算法

Vue 3 使用贪心 + 二分查找来求 LIS,时间复杂度为 O(n log n)。核心思路是:

  1. 维护一个结果数组 result,存放递增子序列的索引
  2. 遍历输入数组,如果当前值大于 result 末尾的值,追加
  3. 否则,用二分查找找到 result 中第一个大于当前值的位置,替换它

但直接替换会导致结果不正确(位置信息被破坏),所以需要一个辅助数组 p 来记录每个位置的前驱索引,最后通过回溯 p 数组还原正确的 LIS。

function getSequence(arr) {
  const p = arr.slice()        // 前驱索引数组
  const result = [0]           // 结果数组,存索引
  const len = arr.length

  for (let i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI === 0) continue   // 0 表示新节点,跳过

    const last = result[result.length - 1]
    // 当前值大于 result 末尾,直接追加
    if (arr[last] < arrI) {
      p[i] = last              // 记录前驱
      result.push(i)
      continue
    }

    // 二分查找:找到第一个 >= arrI 的位置
    let lo = 0, hi = result.length - 1
    while (lo < hi) {
      const mid = (lo + hi) >> 1
      if (arr[result[mid]] < arrI) {
        lo = mid + 1
      } else {
        hi = mid
      }
    }

    // 替换
    if (arrI < arr[result[lo]]) {
      if (lo > 0) {
        p[i] = result[lo - 1]  // 记录前驱
      }
      result[lo] = i
    }
  }

  // 回溯,从 result 末尾开始,通过 p 数组还原正确序列
  let length = result.length
  let idx = result[length - 1]
  while (length-- > 0) {
    result[length] = idx
    idx = p[idx]
  }

  return result
}

[5, 3, 4, 0] 为例,运行过程:

i=0: arrI=5, result=[0]           -> result=[0]
i=1: arrI=3, 3<5, 替换            -> result=[1]
i=2: arrI=4, 4>3, 追加            -> result=[1,2]
i=3: arrI=0, 跳过(新节点)

回溯: result = [1, 2]

最终得到索引 [1, 2],对应 newIndexToOldIndexMap 中位置 1 和 2 的元素(即 c 和 d),它们不需要移动。

6.5 key 的作用

现在你应该能深刻理解 key 的意义了。在 diff 算法中,Vue 通过 type + key 来判断两个节点是否是”同一个节点”:

function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key
}

如果不提供 key(或使用 index 作为 key),所有同类型节点都会被认为是”同一个”,diff 算法只能逐个 patch,无法正确识别哪些节点被移动、新增或删除。

不用 key 的列表:
旧: [li] [li] [li]
新: [li] [li] [li] [li]
     type 都相同,只能逐个 patch,新增一个

用 key 的列表:
旧: [li:a] [li:b] [li:c]
新: [li:c] [li:a] [li:b] [li:d]
     key 不同,能精确识别 c 需要移动,d 需要新增

因此:在列表可能重新排序、增删的场景下,不要用 index 作为 keyindex 作为 key 会导致错误的节点复用,轻则产生性能问题,重则出现状态混乱的 Bug。只有在列表完全静态(不重排、不增删)且列表项无内部状态时,index 才是可接受的。


🤔 思考题

  1. 为什么 reactive 返回的代理对象和原始对象不是同一个引用? 如果在代码中混用原始对象和代理对象会发生什么?(提示:考虑依赖收集是否生效)

  2. Vue 3 的响应式系统能否追踪到 MapSet 的变化? 如果能,它需要怎样特殊处理?(提示:Map.set() 不是属性赋值操作)

  3. 假设有一个深层嵌套的对象 { a: { b: { c: { d: 1 } } } },模板中只使用了 state.a.b,那么 state.a.b.c.d 是否会被 Proxy 代理? 如果此时直接修改 state.a.b.c.d = 2,视图会更新吗?

  4. Vue 3 的快速 diff 算法相比 Vue 2 的双端 diff 做了哪些改进? 提示:Vue 3 仍保留了头尾对比的步骤,但在处理乱序段时引入了最长递增子序列(LIS)来最小化 DOM 移动。在什么场景下这种优化效果最明显?

  5. 如果在 watchEffect 中使用了 async/awaitawait 之后访问的响应式数据还能被正确追踪吗? 为什么?


📝 本章自测

  1. Vue 3 选择 Proxy 替代 Object.defineProperty 的核心原因中,不包括以下哪项?

    • A. Proxy 能拦截属性的新增和删除
    • B. Proxy 的运行时性能比 defineProperty 快 10 倍
    • C. Proxy 可以实现惰性递归代理
    • D. Proxy 能拦截数组索引的直接赋值
  2. 在 Vue 3 的依赖追踪数据结构中,第一层使用 WeakMap 而非普通 Map 的原因是?

    • A. WeakMap 的查找速度更快
    • B. WeakMap 的键是弱引用,不会阻止目标对象被垃圾回收
    • C. WeakMap 支持任意类型的键
    • D. WeakMap 可以被遍历
  3. 当同一个组件内的两个响应式数据同步修改时,组件会更新几次?

    • A. 2 次,每个数据变化触发一次
    • B. 1 次,得益于批量更新和队列去重机制
    • C. 0 次,需要手动调用 nextTick 才会更新
    • D. 不确定,取决于数据变化的顺序
  4. 在 Vue 3 的 diff 算法中,最长递增子序列的作用是?

    • A. 决定哪些节点需要被删除
    • B. 找出不需要移动的节点,从而最小化 DOM 操作
    • C. 决定节点的渲染顺序
    • D. 优化属性的比对过程
  5. 下面关于 key 的说法,错误的是?

    • A. key 帮助 diff 算法识别哪些节点可以复用
    • B. 使用 index 作为 key 在列表顺序不变时不会有问题
    • C. 没有 key 时 Vue 会使用就地更新策略
    • D. key 值可以重复,Vue 会自动处理冲突
查看答案
  1. B。Proxy 并不比 defineProperty 快 10 倍,在某些微基准测试中 defineProperty 的单次访问甚至更快。Vue 3 选择 Proxy 是因为其功能更完善(A、C、D 都是正确原因),而非单纯的性能差距。

  2. B。WeakMap 的键是弱引用,当原始对象不再被其他地方引用时,垃圾回收器可以回收该对象及其对应的依赖信息,避免内存泄漏。

  3. B。Vue 3 的调度器会将同一个组件的更新任务去重后放入微任务队列,在当前同步代码执行完毕后统一批量处理,因此只会更新 1 次。

  4. B。LIS 找出的是在新旧序列中相对顺序一致的最长子序列,这些节点不需要移动,从而将 DOM 移动操作降到最少。

  5. D。key 值不能重复,重复的 key 会导致 diff 算法行为异常,Vue 在开发模式下会给出警告。


本章小结

本章我们从设计动机出发,深入探讨了 Vue 3 的两大核心机制:

响应式系统方面,我们理解了从 Object.definePropertyProxy 的演进原因;通过迷你实现掌握了 reactiverefeffecttracktrigger 的协作流程;了解了 WeakMap -> Map -> Set 三层依赖存储结构的设计考量;以及批量更新、调度器和 nextTick 如何协同避免无效的重复渲染。

虚拟 DOM 方面,我们理解了 VNode 的结构设计和 h() 函数的创建流程;掌握了 patch 的整体入口逻辑和子节点更新的分类处理;深入拆解了 Vue 3 快速 diff 算法的五个步骤,特别是最长递增子序列如何将 DOM 移动操作降到最少。

这些底层原理不仅帮助你理解”Vue 为什么这样工作”,更重要的是当你遇到性能问题或诡异 Bug 时,能够从原理层面定位和解决问题。下一章我们将继续深入编译器和渲染优化机制,看看 Vue 3 如何在编译阶段就为运行时铺平道路。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90