Appearance
Vue 3 渲染副作用与异步调度:从数据变化到 DOM 更新的完整链路
本章深入解析 Vue 3 的异步调度系统,揭示"多次修改只更新一次"背后的工程奥秘。
前言
在上一章中,我们学习了组件实例的创建过程与属性访问的优化机制。当组件实例创建完成后,它是如何被"驱动"起来的?当你修改响应式数据时,组件并不是立即重新渲染的——Vue 将更新推入了一个异步队列。
本章将解答五个关键问题:
- setupRenderEffect 做了什么? 为什么组件能自动更新?
- Mount 与 Update 路径有什么区别? 首次渲染 vs 后续更新
- 为什么多次修改只更新一次? queueJob 的去重与排序机制
- 三个队列如何协作? pre → main → post 的执行顺序
- 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 钩子两条路径的对比:
| 维度 | Mount | Update |
|---|---|---|
| patch 第一参数 | null | prevTree |
| DOM 操作 | 创建新节点 | Diff 后最小化更新 |
| 钩子 | beforeMount / mounted | beforeUpdate / 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)三个队列对比表
| 队列 | 典型用例 | 执行时机 | 说明 |
|---|---|---|---|
| pre | watch flush: 'pre' | 组件更新前 | 需要在 DOM 更新前处理数据 |
| main | componentUpdateFn | 组件更新中 | 执行 patch,更新 DOM |
| post | mounted、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 次数 | 实际更新次数 |
|---|---|---|
| 逐个 push | 100 次 | 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核心机制:
- scheduler:响应式与调度器的连接点,推迟执行
- 去重:同一 job 只入队一次
- 排序:按 id 保证父 → 子的更新顺序
- 微任务:同一帧内完成更新,避免闪烁
- 三个队列:pre → main → post 的清晰分工
"理解了调度器,你就理解了 Vue 3 为什么能在复杂场景下依然保持高性能。这不是魔法,而是精心设计的工程。"
