Skip to content

Vue 3 渲染副作用与异步调度:从数据变化到 DOM 更新的完整链路

本章深入解析 Vue 3 的异步调度系统,揭示"多次修改只更新一次"背后的工程奥秘。

前言

在上一章中,我们学习了组件实例的创建过程与属性访问的优化机制。当组件实例创建完成后,它是如何被"驱动"起来的?当你修改响应式数据时,组件并不是立即重新渲染的——Vue 将更新推入了一个异步队列

本章将解答五个关键问题:

  1. setupRenderEffect 做了什么? 为什么组件能自动更新?
  2. Mount 与 Update 路径有什么区别? 首次渲染 vs 后续更新
  3. 为什么多次修改只更新一次? queueJob 的去重与排序机制
  4. 三个队列如何协作? pre → main → post 的执行顺序
  5. nextTick 的实现原理是什么? 微任务的应用

setupRenderEffect:连接响应式与渲染

背景:mountComponent 的第三步

回顾 mountComponent 的三步走战略:

Step 1:createComponentInstance() → 创建实例对象
Step 2:setupComponent() → 初始化 props、slots、setup
Step 3:setupRenderEffect() → 建立渲染副作用 ← 本章重点

核心问题:为什么需要"副作用"?

副作用 = 响应式系统与渲染系统的桥梁

它将 render 函数包装成 ReactiveEffect
当响应式数据变化时,自动触发重新渲染

两条关键路径

setupRenderEffect 内部定义了 componentUpdateFn,这是组件更新的核心函数。它根据 instance.isMounted 区分两条路径:

Mount 路径(首次渲染)

instance.isMounted === false

调用 beforeMount 钩子

renderComponentRoot(instance) → 生成 subTree(VNode)

patch(null, subTree, container) → 创建真实 DOM

将 el 保存到 vnode 上

instance.isMounted = true

异步执行 mounted 钩子

Update 路径(重新渲染)

instance.isMounted === true

如果 instance.next 存在,更新 props(父组件驱动)

调用 beforeUpdate 钩子

renderComponentRoot(instance) → 生成新 subTree

patch(prevTree, nextTree, ...) → Diff 并更新 DOM

异步执行 updated 钩子

两条路径的对比

维度MountUpdate
patch 第一参数nullprevTree
DOM 操作创建新节点Diff 后最小化更新
钩子beforeMount / mountedbeforeUpdate / updated
触发时机组件首次渲染数据变化 / 父组件更新

scheduler:响应式与调度器的连接点

这是整个机制的关键!

ts
// 伪代码
const effect = new ReactiveEffect(componentUpdateFn)

// 关键设置:不直接执行,而是推入队列
effect.scheduler = () => queueJob(job)

有无 scheduler 的区别

【无 scheduler】
数据变化 → 立即执行 effect.run() → 同步渲染
问题:同一 tick 修改 3 次 = 渲染 3 次!

【有 scheduler】
数据变化 → 调用 scheduler → 推入队列 → 微任务批量处理
效果:同一 tick 修改 3 次 = 只渲染 1 次!

通俗类比

无 scheduler = 收到一封信就跑一趟邮局
有 scheduler = 攒够一批信再跑一趟邮局(批量处理)

异步调度系统:批量更新的核心

调度器的数据结构

Vue 调度器维护三个队列:

【pre 队列】
  用途:watch 的 flush: 'pre'
  时机:组件更新之前

【main 队列】
  用途:组件更新任务
  时机:DOM 更新时

【post 队列】
  用途:mounted、updated 钩子
  时机:DOM 更新之后

queueJob:入队与去重

当响应式数据变化触发 scheduler 时,job 会进入队列:

ts
// 伪代码
function queueJob(job) {
  // 去重:检查 QUEUED 标记
  if (job 已在队列中) {
    return  // 直接返回,不重复入队
  }

  // 排序:按 job.id 递增排列
  // 快速路径:如果 id 大于队尾,直接 push
  // 否则:二分查找插入位置
  queue.insert(job)

  // 标记已入队
  job.flags |= QUEUED

  // 触发微任务
  queueFlush()
}

三个关键优化

【去重】
  机制:检查 QUEUED 标记
  效果:同一个 job 只入队一次

【快速路径】
  机制:比较 job.id 与队尾
  效果:避免二分查找开销

【排序】
  机制:按 job.id 升序
  效果:保证父组件先于子组件更新

为什么按 id 排序?

这是一个精妙的设计:

场景:父组件状态变化,导致子组件也需要更新

队列入队顺序:
  1. job_parent (id=1)
  2. job_child (id=2)

如果不排序(FIFO):
  job_parent 执行 → patch 过程中可能卸载子组件
  job_child 执行 → 试图更新已卸载的子组件 ❌ BUG!

如果按 id 排序(父 < 子):
  job_parent 执行 → 决定是否卸载子组件
  job_child 执行 → 如果已被卸载,可以安全跳过 ✅

为什么父组件 id < 子组件 id?

因为父组件先创建(uid 是递增的),所以 id 更小。创建顺序隐式保证了更新顺序。

flushJobs:执行队列

微任务触发时,执行队列中的所有 job:

ts
// 伪代码
function flushJobs() {
  try {
    // 按顺序执行每个 job
    for (let i = 0; i < queue.length; i++) {
      const job = queue[i]

      // 检查递归更新(防止无限循环)
      if (执行次数 > 100) {
        throw Error('递归更新超过上限')
      }

      // 执行 job
      job()
    }
  } finally {
    // 清理队列
    queue.length = 0

    // 执行 post 队列(mounted、updated 钩子)
    flushPostFlushCbs()

    // 如果有新 job 入队,继续处理
    if (queue.length > 0) {
      flushJobs()
    }
  }
}

执行流程的关键点

1. 遍历执行:按 id 排序后的顺序
2. 错误处理:try-finally 确保清理状态
3. 递归保护:超过 100 次抛出错误
4. Post 队列:main 队列执行完后,执行 post 队列

递归更新保护

防止无限循环更新的安全机制:

ts
// 有问题的代码示例
setup() {
  const count = ref(0)

  onUpdated(() => {
    count.value++  // 在 updated 中修改依赖 → 无限循环!
  })
}

会发生什么?

count 变化 → 触发更新 → updated 执行 → count 又变化
            ↑                                    ↓
            └────────────────────────────────────┘
            无限循环!

保护机制:统计每个 job 执行次数,超过 100 次就判定为无限循环,抛出错误帮助开发者定位问题。


三个队列的执行顺序

完整的调度流程

T0: 同步执行用户代码

    ├─ state.count = 1
    │   └─ trigger() → queueJob(job)

    ├─ state.count = 2
    │   └─ trigger() → queueJob(job)(去重,不入队)

    └─ 用户代码结束
        └─ 推入微任务

T1: 微任务执行

    ├─ flushPreFlushCbs()   ← pre 队列
    │   └─ watch flush:'pre' 回调

    ├─ flushJobs()          ← main 队列
    │   └─ componentUpdateFn
    │       ├─ beforeUpdate 钩子
    │       ├─ renderComponentRoot()
    │       └─ patch(prevTree, nextTree)

    └─ flushPostFlushCbs()  ← post 队列
        ├─ updated 钩子
        └─ watch flush:'post' 回调

T2: nextTick 回调执行(DOM 已更新)

T3: 浏览器重排/重绘(用户看到最新 UI)

三个队列对比表

队列典型用例执行时机说明
prewatch flush: 'pre'组件更新需要在 DOM 更新前处理数据
maincomponentUpdateFn组件更新中执行 patch,更新 DOM
postmounted、updated组件更新DOM 已更新,可安全访问

场景演示

ts
// 一个完整的更新周期
setup() {
  const count = ref(0)

  // pre 队列
  watch(count, (val) => {
    console.log('pre watch:', val)
  }, { flush: 'pre' })

  // post 队列(默认)
  watch(count, (val) => {
    console.log('post watch:', val)
  })

  onBeforeUpdate(() => console.log('beforeUpdate'))
  onUpdated(() => console.log('updated'))
}

修改 count.value = 1 后的执行顺序

1. pre watch: 1         ← pre 队列
2. beforeUpdate         ← componentUpdateFn 内部
3. patch DOM            ← componentUpdateFn 内部
4. updated              ← post 队列
5. post watch: 1        ← post 队列

nextTick:微任务的应用

实现原理

ts
// 伪代码
const resolvedPromise = Promise.resolve()
let currentFlushPromise = null

function nextTick(fn) {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(fn) : p
}

function queueFlush() {
  if (!currentFlushPromise) {
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

工作流程

修改数据 → trigger() → queueJob() → queueFlush()

currentFlushPromise = Promise.resolve().then(flushJobs)

用户代码继续执行...

微任务执行 flushJobs → DOM 更新

nextTick 回调执行(此时 DOM 已更新)

为什么用 Promise?

微任务 vs 宏任务的对比:

特性微任务 (Promise)宏任务 (setTimeout)
执行时机当前宏任务结束后立即至少等待 4ms
帧内更新✅ 同一帧内完成❌ 可能跨帧
用户体验✅ 更流畅❌ 可能有闪烁

通俗类比

微任务 = 下课铃响后立刻做作业
宏任务 = 放学回家后再做作业(有延迟)

使用场景

ts
// ❌ 错误:同步访问 DOM
const handleClick = () => {
  count.value = 1
  console.log(domRef.value.textContent) // "0"(还没更新!)
}

// ✅ 正确:等待 DOM 更新
const handleClick = async () => {
  count.value = 1
  await nextTick()
  console.log(domRef.value.textContent) // "1"(已更新!)
}

完整时序图


综合案例:列表批量更新

ts
const items = ref([])

// 逐个添加 100 项
for (let i = 0; i < 100; i++) {
  items.value.push({ id: i })
  // 每次 push 触发一次 trigger → queueJob
  // 但由于去重机制,最终只更新一次!
}

性能分析

做法trigger 次数实际更新次数
逐个 push100 次1 次(去重生效)
批量赋值1 次1 次

结论:得益于调度器的去重机制,两种做法的最终更新次数相同。但批量赋值减少了 trigger 调用开销,性能更优。


思考

为什么不直接同步执行 render?

【同步执行的问题】
  - 同一 tick 修改 3 次 = 渲染 3 次(浪费)
  - 用户可能看到"中间状态"的 UI
  - 难以控制父子组件的更新顺序

【异步批量处理的收益】
  ✅ 多次修改合并为一次更新
  ✅ 用户只看到最终状态
  ✅ 可以按 id 排序控制更新顺序

调度器设计的局限性

优点

  • 去重机制简单高效
  • 排序保证更新顺序
  • 错误处理和递归保护完善

局限

  • 没有优先级概念(不像 React Fiber)
  • 大量更新在同一微任务,可能阻塞主线程
  • 递归上限 100 是硬编码,不够灵活

总结:从数据变化到 DOM 更新的完整链路

数据变化

trigger() 收集 effects

effect.scheduler() 而非 effect.run()

queueJob(job) 入队(去重 + 排序)

queueFlush() 推入微任务

[等待当前宏任务结束]

flushPreFlushCbs() → pre 队列

flushJobs() → main 队列 → componentUpdateFn → patch

flushPostFlushCbs() → post 队列 → mounted/updated

nextTick 回调执行

浏览器重绘 → 用户看到最新 UI

核心机制

  1. scheduler:响应式与调度器的连接点,推迟执行
  2. 去重:同一 job 只入队一次
  3. 排序:按 id 保证父 → 子的更新顺序
  4. 微任务:同一帧内完成更新,避免闪烁
  5. 三个队列:pre → main → post 的清晰分工

"理解了调度器,你就理解了 Vue 3 为什么能在复杂场景下依然保持高性能。这不是魔法,而是精心设计的工程。"


扫描关注微信 - 前端小卒,获取更多 Vue 3 源码解析内容