Vue 3 核心源码深度剖析系列(v3.5.44)-第 14 章 运行时调度器:队列、批处理、nextTick 与应用上下文
本章对应
packages/runtime-core/src/scheduler.ts(318 行,核心)、apiCreateApp.ts(494 行)、apiInject.ts(93 行)、errorHandling.ts(185 行)、warning.ts(182 行)、featureFlags.ts(38 行)、apiSetupHelpers.ts(573 行),并交叉引用renderer.ts中的setupRenderEffect(1343–1651)、updateComponent(1307–1341)、updateComponentPreRender(1653–1670)、toggleRecurse(2530–2541)、unmount的DISPOSED标记(2399),apiWatch.ts的 scheduler 配置(198–232),rendererTemplateRef.ts的job.id = -1(196),reactivity/src/effect.ts的batch/startBatch/endBatch(247–310),以及components/Suspense.ts对queuePostFlushCb的使用(29/585/647/950)。所有行号对应vuejs/core@3.5.44。读完本章你将能回答:为什么 Vue 必须把"改状态"与"更新 DOM"拆成两个时刻?
queue、pendingPostFlushCbs、flushIndex、currentFlushPromise、isFlushing这五个模块级变量各自维护哪一条不变量?queueJob的"快速路径 push + 二分插入"如何保证父组件永远先于子组件执行且不破坏插入正确性?flushJobs的try/finally里那句"再次flushJobs"在什么情况下会触发、会不会爆栈?nextTick为什么返回 Promise 而且返回的是"当前这一轮 flush 的 Promise"?instance.update与instance.job为什么是两个不同的函数?pre/post 两条队列分别在什么时机插入、什么时机排出?响应式的startBatch/endBatch与调度器的 flush 边界是什么关系?createApp造出的appContext如何在多次mount之间保持隔离?provide/inject的原型链查找为什么是 O(深度) 而不是 O(祖先数)?错误从callWithErrorHandling到errorCaptured到app.config.errorHandler是怎么一层层冒泡的?
导语:调度器是"把突变折算成一次渲染"的那台机器
第 13 章讲的是"组件实例有哪些状态、这些状态被谁写",本章要回答的是另一个问题:当这些状态被写脏之后,谁来决定"什么时候"把它们折算成一次 DOM 更新?
一个直觉的答案可能是"立即更新"。但 Vue 的实现选择了相反的路径:写脏状态时只做一件极轻的事——把一个"任务"塞进队列,然后把真正的执行推迟到一个微任务里。这台机器的全部代码只有 318 行,却承载了四条互相约束的设计原则:
- 合并(batching)。同一个 tick 内对同一个状态的 N 次修改,只产生 1 次组件重渲染。这是"声明式 UI 框架"能跑赢手写 DOM 操作的唯一根本原因。
- 有序(ordering)。队列不是 FIFO,而是按组件实例
uid升序排列。父组件的uid必然小于子组件(先创建先分配),因此"父先于子"不是靠调用栈保证,而是靠排序这一数据结构不变量保证。 - 可重入且安全(reentrancy)。任务的执行过程本身可能再次写脏状态、再次入队。调度器必须在同一次 flush 中把新任务也算进去,同时用
RECURSION_LIMIT = 100兜底,防止"渲染自己改自己依赖"的自激发死循环把页面冻死。 - 可观测(observability)。
nextTick把"flush 完成"这个时刻建模为一个 Promise,让用户能在正确的时机读取更新后的 DOM。这不是附加功能——它和队列共用同一个currentFlushPromise变量,是调度器数据结构的直接导出品。
本章的结构是"数据结构 → 算法 → 时序 → 应用外壳 → 实战"。14.1 先讲清"不调度会怎样",14.2 逐个拆解调度器仅有的 7 个模块级状态,14.3 与 14.4 深入 queueJob 与排序,14.5 是全书最长的一节——flushJobs 的逐步状态表,14.6 讲 nextTick,14.7 把渲染器里的 instance.job 接回调度器,14.8 拆 pre/post 两条队列,14.9 打通响应式批处理与调度器批处理的边界,14.10–14.12 分别是 createApp、provide/inject、错误处理这三块"看起来不属于调度器、却与调度器的执行上下文强耦合"的内容,14.13 用三个可复现实验打印真实执行顺序,14.14 收口。
一句贯穿全章的判断先放在这里:调度器的复杂度不在"排序算法"或"微任务技巧",而在"一个任务在执行过程中修改了队列本身"这件事上。 你后面读到的所有 flags 位运算、flushIndex 重扫、seen 递归计数、activePostFlushCbs 二次插入,全都是为了给这个自修改问题提供确定性答案。
14.1 为什么需要调度:同步更新的三重灾难
在讨论实现之前,先把"如果不要调度器会怎样"推演清楚。假设我们把 ReactiveEffect 的 scheduler 直接改成"立即执行 componentUpdateFn",也就是每次写脏一个被渲染函数依赖的响应式数据,就立刻同步重渲染。三个问题会立刻浮现。
14.1.1 灾难一:同一 tick 内的重复渲染
考虑一个最朴素的计数器组件:
// 模板等价:<div>{{ a }} {{ b }}</div>
setup() {
const a = ref(0)
const b = ref(0)
function bump() {
a.value++ // 触发一次 effect
b.value++ // 触发一次 effect
}
return { a, b }
}
render 同时依赖 a 与 b。同步执行模型下,a.value++ 会立刻把 render effect 触发一次 → patch DOM(把 a 的新值写上去);紧接着 b.value++ 再触发一次 → 又一次 patch DOM。一次用户点击造成了两次完整渲染,而最终结果其实只需要一次。
真实的组件远比这复杂:一个 props 对象里可能有十几个字段在同一个事件处理函数里被更新,一个 watch 回调里可能连续写三个 ref。同步模型下,渲染次数与"状态写次数"成正比,而调度模型下,渲染次数与"tick 数"成正比。这是从 O(写次数) 降到 O(tick 数) 的收益。
14.1.2 灾难二:父子执行顺序失控
渲染 effect 是各自独立注册的。同步模型下,"哪个 effect 先被触发"完全取决于依赖被写入的先后,而不是组件树的层级。考虑:
// Child.vue
props: ['modelValue'],
setup(props) {
watch(() => props.modelValue, () => { /* 记录一次 */ })
}
// Parent.vue
setup() {
const v = ref(0)
return { v }
}
// 模板:<Child :modelValue="v" />
某个事件里 v.value++。此时依赖图里:
- Parent 的 render effect 依赖
v; - Child 的 render effect 依赖
props.modelValue,而props.modelValue是从v派生的(父渲染时写入)。
v.value++ 先触发 Parent 的 render effect(因为它是 v 的直接订阅者),Parent 重渲染时把新的 props.modelValue 写进 Child 的 props——但 props 是 shallowReactive 的,写它又会触发 Child 的 render effect。于是顺序是"父渲染 → 触发子渲染"。这个顺序碰巧是对的。但如果 v 同时被一个 watch(pre-flush)依赖呢?如果 Parent 的 render effect 因为某些原因在 Child 之后注册呢?同步模型下没有"权威顺序",只有"依赖图的遍历顺序",而这个顺序对用户不可见、不可控、不可调试。
调度器用排序取代了这种隐式的遍历顺序:把同一个 flush 里所有待执行任务按 job.id(组件 uid)升序排列,于是"父先于子"成为一条可证明的不变量(14.4 给出证明)。
14.1.3 灾难三:无限递归
最经典的自激发场景来自 Array.prototype.push(issue #1740):
watch(list, () => {
list.value.push(...) // 在回调里改自己依赖的数组
})
push 在写入数组的同时也会读取 length,因此回调内的 push 会再次触发这个 watch,再 push,再触发……同步模型下这就是一个立即爆栈或者死循环。再如组件渲染函数里调用了一个会写自己依赖的方法:
// render 里读了 count,某个 helper 又写了 count
<div>{{ helper(count) }}</div>
调度器给出的答案是两条互补的机制:
- ALLOW_RECURSE 白名单(
scheduler.ts:23):默认一个任务不能在 flush 过程中触发自己——触发时QUEUED标志还在,queueJob直接跳过(scheduler.ts:101)。只有显式标记了ALLOW_RECURSE的任务(组件更新函数、watch回调)才允许自触发,因为它们的自触发"多半是用户有意为之"(源码注释 #1727、#1801)。 - RECURSION_LIMIT = 100(
scheduler.ts:54):在 dev 下统计每个任务被执行的次数,超过 100 次就抛出"Maximum recursive updates exceeded"并指出组件名(scheduler.ts:329-348)。这把"无限循环"降级为"明确的开发期错误"。
14.1.4 微任务批处理:为什么是 Promise 而不是 setTimeout
批处理的载体可以是宏任务(setTimeout(fn, 0) / MessageChannel)或微任务(Promise.resolve().then(fn))。Vue 3 选择微任务,理由有三条:
| 维度 | 微任务(Promise) | 宏任务(setTimeout) | MessageChannel |
|---|---|---|---|
| 触发时机 | 当前同步代码执行完后、下一个宏任务前 | 下一个宏任务周期 | 下一个宏任务周期 |
| 对用户可见性 | 同一"事件回调 → DOM 更新"之间几乎无感知延迟 | 用户可能看到"先改状态、后延迟 4ms 才更新"的中间态 | 同左 |
| 是否受浏览器 clamp 影响 | 否 | 是(嵌套 setTimeout 有 4ms 最小延迟) | 否 |
| 与其他微任务(如 Promise 回调)的相对顺序 | 参与同一微任务队列的 FIFO | 排在不同的批次 | 排在宏任务批次 |
Vue 2 曾用 Promise + MutationObserver + setImmediate + setTimeout 四级降级。Vue 3 面向"现代浏览器必须支持 Promise"这个前提,直接用裸 Promise.resolve(),省掉了整套降级逻辑——这正是 scheduler.ts:51 只有一行 const resolvedPromise = Promise.resolve() 的原因。
但要注意一个微妙点:微任务批处理会让"同一个同步代码块内连续写状态"合并,而不会合并发生在"两个不同事件回调"里的写。也就是说:
// 同一个 tick:合并为 1 次渲染
a.value++; b.value++
// 两个 tick:各渲染 1 次(共 2 次)
btn.onclick = () => { a.value++ }
btn2.onclick = () => { b.value++ }
这条边界是理解"批处理能在什么场景下省事"的关键——它不是"永远只渲染一次",而是"每个宏任务周期最多一次"。
14.1.5 调度器不负责什么
不做 diff(flushJobs 只按序调用任务,任务内部才 patch/diff,第 11、12 章);不做优先级(与 React 的 lane 模型不同,Vue 所有任务同权、只按 id 排序,14.9 详述);不做时间切片(flushJobs 是同步 for 循环,一旦开始就跑到底,中间不 yield);不做异步任务编排(Suspense 的等待是其自身状态机,调度器只负责把回调排进 post 队列)。明白边界,就懂为什么 318 行足够——它只解决"排期"这一件事。
14.2 队列数据结构:七个模块级状态逐个解释
scheduler.ts 的模块作用域里只有七个可变状态(外加一个常量与一个类型别名)。它们是整个调度器的全部内存模型。先看源码原文(scheduler.ts:44-55):
const queue: SchedulerJob[] = []
let flushIndex = -1
const pendingPostFlushCbs: SchedulerJob[] = []
let activePostFlushCbs: SchedulerJob[] | null = null
let postFlushIndex = 0
const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
const RECURSION_LIMIT = 100
type CountMap = Map<SchedulerJob, number>
以及后面出现的:
let isFlushing = false // scheduler.ts:244
与大纲的差异说明:任务大纲在 14.2 里列出的
isFlushPending在 3.5.44 的源码中并不存在。源码里承担"是否已有一次 flush 被排期但尚未开始"这一语义的,是currentFlushPromise——它非null即表示"已有一个 flush 微任务在途"。queueFlush(scheduler.ts:120-124)正是靠if (!currentFlushPromise)来保证"一次 tick 只排一个 flush 微任务"。下文按源码实际命名解释,并在涉及该语义处标注currentFlushPromise的这重身份。
14.2.1 状态 #1 与 #2:queue 与 flushIndex——主队列与它的游标
queue 是主队列,存放所有"同步任务",元素的形态是 SchedulerJob(一个函数,可能带 id/flags/i 三个附加属性,见 14.2.6)。
关于 queue 的核心不变量有两条:
- 有序:从
flushIndex + 1起到queue.length - 1,元素按getId(job)非递减排列(14.4 证明)。 - 已执行的前缀被保留直到本轮结束。这是最反直觉的一点:任务执行后不会从
queue里删除。flushJobs用flushIndex作为游标从左往右扫,已执行的任务留在数组里(scheduler.ts:288-306)。为什么不删?因为queue.splice是 O(n) 的,逐元素删除会让 flush 变成 O(n²);而保留前缀、用游标推进,是 O(1) 的"逻辑删除"。
flushIndex 初始为 -1,含义是"当前没有在进行中的 flush"——第一个待执行任务的下标是 flushIndex + 1 = 0。这个 -1 出现在很多地方:
findInsertionIndex从flushIndex + 1开始二分(scheduler.ts:80),确保插入位置不会落在已执行前缀里;flushPreFlushCbs默认参数i = flushIndex + 1(scheduler.ts:154);flushJobs结束后重置为-1(scheduler.ts:316)。
用 ASCII 图表示"正在 flush 到第 3 个任务"时的内存视图:
queue: [ J0 ][ J1 ][ J2 ][ J3 ][ J4 ][ J5 ]
index: 0 1 2 3 4 5
↑
flushIndex = 2
└── 0..2 是"已执行前缀"(保留在数组里,但不会再被执行)
3..5 是"待执行后缀"(仍需按序执行)
14.2.2 状态 #3、#4、#5:pendingPostFlushCbs / activePostFlushCbs / postFlushIndex
后置回调(post-flush callbacks,简称 post cb)是"必须在 DOM 更新完成后才执行"的回调。典型来源:
- 组件的
updated钩子(渲染器通过queuePostRenderEffect排出,renderer.ts:297-306); mounted/unmounted钩子(同样走queuePostRenderEffect);flush: 'post'的 watch 回调(apiWatch.ts:200-206);- Suspense 在依赖解析完成后排出的 effects 数组(
Suspense.ts:585/647); - 模板 ref 的赋值任务(
rendererTemplateRef.ts:192-198,job.id = -1)。
为什么这些不能用主队列?因为它们需要"整棵组件树都已 patch 完成"这个前提(比如 updated 钩子里读 DOM 想拿到最终布局,或 ref 想拿到稳定的子组件实例)。
三个状态的职责划分:
| 状态 | 类型 | 生命周期 | 承担的不变量 |
|---|---|---|---|
pendingPostFlushCbs | SchedulerJob[] | 跨 flush 存在 | 存放"已入队但尚未开始执行"的 post cb。数组顺序无意义(排出前会整体排序) |
activePostFlushCbs | SchedulerJob[] | null | 仅在一次 flushPostFlushCbs 内 | null 表示"当前没有正在执行的 post 批次";非 null 表示"正在执行",且此时新来的 cb 要追加到它尾部(scheduler.ts:212-217) |
postFlushIndex | number | 仅在一次 flushPostFlushCbs 内 | 与 flushIndex 对称的游标,指向当前正在执行的 post cb(scheduler.ts:224-238) |
activePostFlushCbs 之所以必须存在,是因为"post cb 执行过程中又产生了新的 post cb"这件事(典型:updated 钩子里改了状态 → 触发新的组件更新 → 新组件挂载 → 排出新的 mounted)。此时如果用"取空 pendingPostFlushCbs → 执行 → 再取空"的朴素写法,新 cb 会排到下一轮 microtask,导致 nextTick 拿不到最终 DOM。源码的解法(scheduler.ts:211-217)是:如果已有 activePostFlushCbs,就把新排出的批次直接 push 到正在执行的数组尾部——游标 postFlushIndex 会自然扫到它们:
// #1947 already has active queue, nested flushPostFlushCbs call
if (activePostFlushCbs) {
for (let i = 0; i < deduped.length; i++) {
activePostFlushCbs.push(deduped[i])
}
return
}
14.2.3 状态 #6:resolvedPromise 与 currentFlushPromise
const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
resolvedPromise 是一个全局共享的已 resolve Promise。它只做一件事:作为"微任务发射器"。resolvedPromise.then(fn) 会把 fn 排入微任务队列,且复用同一个 Promise 实例,避免每次 queueFlush 都新建 Promise(省一次分配)。
currentFlushPromise 承担双重身份:
- 净化开关(去重闸门)。
queueFlush里if (!currentFlushPromise)保证一次 tick 内只排一个 flush 微任务(scheduler.ts:120-124)。一旦currentFlushPromise非 null,后续所有queueJob都只是往queue里塞,不再重复排微任务。 nextTick的锚点。nextTick()返回的正是currentFlushPromise || resolvedPromise(scheduler.ts:66-67)。这解释了为什么"在 flush 前调用nextTick得到的 Promise 会在本次 flush 完成后 resolve"——因为 flush 本身就是这个 Promise 的then回调,flushJobs执行完毕即意味着该 Promise 的 handler 返回。
flushJobs 结束时会把它重置为 null(scheduler.ts:321),从而重新打开闸门。
14.2.4 状态 #7:isFlushing
isFlushing 声明在 flushPostFlushCbs 之后(scheduler.ts:244),因为它只被 flushOnAppMount(scheduler.ts:254-268)这一处使用:
export function flushOnAppMount(fn: () => void, minUid?: number): void {
if (isFlushing) {
fn()
return
}
const queued = minUid === undefined ? undefined : new Set(pendingPostFlushCbs)
fn()
isFlushing = true
try {
flushPreFlushCbs(undefined, minUid)
flushPostFlushCbs(undefined, queued, minUid)
} finally {
isFlushing = false
}
}
它的语义是"当前是否正处在一次 app 挂载/更新引发的 flush 中"。如果 fn()(一次根渲染)本身是被另一个 flush 触发的(即 isFlushing === true),就直接执行 fn() 而不再嵌套做"局部 flush",因为外层 flush 的 flushJobs 循环会自然把这次渲染排出的任务吃掉。注意它与 flushIndex !== -1 的区别:flushIndex 只在 flushJobs 内非 -1,而 isFlushing 覆盖"根渲染 + 局部 pre/post flush"这一更长的区间。
14.2.5 常量:RECURSION_LIMIT 与 CountMap
const RECURSION_LIMIT = 100 // scheduler.ts:54
type CountMap = Map<SchedulerJob, number> // scheduler.ts:55
CountMap 是 dev-only 的"任务执行次数表":seen: Map<job, count>。它在每次 flushJobs/flushPreFlushCbs/flushPostFlushCbs 调用时新建(scheduler.ts:274-276、156-158、220-222),因为 "递归" 的判定范围是单次 flush 调用。为什么是 100?源码没有解释,但 100 足以容纳合理的"父→子→孙"级联更新链(正常组件树深度远小于 100),又足以在小测试用例里捕获 push 自激发(通常几毫秒内就能跑过 100 次)。
14.2.6 SchedulerJobFlags 位掩码:四种标志的语义
export enum SchedulerJobFlags {
QUEUED = 1 << 0, // 1
PRE = 1 << 1, // 2
ALLOW_RECURSE = 1 << 2, // 4
DISPOSED = 1 << 3, // 8
}
| 标志 | 值 | 语义 | 谁设置 | 谁消费 |
|---|---|---|---|---|
QUEUED | 1 | 该 job 已在某条队列中,防止重复入队 | queueJob(scheduler.ts:114)、queuePostFlushCb(scheduler.ts:132) | queueJob/queuePostFlushCb 的入队判定;flushJobs 执行前后清位 |
PRE | 2 | 该 job 属于 pre 队列(要在组件渲染前执行) | apiWatch 的 augmentJob(apiWatch.ts:225-231) | findInsertionIndex(同 id 时的前后关系)、flushPreFlushCbs(筛选)、getId(无 id 的 pre job 取 -1) |
ALLOW_RECURSE | 4 | 允许在 flush 中自触发 | toggleRecurse(renderer.ts:2530-2541)、apiWatch(apiWatch.ts:222-224) | flushJobs/flushPreFlushCbs/flushPostFlushCbs 的 QUEUED 清位时机 |
DISPOSED | 8 | 该 job 所属组件已卸载,不要再执行 | unmount(renderer.ts:2399)、卸载钩子清理(renderer.ts:2665) | flushJobs(scheduler.ts:290)、flushPostFlushCbs(scheduler.ts:236)的跳过判定 |
QUEUED 的清位策略是最容易读错的地方,值得单独强调。以 flushJobs 为例(scheduler.ts:294-304):
if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
job.flags! &= ~SchedulerJobFlags.QUEUED // ① 执行前先清
}
callWithErrorHandling(job, job.i, ...)
if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
job.flags! &= ~SchedulerJobFlags.QUEUED // ② 执行后再清
}
- 对
ALLOW_RECURSE的 job(组件更新、watch 回调),执行前清QUEUED,于是 job 在执行过程中若再次写脏自己的依赖,effect.scheduler会再次queueJob(job),而这次QUEUED已清、能进入队列 → 递归调度成立。 - 对非
ALLOW_RECURSE的 job,执行后才清,于是执行期间的任何自触发都被QUEUED挡住。 - 但组件更新的
ALLOW_RECURSE不是恒真的:toggleRecurse(instance, false)会在beforeUpdate之前的窗口、以及mounted之前的窗口把它临时关掉(renderer.ts:1359、1536),防止"pre 生命周期钩子里改状态"造成自激发。这正是为什么源码要在执行后再补一次清位——执行期间ALLOW_RECURSE可能被改过。
14.3 queueJob 逐行剖析:一次入队的完整代价
queueJob 是调度器对外暴露的唯一"同步入口"(组件更新、pre watch 都通过它)。整段代码只有 19 行(scheduler.ts:100-118),但每一行都在回答一个具体的性能或正确性问题。
14.3.1 入口契约:SchedulerJob 是什么形状
export interface SchedulerJob extends Function {
id?: number
flags?: SchedulerJobFlags
i?: ComponentInternalInstance | null
}
一个 SchedulerJob 首先是函数,其次可以携带三个元数据:
| 属性 | 含义 | 缺省时的行为 |
|---|---|---|
id | 排序主键。组件更新任务 = instance.uid;pre watch = 所属组件 uid;纯函数 job 无 id | 由 getId 折算(见 14.3.3) |
flags | 位掩码。可能为 undefined,源码注释明确"在按位运算里等价于 0"(scheduler.ts:31-33) | 所有按位判断走 flags! & ...,undefined & 1 === 0 |
i | 归属组件实例。用于错误报告时显示组件名、以及 post cb 的 minUid 过滤 | 无归属 |
注意 flags 是 3.5.x 引入的,用于取代早期版本里每次入队都做一次 queue.includes(job, flushIndex + 1) 的线性去重。这是一次典型的"用一点内存换 O(n) 时间"的优化,下文 14.3.2 会详述。
14.3.2 去重:QUEUED 标志取代了历史上的 includes 查找
与大纲的差异说明:任务大纲 14.3 写了"去重(includes 查找)"。在 3.5.44 源码里,去重不再使用
queue.includes(...),而是改用job.flags & SchedulerJobFlags.QUEUED判断(scheduler.ts:101)。includes的写法是 3.4 及更早版本的实现,本章按当前源码解释并对比历史实现。
当前实现:
export function queueJob(job: SchedulerJob): void {
if (!(job.flags! & SchedulerJobFlags.QUEUED)) {
// ...(排序插入)
job.flags! |= SchedulerJobFlags.QUEUED
queueFlush()
}
}
判定条件从"遍历队列查找 job 是否已存在"变成"读一个位"。成本从 O(n) 线性扫描降到 O(1) 位测试。
历史实现(3.4 及以前)用 queue.includes(job, fromIndex) 做去重,起点还要区分 isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex:因为队列保留已执行前缀(14.2.1),允许递归的 job 执行完后可能立刻又被入队,从 0 扫描会扫到"已执行、仍在数组里的自己"。3.5 改用 QUEUED 位后,去重变成 O(1) 且与位置无关,顺带修掉了"两个起点、两种语义"的易错点;递归白名单也从 job.allowRecurse 布尔属性改成 ALLOW_RECURSE 位。
| 维度 | 3.4 及更早 | 3.5.44 |
|---|---|---|
| 去重方式 | queue.includes(job, fromIndex)(O(n) 扫描) | job.flags & QUEUED(O(1)) |
| 递归白名单 | job.allowRecurse 布尔属性 | job.flags & ALLOW_RECURSE 位 |
includes 起点语义 | 需按 flushIndex/flushIndex+1 区分前缀与后缀 | 位标记与位置无关 |
14.3.3 getId:把"缺省 id"折算成排序主键
const getId = (job: SchedulerJob): number =>
job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id
(scheduler.ts:270-271)
job.id | 是否 PRE | getId 返回 | 排序位置 |
|---|---|---|---|
null/undefined | 否 | Infinity | 排在所有有 id 的 job 之后(普通无主 post 类任务) |
null/undefined | 是 | -1 | 排在所有有 id 的 job 之前 |
有值(如 uid) | — | 原值 | 按值插入 |
Infinity 与 -1 的选择不是随意的:
- "无 id 的非 pre job"通常是"没有任何组件归属的普通任务",把它排在最后最安全——它不依赖任何组件状态,晚执行无副作用。
- "无 id 的 pre job"是"没有归属组件的 pre 任务",把它排在最前,确保它在所有组件渲染前执行(pre 语义:渲染前)。注意:当前源码里
PRE标志主要由apiWatch设置(apiWatch.ts:225-231),而该处只在instance存在时才设job.id = instance.uid;若instance为 null(组件外创建且无i的 pre watcher),就会落到getId = -1分支。
Infinity 有个微妙副作用:Infinity - Infinity = NaN。在 post 队列排序 (a, b) => getId(a) - getId(b)(scheduler.ts:208)里,两个 Infinity 的比较返回 NaN,而 Array.prototype.sort 把 NaN 比较结果视为 0(相等),配合 ES2019 起规范的"稳定排序",相同 id 的元素保持原始入队顺序。这个"NaN 当 0 用"的技巧是源码里没写注释但实际生效的行为,14.4.4 会专门验证。
14.3.4 快速路径:为什么先判尾再二分
const jobId = getId(job)
const lastJob = queue[queue.length - 1]
if (
!lastJob ||
// fast path when the job id is larger than the tail
(!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob))
) {
queue.push(job)
} else {
queue.splice(findInsertionIndex(jobId), 0, job)
}
(scheduler.ts:102-112)
这段的逻辑是"优先尝试 O(1) 的尾部追加,失败才做 O(log n) 的二分 + O(n) 的 splice"。三个条件的含义:
!lastJob:队列空,直接 push。!(job.flags & PRE):只有非 pre job 才走快速路径。为什么把 pre 排除在外?因为 pre job 需要插到"同 id 的组件更新任务之前"(findInsertionIndex的 tie-break,见 14.4.2)。如果一个 pre job 的 id 恰好等于队尾 job 的 id,那么jobId >= getId(lastJob)成立,但按语义它必须插到队尾那个 job 之前——快速路径会产生错误顺序。所以 pre job 一律走精确插入。jobId >= getId(lastJob):新 job 的排序键不小于队尾,追加后仍保持非递减。
这个快速路径在实际场景里命中率很高:组件树的初始挂载、同一父组件的多个子组件按顺序更新、uid 单调递增的批量创建,都会让新 job 的 id 大于队尾。命中时入队是纯 O(1),不触发二分。
一个容易忽略的细节:快速路径命中时只判断了队尾,并没有检查"队里是否已存在同 id 的 job"。去重已经由 QUEUED 位在外面完成了(scheduler.ts:101),所以这里无需要再查。三层职责分离得很干净:
queueJob
├─ 第一层:QUEUED 位 → 是否已入队(O(1) 去重)
├─ 第二层:快速路径 → 是否能简单追加(O(1) 常见情况)
└─ 第三层:findInsertionIndex + splice → 精确有序插入(O(log n) + O(n) 兜底)
14.3.5 递归上限:RECURSION_LIMIT 与那条熟悉的报错
RECURSION_LIMIT 不在 queueJob 里使用,而在 checkRecursiveUpdates(scheduler.ts:329-348)里:
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
const count = seen.get(fn) || 0
if (count > RECURSION_LIMIT) {
const instance = fn.i
const componentName = instance && getComponentName(instance.type)
handleError(
`Maximum recursive updates exceeded${
componentName ? ` in component <${componentName}>` : ``
}. ` +
`This means you have a reactive effect that is mutating its own ` +
`dependencies and thus recursively triggering itself. Possible sources ` +
`include component template, render function, updated hook or ` +
`watcher source function.`,
null,
ErrorCodes.APP_ERROR_HANDLER,
)
return true // 返回 true → 调用方 continue,跳过这次执行
}
seen.set(fn, count + 1)
return false // 返回 false → 正常执行
}
三个设计点:阈值用 > 而非 >=——count > RECURSION_LIMIT 意味着同一 job 在单次 flush 内最多执行 101 次(第 102 次拦截),这个 off-by-one 是刻意宽松,避免误报合法的深度级联;报错时 instance 传 null——handleError(err, null, APP_ERROR_HANDLER) 的第二参是 null 而非 fn.i,所以递归警告不会走 errorCaptured 冒泡链,直接进 logError 抛出/打印,组件名靠消息字符串拼进去(in component <Xxx>);返回 true 而非抛异常——超限时只是跳过这个 job(调用方 continue),不中断整个 flush,动机是"保住其他组件的更新"。
seen 表是 dev-only 的(if (__DEV__) seen = seen || new Map(),scheduler.ts:274-276)。生产构建里整个 checkRecursiveUpdates 会被打桩为 NOOP(scheduler.ts:283-285):
const check = __DEV__
? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
: NOOP
这里有一段非常重要的工程注释(scheduler.ts:278-282):把 check 的确定放到 try/catch 之外,是为了避免 Rollup 在 try...catch 内部做去 treeshaking 优化——如果把条件判断写在 try 块内,整个警告代码可能无法被摇掉。这是一个"为了让 tree-shaking 生效而调整代码结构"的典型例子。
14.3.6 queueFlush:一次 tick 只排一个微任务
function queueFlush() {
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
(scheduler.ts:120-124)
这是"批处理"落地的关键三行。执行流程:
sequenceDiagram
participant U as 用户同步代码
participant Q as queueJob
participant F as queueFlush
participant M as 微任务队列
U->>Q: a.value++ (第 1 次)
Q->>F: queueFlush()
F->>F: currentFlushPromise === null ?
F->>M: resolvedPromise.then(flushJobs)
F->>F: currentFlushPromise =
U->>Q: a.value++ (第 2 次)
Q->>F: queueFlush()
F->>F: currentFlushPromise 非 null,直接返回
U->>U: 同步代码结束
M->>F: 微任务触发 flushJobs()
两次 a.value++ 只排了一个 flushJobs 微任务,这就是"合并"的物理实现。第二次及以后的所有 queueJob 都只是往 queue 数组里插,currentFlushPromise 这道闸门保证微任务不会被重复排期。
一个可能被忽略的边界:currentFlushPromise = resolvedPromise.then(flushJobs) 里的 flushJobs 是函数引用而非调用。then 的回调签名是 (value) => ...,flushJobs 的首参会收到 undefined,于是 seen 参数为 falsy,seen = seen || new Map() 生效(scheduler.ts:274-276)。这个"then 传参碰巧等价于无参调用"的行为是源码刻意利用的:flushJobs(seen?) 的首参设计成可选,正是为了能直接作为 then 回调。
14.4 排序策略:为什么按 uid 排序、插入的正确性证明
排序是调度器最核心的不变量。本节把"为什么这么排"和"这么排为什么对"讲透。
14.4.1 为什么按组件 uid 升序
源码在 findInsertionIndex 上方写了两条动机(scheduler.ts:70-78),值得逐字解读:
// Use binary-search to find a suitable position in the queue. The queue needs
// to be sorted in increasing order of the job ids. This ensures that:
// 1. Components are updated from parent to child. As the parent is always
// created before the child it will always have a smaller id.
// 2. If a component is unmounted during a parent component's update, its update
// can be skipped.
动机一:父组件先于子组件更新。 uid 由模块级计数器自增分配(component.ts:612),且组件实例创建严格遵循"父先创建、子后创建"(父组件渲染时才会创建子组件实例)。因此 parent.uid < child.uid 恒成立。按 uid 升序执行,天然地保证父组件先渲染、子组件后渲染。
为什么"父先"这么重要?因为父组件的渲染会重新计算子组件的 props,并把新 props 通过 updateProps 写进子组件(renderer.ts:1662)。如果子组件先渲染,它读到的还是旧的 props,会渲染出错误的中间态,随后父组件更新又触发子组件第二次渲染——即"同一 tick 内子组件渲染两次 + 一次中间态"。父先子后,子组件只需渲染一次,且读到的一定是最新 props。
动机二:父更新中卸载子组件时,可跳过子的更新。 若父组件的这次渲染把某个子组件从树上移除(v-if 翻转、列表项被删),unmount 会给该子组件的 job 打上 DISPOSED(renderer.ts:2399)。由于父 job 的 id 更小,父先执行;父执行完毕后,轮到子 job 时 flushJobs 检查 DISPOSED 直接跳过(scheduler.ts:290)。如果顺序是"子先父后",子组件就会先做一次毫无意义的渲染,然后才被卸载——纯浪费。
顺带一提,pre watcher 与它的组件更新 job 共享同一个 id(apiWatch.ts:227-228)。源码注释解释了这个设计的用意(scheduler.ts:76-78):pre watcher 插到同 id 的更新 job 之前,这样"如果组件被父更新卸载",DISPOSED/跳过逻辑能同时覆盖 watcher 与更新任务——因为两者紧邻,且都归同一 uid。
14.4.2 findInsertionIndex:二分细节与 PRE 的 tie-break
function findInsertionIndex(id: number) {
let start = flushIndex + 1
let end = queue.length
while (start < end) {
const middle = (start + end) >>> 1
const middleJob = queue[middle]
const middleJobId = getId(middleJob)
if (
middleJobId < id ||
(middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE)
) {
start = middle + 1
} else {
end = middle
}
}
return start
}
(scheduler.ts:79-98)
逐点解读:
- 搜索区间是
[flushIndex + 1, queue.length),即"待执行后缀"。已执行前缀[0, flushIndex]不参与搜索——否则可能把新 job 插到已执行位置,永远不会被执行。这是flushIndex这个游标存在的第二重意义。 (start + end) >>> 1而非Math.floor((start + end) / 2):>>>是无符号右移,等价于除以 2 取整,但对大数组不会因为start + end溢出 32 位而产生负索引。这是经典的"中点计算防溢出"写法(虽然 JS 数组长度实际上到不了 2^31,但保持一致更安全、也更省一次浮点运算)。- 循环不变量:区间
[start, end)内恒有"所有元素都>= id(按 tie-break 规则)"。当middleJobId < id,说明 middle 及其左侧都小于新 job,新区间收缩到右半;当middleJobId === id && middleJob 是 PRE,说明这个 PRE job 应该排在新的非 pre job 之前——所以新 job 该排它右边(start = middle + 1)。其余情况(middle 更大,或相等但 middle 非 PRE)都说明 middle 该在新 job 右侧或就是边界,收缩到左半(end = middle)。 - 返回
start:循环结束时start === end,即第一个"应排在新 job 之后"的位置,也就是新 job 的插入位置。
PRE 的 tie-break 规则表(middleJobId === id 时新 job 该插哪):
| 新 job 是否 PRE | 队中同 id 的 middleJob 是否 PRE | 新 job 相对 middleJob 的位置 |
|---|---|---|
| 否 | 否 | 之后(稳定:同 id 非 pre 保持入队顺序) |
| 否 | 是 | 之后(pre 优先于非 pre) |
| 是 | 否 | 之前(pre 优先于非 pre) |
| 是 | 是 | 之后(同为 pre,稳定保持入队顺序) |
归纳成一句:同 id 时,PRE job 排在非 PRE job 之前;PRE 与 PRE 之间、非 PRE 与非 PRE 之间保持入队顺序。 但要注意 queueJob 的快速路径排除了新 job 是 PRE 的情况(14.3.4),所以真正走到二分插入的 PRE job 一定是"id 比队尾小"的,逻辑自洽。
14.4.3 插入排序正确性证明:为什么"按 id 二分"就够
疑问在于:队列在 flush 中会被反复修改(任务执行时可能 queueJob),有序性还能保持吗?用归纳法证明。命题 P(n):经过 n 次 queueJob 后,queue[flushIndex+1 .. length-1] 按 getId 非递减(同 id 时 PRE 在前)。基例 n=0 显然成立。归纳步分三种情况:A(QUEUED 已置 → 直接返回,队列不变)、B(快速路径 push 到原最大元素之后,非递减保持)、C(splice 到二分解出的位置 k——由二分不变量,queue[k-1] 应在新 job 之前/相等、queue[k] 应在之后,插到 k 后两段各自有序且交界满足序关系)。三种情况都保持 P(n+1)。
关键在于二分只用 [flushIndex+1, length) 这个"待执行后缀",而 flush 中 flushIndex 单调不减(scheduler.ts:288),所以已执行前缀的乱序不影响后缀——这就是 start = flushIndex + 1 必要而非可选的原因。flushPreFlushCbs 的 queue.splice(i, 1)(scheduler.ts:171)是删除而非插入,剩余元素相对顺序不变,也不破坏有序性。结论:只要所有修改都经 queueJob 的二分/splice 或 flushPreFlushCbs 的删除,待执行后缀永远有序,源码因此可以放心假设"queue 已排序"而不每次重排。
14.4.4 post 队列的排序:去重 + 稳定排序 + Infinity 陷阱
post 队列的排序与主队列完全不同——它是在"排出"时整体排序,而不是插入时定位(scheduler.ts:208-209):
const deduped = [...new Set(cbs)].sort((a, b) => getId(a) - getId(b))
pendingPostFlushCbs.length = kept
三个动作:
new Set(cbs)去重:post cb 没有QUEUED位那种"入队即置位"的保护吗?有的(scheduler.ts:130-133的非数组分支会置位),但数组形态的 cb(组件生命周期钩子数组)在queuePostFlushCb的数组分支里是直接push的、不查重(scheduler.ts:134-144)。源码注释解释了原因:"数组形态必然是组件生命周期钩子,它们只能由已经去重过的 job 触发,所以这里跳过去重以提升性能。"尽管如此,为了兜底(同一钩子数组可能被两次queuePostRenderEffect排出),排出时统一用Set去重。.sort(...)按getId升序:让 post cb 也按组件uid排列。这样"父组件的updated先于子组件的updated",与主队列的父先子后一致。[...]拷贝:不原地改pendingPostFlushCbs。因为可能有skip过滤逻辑先把要保留的元素前移(scheduler.ts:197-207),只剩kept个;拷到新数组后把pendingPostFlushCbs.length = kept截断。
Infinity 陷阱的验证:两个无 id 的非 pre post cb(getId 都是 Infinity),比较器返回 Infinity - Infinity = NaN。Array.prototype.sort 规范(ES2019 起)要求 compareFn 返回 NaN 时等同于返回 0,且排序必须稳定(相等元素保持原相对顺序)。因此两个 Infinity 的 cb 保持入队顺序——这正是模板 ref 任务(job.id = -1)之外的普通 post cb 想要的"先来先服务"语义。
下表总结两条队列的定位方式差异:
| 维度 | 主队列 queue | post 队列 pendingPostFlushCbs |
|---|---|---|
| 插入时是否排序 | 是(二分定位) | 否(直接 push,数组序无意义) |
| 排出时是否排序 | 否(已有序) | 是(sort 整体排序) |
| 去重机制 | QUEUED 位(O(1)) | 数组分支靠"上游 job 已去重";排出时 Set 兜底 |
| 排序键相同时的 tie-break | PRE 优先 + 保持入队顺序 | 稳定排序保持入队顺序 |
| 特殊 id | 无 id 非 pre = Infinity;无 id pre = -1 | 同左 |
14.4.5 为什么用 uid 而不是"入队序"或"组件深度"
若改用入队序(FIFO):父子顺序失去保证(子可能先入队 → 渲染出旧 props 中间态),卸载跳过也失效(子 job id 更小 → 先执行再被卸载,白渲染)。若改用组件深度:同深度不同分支的组件顺序无法确定,且每次入队都要 O(深度) 计算。uid 是"既便宜又能表达拓扑序"的最优选择——它把"创建顺序"这一包装期信息冻结成一个整数,运行时无需任何树遍历就得到拓扑序。
14.5 flushJobs 逐行剖析:一次 flush 的完整生命周期
flushJobs 是调度器的执行引擎(scheduler.ts:273-327)。它只有 55 行,但包含主循环、兜底清位、post 排出、递归再入四个阶段。本节先看骨架,再用一张逐步状态表把整个时序钉死。
14.5.1 三段式骨架
function flushJobs(seen?: CountMap) {
if (__DEV__) seen = seen || new Map()
const check = __DEV__ ? (job) => checkRecursiveUpdates(seen!, job) : NOOP
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
// ...执行任务
}
} finally {
// 1) 兜底清位
for (; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) job.flags! &= ~SchedulerJobFlags.QUEUED
}
// 2) 重置主队列
flushIndex = -1
queue.length = 0
// 3) 排出 post 队列
flushPostFlushCbs(seen)
// 4) 打开微任务闸门
currentFlushPromise = null
// 5) 若两条队列又有新任务,递归再跑一轮
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
把这段拆成"三段":
┌─────────────────────────────┐
│ Phase A 主循环(try) │ 按 flushIndex 顺序执行 queue 中的任务
├─────────────────────────────┤
│ Phase B 清理(finally 前半)│ 兜底清 QUEUED、清 queue、flush post
├─────────────────────────────┤
│ Phase C 递归(finally 后半)│ 若又有新任务 → flushJobs(seen) 再跑
└─────────────────────────────┘
14.5.2 主循环逐行
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) {
if (__DEV__ && check(job)) continue
if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
callWithErrorHandling(
job,
job.i,
job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER,
)
if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
}
}
(scheduler.ts:288-306)
逐行含义:
flushIndex = 0初始化:每轮 flush 从头扫。注意queue.length在循环中可能增长(任务执行时入队),而flushIndex < queue.length是每次都重新求值的动态条件——所以"执行期间新增的任务"会被同一轮循环吃掉。这正是for条件用queue.length(而非缓存的长度变量)的原因。if (job && !DISPOSED):job可能为undefined(flushPreFlushCbs用splice删过元素,数组可能出现空洞);DISPOSED表示组件已卸载,跳过。二者是两道独立防线。check(job)→continue:dev 下的递归守护(14.3.5)。continue会触发flushIndex++,任务被跳过且不会被清 QUEUED——严格说这是个"故意留下的不一致",但因为在递归超限场景下该 job 已经被判定为病态,留着 QUEUED 可避免它在下一轮又被立即排队。- 执行前清 QUEUED(仅 ALLOW_RECURSE):见 14.2.6 的详细解释。
callWithErrorHandling(job, job.i, job.i ? COMPONENT_UPDATE : SCHEDULER):任务一律经过统一错误处理。job.i决定错误码:有归属组件的是COMPONENT_UPDATE(会在报错信息里带组件上下文),无归属的纯任务是SCHEDULER(errorHandling.ts:63-64对应字符串'scheduler flush'/'component update')。这就是"组件更新报错时提示<ComponentName> is missing之外的上下文"的来源。- 执行后按需清 QUEUED:见 14.2.6。
注意 job 的返回值被忽略、不做 await。flushJobs 是同步的,任何任务里产生的 Promise 都不会被调度器等待(异步任务要自己用 await nextTick() 或 Suspense 编排)。
14.5.3 逐步状态表:一个可复现的完整场景
为了让时序可验证,构造如下场景(14.13 会给可运行的实验代码):
// App (uid = 0)
const n = ref(0) // App 的 render 依赖它
const side = ref(0)
watch(n, () => { side.value++ }, { flush: 'pre' }) // pre 队列,id = 0
// 模板:<Child :n="n" /><Sibling :s="side" />
// Child (uid = 1):模板 {{ n }},注册 onUpdated
// Sibling (uid = 2):模板 {{ s }},注册 onUpdated
用户同步执行 n.value++。以下每一步都标注"此刻的队列状态"。约定用 J(id,flags) 表示主队列元素,P(cb) 表示 post 队列元素。
| 步骤 | 事件 | queue | pendingPostFlushCbs | flushIndex | currentFlushPromise |
|---|---|---|---|---|---|
| S0 | 初始(挂载已完成) | [] | [] | -1 | null |
| S1 | n.value++ 触发 App 的 render effect → queueJob(J_app) | [J(0)] | [] | -1 | <pending>(queueFlush 排微任务) |
| S2 | 同一次写触发的 pre watcher → queueJob(J_pre);因 PRE 走精确插入,插到同 id 的 J(0) 之前 | [J(0,PRE), J(0)] | [] | -1 | <pending> |
| S3 | 同步代码结束 → 微任务 flushJobs 开始;flushIndex=0 取 J(0,PRE),清其 QUEUED,执行 pre 回调 → side.value++ → Sibling 的 render effect 入队 | [J(0,PRE), J(0), J(2)] | [] | 0 | <pending> |
| S4 | flushIndex=1 取 J(0)(App 更新)。App 重渲染、patch Child → updateComponent 同步调用 instance.update();Child 在自己的 run 内写 props(因 toggleRecurse(false) 不会重新入队);App、Child 的 updated 钩子被排出 | [J(0,PRE), J(0), J(2)] | [P(AppUpdated), P(ChildUpdated)] | 1 | <pending> |
| S5 | flushIndex=2 取 J(2)(Sibling 更新)。Sibling 重渲染,其 updated 钩子被排出 | [...同上] | […, P(SiblingUpdated)] | 2 | <pending> |
| S6 | flushIndex=3 → 3 < queue.length(3) 为假,主循环结束 | 同 S5 | 3 个 P | 3 | <pending> |
| S7 | finally:flushIndex 已等于 queue.length,兜底清位循环不执行;flushIndex=-1;queue.length=0 | [] | 3 个 P | -1 | <pending> |
| S8 | flushPostFlushCbs:deduped = sort(3 个 P),三者 getId 均为 Infinity → 稳定排序保持 App→Child→Sibling 顺序;依次执行 | [] | [] | -1 | <pending> |
| S9 | currentFlushPromise = null;queue.length === 0 && pendingPostFlushCbs.length === 0 → 不递归 | [] | [] | -1 | null |
| S10 | 微任务结束。此前注册的 nextTick(fn) Promise 现在 resolve | [] | [] | -1 | null |
几个必须理解的细节:
- S3 的 pre 回调为什么可以在主循环里执行? PRE job 同时在主队列里,主循环会正常扫到它。
flushPreFlushCbs是另一条"提前执行"的通道(14.8),用于在某个组件渲染之前精确地把它的 pre job 排空。 - S4 中 Child 为什么没有被单独入队? 因为
componentUpdateFn在更新分支里先toggleRecurse(instance, false)(renderer.ts:1536),把 Child 的ALLOW_RECURSE关掉;此时 Child 的 render effect 正处于 RUNNING(effect.ts:152),notify()判定RUNNING && !ALLOW_RECURSE成立 → 直接 return,不进入 batch。这一步是"防止子组件更新自己的 props 时又把自己排一次队"的关键闸门。 - S4 的 Child 更新是同步的:
updateComponent里instance.update()是直接调用effect.run(renderer.ts:1331-1333),不是入队。所以子组件在父组件这一趟渲染中就地完成,不需要等下一个微任务。 - S8 的 post 顺序与主队列一致:
Infinity的稳定性把"入队顺序"保留了下来,而入队顺序恰好就是 App→Child→Sibling(父先子后)。
14.5.4 finally 的兜底清位:为什么单独写一个循环
} finally {
// If there was an error we still need to clear the QUEUED flags
for (; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
}
flushIndex = -1
queue.length = 0
...
}
(scheduler.ts:307-318)
正常路径下,每个 job 在执行前后都已经清过 QUEUED,这个循环扫到的都是"已被清位"的元素,属于无操作空转。它的真正价值在异常路径:
- 若某个 job 在执行中抛错(且没有被
handleError吞掉,比如用户设了throwUnhandledErrorInProduction),控制流会从try块直接跳到finally。此时flushIndex停在出错位置,后面尚未执行的任务仍带着QUEUED位。如果不清理,这些 job 会"永久卡在已入队状态",后续任何queueJob都会因QUEUED非零而被静默跳过——整个组件子树再也不会更新。 - 兜底循环把它们全部清位,保证异常之后调度器仍能正常工作。
- 紧接着
queue.length = 0清空队列(注意:不是逐个splice,而是直接截断,O(1))。
一个反直觉点:flushIndex = -1 必须放在清位循环之后(scheduler.ts:316)。如果先置 -1,清位循环会从 -1 开始扫,queue[-1] 是 undefined,if (job) 兜住不会报错,但循环变成从 0 到 length-1 再扫一遍——虽然结果一样,但语义混乱。当前顺序(先扫完 从出错位到末尾,再置 -1)表达了"清理尚未执行的后缀"这一意图。
14.5.5 递归再入:为什么用 flushJobs 递归而不是 while 循环
flushPostFlushCbs(seen)
currentFlushPromise = null
// If new jobs have been added to either queue, keep flushing
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
(scheduler.ts:319-325)
三点解释:
- 为什么会"queue 又有内容"? 三种来源:(a) 某任务在执行中
queueJob了新任务,但在当前循环里已被吃掉(queue.length动态求值),所以这种情况不需要递归;(b)flushPostFlushCbs执行 post cb 时,某个 post cbqueueJob了新任务——此时主循环已结束,必须递归;(c) post cb 又排出了新的 post cb(但activePostFlushCbs机制通常已在本轮吃掉,只有"post 批次结束后又有人 push"才会落到这里)。所以"递归"主要服务的是 (b)。 currentFlushPromise = null放在递归之前:递归的那一轮flushJobs会重新检查queue.length并清空,结束时再次置 null。若直到最后仍有任务残留(理论上不会,因为循环会跑干净),闸门保持打开状态也无害——但正常路径下一定以 null 收尾。- 递归而非
while的动机:seen表要在整个级联中共享(同一 job 反复执行会被累加计数)。用递归可以把同一个seen透传下去;用while的话,flushIndex/seen的重置逻辑都要手写,容易出错。此外每一轮递归都重新进入try/finally,即使某轮抛错,其自身的 finally 仍会执行兜底清理——while里嵌try/finally也能做到,但结构不如递归清晰。栈深度风险极低:递归只发生在"post 阶段又产生了同步任务"这种罕见情况,且每轮都会收敛(seen计数会拦住失控的链)。
14.5.6 flushPostFlushCbs 逐行
export function flushPostFlushCbs(
seen?: CountMap,
skip?: Set<SchedulerJob>, // root mount: 保留它之前排入的 cbs
minUid = 0, // root mount: 只排出 uid >= minUid 的 cbs
): void {
if (pendingPostFlushCbs.length) {
let cbs = pendingPostFlushCbs
let kept = 0
if (skip) {
cbs = []
for (let i = 0; i < pendingPostFlushCbs.length; i++) {
const cb = pendingPostFlushCbs[i]
if (cb.i !== undefined ? !cb.i || cb.i.uid < minUid : skip.has(cb)) {
pendingPostFlushCbs[kept++] = cb // 保留在原数组前部
} else {
cbs.push(cb) // 本轮要执行
}
}
}
const deduped = [...new Set(cbs)].sort((a, b) => getId(a) - getId(b))
pendingPostFlushCbs.length = kept
// #1947 already has active queue, nested flushPostFlushCbs call
if (activePostFlushCbs) {
for (let i = 0; i < deduped.length; i++) {
activePostFlushCbs.push(deduped[i])
}
return
}
activePostFlushCbs = deduped
if (__DEV__) seen = seen || new Map()
for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
const cb = activePostFlushCbs[postFlushIndex]
if (__DEV__ && checkRecursiveUpdates(seen!, cb)) continue
if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
cb.flags! &= ~SchedulerJobFlags.QUEUED
}
if (!(cb.flags! & SchedulerJobFlags.DISPOSED)) cb()
cb.flags! &= ~SchedulerJobFlags.QUEUED
}
activePostFlushCbs = null
postFlushIndex = 0
}
}
(scheduler.ts:186-242)
逐段拆解:
(1)skip / minUid 参数:为 flushOnAppMount 服务的取舍逻辑。
skip 是一个 Set<SchedulerJob>——"在本次根挂载之前就已经在 pendingPostFlushCbs 里的 cb"。minUid 是"只排出归属 uid ≥ minUid 的 cb"。这套机制解决的是 issue #5721 / #6728 的张力:
mounted钩子里的watch(..., { flush: 'post' })应该看到mounted之后的状态(#5721),所以挂载引发的 post cb 必须在本轮排出;- 但别的组件(在本次挂载之前就存在于队列中)的 post cb 不能被这次挂载"顺带"提前排出,否则会破坏它们的顺序(#6728)。
于是:cb.i 有值(是组件钩子)时按 cb.i.uid >= minUid 判断归属;cb.i 为 undefined 时退回 skip.has(cb) 判断它是"挂载前排入的"还是"挂载时新排入的"。保留的 cb 被压缩到 pendingPostFlushCbs 前部(kept++ 原地写),然后 pendingPostFlushCbs.length = kept 截断。
(2)[...new Set(cbs)] 去重 + 排序:见 14.4.4。注意这里对数组形态的 cbs(生命周期钩子数组)也会展开去重——Set 会把数组本身当成一个元素,所以实际上"数组元素被去重"这句话要打个折扣:new Set 去重的是 cb 的引用,数组作为单个引用只会出现一次。真正的展开发生在 queuePostFlushCb 入队时(scheduler.ts:141-143 的 for 循环逐项 push)。
(3)activePostFlushCbs 的二次插入:如果当前已有一个 post 批次在执行(activePostFlushCbs 非 null),说明这是"执行 post cb 期间又有人调用了 flushPostFlushCbs"(#1947 嵌套场景)。此时不能另起一轮,否则新 cb 会被排到下一个微任务;正确做法是把新批次 push 到正在执行的数组尾部,让外层循环的 postFlushIndex 自然扫到它们。
(4)执行循环与 DISPOSED:cb.flags & ALLOW_RECURSE 时执行前清 QUEUED(与主循环一致);DISPOSED 的 cb 跳过执行——这是"组件在 post cb 排入之后、执行之前被卸载"的防护(例如 unmounted 与 updated 同时排队,但组件先被卸载)。注意:跳过执行的 cb 仍会被清 QUEUED(最后一行无条件执行),保证它不会卡住。
(5)退出时重置:activePostFlushCbs = null; postFlushIndex = 0。把 activePostFlushCbs 置回 null 是"批次结束"的信号,允许下一轮 flushPostFlushCbs 另起批次。
与大纲的差异说明:任务大纲 14.5 提到"
flushPostFlushCbs的逆序"。3.5.44 源码里 post cb 是升序排序((a, b) => getId(a) - getId(b),scheduler.ts:208),并非逆序执行。当前实现保证的是"父组件的 post cb 先于子组件",与主队列的父先子后保持一致。本章按源码实际行为说明。
14.5.7 flushOnAppMount:根挂载/卸载的"局部 flush"
flushOnAppMount(scheduler.ts:254-268)是 3.5.x 新增的对外接口,用于"挂载/卸载一个根组件"这个特定场景。它的语义在源码注释里写得很清楚(scheduler.ts:246-253):一次挂载/卸载只 flush "它创建的那些组件的任务与它排出的 post cb",而其他组件留在队列里的任务保持原顺序(#6728)。而一次"根更新"则flush 所有待执行的 pre job 与 post cb,因为它重渲染的组件可能 id 比它还小。
三个要点:
isFlushing判定:若已在 flush 中,直接fn(),把任务交给外层flushJobs(14.2.4)。queued = new Set(pendingPostFlushCbs):在fn()之前快照"挂载前排入的 post cb",作为skip集的来源。try/finally确保isFlushing一定复位。
14.5.8 复杂度小结
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
queueJob 快速路径命中 / 去重 | O(1) | 尾部追加 / 位测试 |
queueJob 走二分 | O(log n) + O(n) | 二分定位 + splice 移位 |
flushJobs 主循环 | O(k),k = 本轮任务数 | 游标推进,不删元素 |
flushPostFlushCbs | O(m log m),m = 本轮 post cb 数 | Set + sort |
| 单次 flush 总成本 | 接近 O(k) | 快速路径掩盖了二分的 log 因子 |
splice 的 O(n) 移位只在"乱序到达、pre 插队、id 比队尾小"时发生,占比很低,这就是"平均接近线性"的来源。
14.6 nextTick:把"flush 完成"建模为一个 Promise
14.6.1 源码只有六行
export function nextTick(): Promise<void>
export function nextTick<T, R>(this: T, fn: (this: T) => R | Promise<R>): Promise<R>
export function nextTick<T, R>(
this: T,
fn?: (this: T) => R | Promise<R>,
): Promise<void | R> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
(scheduler.ts:57-68)
实现只有一行有效逻辑:const p = currentFlushPromise || resolvedPromise。三个重载签名是为了支持 this 绑定(模板里的 this.$nextTick,以及 Options API 里 this.nextTick 的自动绑定)。
14.6.2 currentFlushPromise || resolvedPromise:两种情形的语义
| 调用时机 | currentFlushPromise | 用哪个 Promise | 回调何时执行 |
|---|---|---|---|
| 同步代码里、有等待中的 flush | 非 null | currentFlushPromise | 本次 flushJobs 完成之后(含 post cb) |
| 同步代码里、没有等待中的 flush | null | resolvedPromise | 下一个微任务(当前微任务批次 FIFO) |
| flush 过程中(任务/post cb 内) | 非 null | currentFlushPromise | 仍等本次 flushJobs 全部结束 |
第二种情形常被误解。如果一个组件已经挂载完毕、没有任何待 flush 的任务,此时调用 nextTick(fn),它排到 resolvedPromise 上——也就是"下一个微任务"。这是正确的:既然没有待执行的更新,那么"DOM 已是最新"这个前提当下就成立,fn 只需等一个微任务即可安全读取 DOM。
14.6.3 为什么 nextTick 返回 Promise 而不是用回调
Vue 3 放弃 Vue 2 的 callbacks 数组,改为返回 Promise:await nextTick() 与语言原语对齐,天然支持 try/catch 与 Promise.all;且直接复用 currentFlushPromise 的 .then,零额外数据结构——nextTick 不是独立模块,而是队列数据结构的副产品。nextTick(fn) 的回调重载只是兼容,内部就是 p.then(fn),fn 返回值成为 resolve 值(await nextTick(() => dom.innerHTML) 能拿到)。
14.6.4 nextTick 与 post cb 的先后:DOM 与钩子的完整时序
一个高频疑问:"await nextTick() 之后,updated 钩子跑了吗?"答案取决于 nextTick 调用点,把时序拉平看:
sequenceDiagram
participant U as 同步代码
participant M as 微任务:"flushJobs"
participant Q as 主队列任务
participant P as post 队列(updated/mounted/post-watch)
participant N as nextTick 回调(.then on currentFlushPromise)
U->>U: 改状态 + 注册 nextTick(fn)
U->>N: fn 被挂到 currentFlushPromise.then
M->>Q: 执行 queue 中所有任务(patch DOM)
M->>P: flushPostFlushCbs(updated 钩子等)
M->>N: flushJobs 返回 → currentFlushPromise resolve
N->>N: fn 执行(此时 DOM 与 updated 钩子都已完成)
结论:nextTick(fn) 的回调一定晚于同一次 flush 的 post cb(updated/mounted)。因为 post cb 在 flushJobs 内部(scheduler.ts:319)执行,而 fn 挂在 flushJobs 这个 .then 处理器的后续 .then 上。这解释了为什么"在 await nextTick() 里读子组件 ref 一定拿得到"。
反之,如果在 updated 钩子内部调用 nextTick(fn),currentFlushPromise 仍非 null(flushJobs 尚未返回),所以 fn 会排在"本次 flush 之后"——但这时它可能晚于后续递归轮的任务。这一点在 14.13 的实验三里会打印出来。
14.6.5 三个易错点
nextTick 保证的是"等待中的更新已执行完",不保证"DOM 一定变了"(值相等时不产生渲染);await nextTick(); await nextTick() 会多等一个微任务,但不代表两次渲染;它也不是 setTimeout(0) 的替代品——想"等渲染"就用它的语义。
14.7 组件更新任务:instance.update 与 instance.job 的分工
14.5 的时序表里出现了一个关键区分:父组件 patch 子组件时走的是同步的 instance.update(),而"依赖写脏导致的重渲染"走的是入队的 instance.job。本节把这条链路的每个环节接起来。
14.7.1 渲染器的三行核心代码
// create reactive effect for rendering
instance.scope.on()
const effect = (instance.effect = new ReactiveEffect(componentUpdateFn))
instance.scope.off()
const update = (instance.update = effect.run.bind(effect))
const job: SchedulerJob = (instance.job = effect.runIfDirty.bind(effect))
job.i = instance
job.id = instance.uid
effect.scheduler = () => queueJob(job)
(renderer.ts:1627-1635)
这 9 行里定义了四个东西,值得逐一对照:
| 名字 | 值 | 用途 | 谁调用 |
|---|---|---|---|
instance.effect | new ReactiveEffect(componentUpdateFn) | 渲染副作用,负责依赖收集 | 间接:update/job |
instance.update | effect.run.bind(effect) | 强制同步更新,跳过脏检查 | updateComponent(父驱动更新)、Suspense 的 nonHydratedAsyncRoot 补更新(renderer.ts:1519) |
instance.job | effect.runIfDirty.bind(effect) | 条件更新,仅当依赖真的脏了才渲染 | 调度器(queueJob) |
effect.scheduler | () => queueJob(job) | 依赖被写脏时的回调 | 响应式系统(effect.trigger,effect.ts:209-210) |
为什么 update 与 job 必须是两个不同的绑定? 这是 3.5 的一个精细优化:
effect.run()无条件执行componentUpdateFn();effect.runIfDirty()先调用isDirty(effect)(effect.ts:219-223),isDirty会遍历 effect 的 dep 链,逐个比较link.version !== link.dep.version(effect.ts:353-370)。只要有一个依赖的版本变过,就判定为脏,执行run();否则什么都不做。
这就带来一个非常实际的好处:同一个组件可能被多次 queueJob,但真正渲染只会发生一次(或零次)。因为在 3.5 里,write 不会无条件地把 effect 标记为"必须跑",而是让 effect 在执行那一刻再去核对"我读过的那些 dep,版本真的变了吗?"。如果 props 被父组件更新过一次(在父的渲染里同步完成的),随后子组件自己的 queued job 执行时发现 dep 版本已同步(因为子的同步渲染已经读过新值并同步了 link.version),于是 runIfDirty 直接跳过。
effect.runIfDirty 里 isDirty 的等价写法(3.4 及更早)是 ReactiveEffect.runIfDirty = () => { if (this.dirty) this.run() },而 dirty 是一个布尔标志,在 trigger 时置位。3.5 用 版本比对 取代布尔标志,好处是:
trigger不必再关心"要不要置 dirty",notify只做批处理(batch);- 能区分"依赖被写了,但值没变"(
hasChanged短路 → 不trigger); - 能处理 computed 的依赖:
isDirty里对 computed dep 会尝试refreshComputed(effect.ts:357-359),如果 computed 重新求值后版本仍未变,则不判定为脏。
14.7.2 job.id = instance.uid:序列号从哪来
instance.uid 在 createComponentInstance 里分配(component.ts:612 附近的模块级计数器 uid++)。它的两个关键性质:
- 全局单调递增,跨 app 实例共享一个计数器。多个
createApp()创建的 app 共享同一计数器,因此"不同 app 的组件"之间也有稳定的相对顺序。 - 父组件先创建。因为组件实例的创建发生在父组件渲染子组件 vnode 时,所以
parent.uid < child.uid。
job.i = instance 则给任务打上"归属",供错误上报(scheduler.ts:333 的 getComponentName(instance.type))与 post 队列的 minUid 过滤使用。
14.7.3 两条更新路径:同步 vs 入队
flowchart TB
A["状态被写脏"] --> B["dep.notify → effect.notify → batch"]
B --> C{"endBatch 时 trigger"}
C -->|有 scheduler| D["effect.scheduler() → queueJob(instance.job)"]
D --> E["进入主队列,等微任务 flush"]
F["父组件 patch 子组件"] --> G["updateComponent(n1,n2)"]
G --> H{"shouldUpdateComponent?"}
H -->|是| I["instance.next = n2"]
I --> J["instance.update()(同步 effect.run)"]
H -->|否| K["只复制 el/placeholder,替换 instance.vnode"]
两条路径的差异:
| 维度 | 依赖写脏 → 入队路径 | 父 patch → 同步路径 |
|---|---|---|
| 入口 | effect.scheduler → queueJob(job) | updateComponent → instance.update() |
| 执行时机 | 本 tick 的微任务 flush | 立刻(在父组件这一趟渲染中) |
| 用的绑定 | effect.runIfDirty(脏检查) | effect.run(无条件) |
instance.next | null(组件自己改了自己依赖的状态) | n2(父传来的新 vnode) |
| 典型触发 | count.value++ | 父组件重渲染导致 props/slots 变化 |
两条路径在 componentUpdateFn 内部汇合,靠 instance.next 是否为空分派(renderer.ts:1503、1537-1542):
let { next, bu, u, parent, vnode } = instance
...
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized) // 父驱动:先写 props/slots
} else {
next = vnode // 自更新:沿用当前 vnode
}
14.7.4 updateComponent 为什么要"同步"而不是"入队"
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!
if (shouldUpdateComponent(n1, n2, optimized)) {
if (__FEATURE_SUSPENSE__ && instance.asyncDep && !instance.asyncResolved) {
// async & still pending - just update props and slots
n2.el = n1.el
n2.placeholder = n1.placeholder
updateComponentPreRender(instance, n2, optimized)
return
} else {
// normal update
instance.next = n2
// instance.update is the reactive effect.
instance.update()
}
} else {
n2.el = n1.el
n2.placeholder = n1.placeholder
instance.vnode = n2
}
}
(renderer.ts:1307-1341)
三个分支:
shouldUpdateComponent为假:props/slots 都没变(或变化的只有不会触发子渲染的部分),只需把n2的 DOM 引用复制过来、替换instance.vnode,不执行任何渲染。这是"父组件重渲染但子组件没必要更新"的快速路径,shouldUpdateComponent里对dynamicProps/patchFlag有一整套判定(第 13 章已展开)。- 异步组件尚未解析(
asyncDep && !asyncResolved):不能渲染(渲染 effect 还没建立),只更新 props 与 slots,并把el/placeholder从旧 vnode 搬运过来(注释特意提到"hydration 被中断时,卸载已认领 DOM 依赖这个 el")。 - 正常路径:
instance.next = n2; instance.update()——同步渲染。
为什么同步? 如果这里改成 queueJob(instance.job),子组件的更新会被推迟到微任务,于是:
- 父组件的这一趟渲染会
patch出"子组件 DOM 还是旧内容"的中间态,用户可能在父组件render之后的同步代码里读到它(比如onVnodeBeforeUpdate钩子); - 更重要的是,子组件的
updated钩子会从"父更新这一趟"漂移到"下一个微任务",打乱 post 队列的顺序语义。
同步执行让"父渲染 → 子更新 → 子 DOM 就绪"在同一趟调用栈内完成,flushJobs 后面的 post 阶段可以放心地以为"整棵树都是最新"。
代价是:递归深度 = 组件树深度。一棵 1000 层深的组件树在父渲染时会形成 1000 层调用栈——这是"同步更新"的直接成本。但因为子组件自身的 queued job 会被 runIfDirty 的脏检查跳过(14.7.5),所以"重复渲染"这个更常见的成本被消掉了。
14.7.5 同步更新 + 脏检查的配合:一次写状态只渲染一次
把 14.5 状态表的 S3–S4 放大,看"父先子后 + 无重复渲染"是怎么达成的:
- t0:
n.value++→ App 的 render effect 通过notify → batch被标记 NOTIFIED;endBatch 时trigger()→scheduler()→queueJob(J_app)。 - t1(微任务):
flushJobs执行J_app→effect.runIfDirty→isDirty(J_app.effect)为真(n的dep.version变了)→run()→ 重渲染 App → patch Child。 - t2(仍在 J_app 内):
updateComponent(Child)→shouldUpdateComponent为真 →instance.next = n2; instance.update()→ Child 同步run():componentUpdateFn先toggleRecurse(child, false)(renderer.ts:1536);updateComponentPreRender→updateProps(child)写props.n = 1→dep.notify→ child 的 render effect 正处于 RUNNING 且 ALLOW_RECURSE 已被关掉 →notify()直接 return(effect.ts:150-156);- 因此 Child 不会被再次
queueJob; - 渲染时读到
props.n = 1,渲染并同步link.version;beforeUpdate/updated钩子按序执行,updated被queuePostRenderEffect排出。
- t3:App 的
updated也排出。 - t4:
flushPostFlushCbs依次执行 App、Child 的updated。
若没有 toggleRecurse(child, false),updateProps 会把 Child 重新 queueJob 一次,主循环再扫到它时 runIfDirty 因 Child 已同步读过新 props(link.version === dep.version)而跳过——即便没有它也不会重复渲染 DOM,只是白白多一次入队/脏检查。这个"双重保险"(同步渲染 + runIfDirty 版本比对)是 3.5 相比早期版本最实在的优化:早期用 dirty 布尔位,父写 props 即置 dirty,queued job 跑起来会真的重渲染一次,故需 allowRecurse 与 includes 去重等高成本手段配合;3.5 用版本比对从根上消除了这类重复渲染。
14.7.6 next 与 flushPreFlushCbs:updateComponentPreRender 里的顺序
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean,
) => {
nextVNode.component = instance
const prevProps = instance.vnode.props
instance.vnode = nextVNode
instance.next = null
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
pauseTracking()
// props update may have triggered pre-flush watchers.
// flush them before the render update.
flushPreFlushCbs(instance)
resetTracking()
}
(renderer.ts:1653-1670)
执行顺序的语义:
- 先写 props/slots(
updateProps/updateSlots)——把父传来的新值落进子实例。 - 再
flushPreFlushCbs(instance)——props 写入可能触发了子组件内flush: 'pre'的 watcher(它们依赖这些 props)。这些 pre watcher 必须在子组件渲染之前执行完,否则渲染会读到它们尚未处理的状态。 pauseTracking()/resetTracking()——flush 期间必须关闭依赖收集,否则"pre watcher 里读到的响应式数据"会被错误地记成当前渲染 effect 的依赖,导致渲染 effect 依赖膨胀、触发多余更新。
flushPreFlushCbs(instance) 只排出"属于这个 instance(cb.id === instance.uid)"的 pre job(scheduler.ts:162-167),因为此刻只有它的 props 刚刚变了。其他组件的 pre job 会在各自被处理时排空。
14.7.7 DISPOSED:卸载后的任务必须失效
组件卸载时(renderer.ts:2392-2400):
// stop effects in component scope
scope.stop()
// job may be null if a component is unmounted before its async
// setup has resolved.
if (job) {
// so that scheduler will no longer invoke it
job.flags! |= SchedulerJobFlags.DISPOSED
unmount(subTree, instance, parentSuspense, doRemove)
}
(1)scope.stop() 停掉组件作用域内所有副作用(watch/computed/子 effect),从源头阻断"卸载后再被写脏";(2)job.flags |= DISPOSED 是第二道防线——万一在 scope.stop() 之前 job 已经入了队,flushJobs 与 flushPostFlushCbs 都会因 DISPOSED 而跳过它。
注意 if (job) 的判断:源码注释解释"异步组件 setup 尚未解析时被卸载,job 可能为 null"。这类组件的 job 在 setupRenderEffect 里才创建,若从没走到那一步就是 null。
DISPOSED 也用在一个更隐蔽的地方——清理钩子数组(renderer.ts:2665):
hooks[i].flags! |= SchedulerJobFlags.DISPOSED
这是把"已经排进 post 队列但组件已卸载的钩子"标记为失效,防止"卸载后又触发 updated"这类不可能的场景。
14.8 pre / post 两条队列:什么时候"抢在渲染前"、什么时候"等到渲染后"
调度器只有一条主队列,但任务按"相对组件渲染的位置"分成两类,用 PRE 标志与 post 队列两个机制分别实现。
14.8.1 pre 与 post 的语义对照
| 维度 | pre(PRE 标志 + 主队列) | post(pendingPostFlushCbs) |
|---|---|---|
| 相对渲染的时机 | 组件渲染之前 | 整棵树渲染之后 |
| 存放位置 | 主队列 queue(与组件更新任务混排、按 id 排序) | 独立的 pendingPostFlushCbs |
| 排序 | 插入时二分定位;同 id 时排在同组件的更新任务之前 | 排出时整体 sort 按 getId 升序 |
| 能否读到新 DOM | 否(DOM 尚未 patch) | 是 |
| 典型来源 | watch(..., { flush: 'pre' })(默认)、props 写入触发的 watcher | updated/mounted/unmounted 钩子、watch(..., {flush:'post'})、模板 ref 赋值、Suspense effects |
| 谁"抢跑" | updateComponentPreRender 调 flushPreFlushCbs(instance) | flushJobs 末尾调 flushPostFlushCbs() |
一句话记忆:pre 是"渲染前先把派生状态算清楚",post 是"等 DOM 稳定后再做副作用"。
14.8.2 pre 队列从哪来
三个来源,全部通过 queueJob 进入主队列:
-
watch的默认 flush(pre)。apiWatch.ts:207-217的 scheduler:} else if (flush !== 'sync') { // default: 'pre' isPre = true baseWatchOptions.scheduler = (job, isFirstRun) => { if (isFirstRun) { job() } else { queueJob(job) } } }注意
isFirstRun分支:首次执行是同步的(job()直接调用),之后的触发才入队。这保证了watch的初始值回调在"依赖已经就绪"时立刻执行,不需要等一个 tick。 -
augmentJob打标。apiWatch.ts:219-232:baseWatchOptions.augmentJob = (job: SchedulerJob) => { if (cb) { job.flags! |= SchedulerJobFlags.ALLOW_RECURSE } if (isPre) { job.flags! |= SchedulerJobFlags.PRE if (instance) { job.id = instance.uid job.i = instance } } }这里正是
PRE与ALLOW_RECURSE两个标志的写入点。ALLOW_RECURSE只在有cb时设置——因为无cb的watch(source)(即watchEffect)回调本身也参与依赖收集,如果允许自触发会真的死循环;而有cb的watch回调不收集依赖(reactivity/watch.ts里 job 执行时会 pauseTracking),自触发多半是用户有意为之(源码注释 #1727)。 -
props 写入触发的 pre watcher。子组件的 pre watcher 依赖
props.x;父组件更新写 props(updateProps)时触发它,queueJob入队。这类 job 的id= 子组件 uid,因此会被flushPreFlushCbs(instance)在子组件渲染前精确排空(14.7.6)。
14.8.3 flushPreFlushCbs 逐行
export function flushPreFlushCbs(
instance?: ComponentInternalInstance,
minUid?: number,
seen?: CountMap,
i: number = flushIndex + 1, // skip the current job
): void {
if (__DEV__) {
seen = seen || new Map()
}
for (; i < queue.length; i++) {
const cb = queue[i]
if (cb && cb.flags! & SchedulerJobFlags.PRE) {
if (
(instance && cb.id !== instance.uid) ||
(minUid !== undefined && !(cb.id! >= minUid))
) {
continue
}
if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
continue
}
queue.splice(i, 1)
// the job may queue a job with a smaller id ahead of this index. A job
// of the same instance can't, so no rescan is needed for that filter.
i = instance ? i - 1 : flushIndex
if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
cb.flags! &= ~SchedulerJobFlags.QUEUED
}
cb()
if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
cb.flags! &= ~SchedulerJobFlags.QUEUED
}
}
}
}
(scheduler.ts:148-184)
逐步解读:
-
默认参数
i = flushIndex + 1:"跳过当前正在执行的任务"。如果调用者是updateComponentPreRender(正在执行某个组件 job,flushIndex指向它),那从flushIndex + 1开始扫描才不会扫到自己。 -
筛选条件:只处理带
PRE的 job;instance给定时只处理cb.id === instance.uid的;minUid给定时只处理cb.id >= minUid的。 -
queue.splice(i, 1):把这个 pre job 从主队列移走再执行。为什么移走?因为它马上要执行了,如果留在队列里,主循环稍后扫到它会发现它QUEUED已被清(因为执行前清了位)而重复执行。移走是最干脆的去重。注意这里是真正的数组删除(O(n)),但 pre job 数量少,成本可忽略。 -
i = instance ? i - 1 : flushIndex:这是本函数最精妙的一行,配合源码注释理解:// the job may queue a job with a smaller id ahead of this index. A job // of the same instance can't, so no rescan is needed for that filter.两种情形:
- 按
instance过滤(i = i - 1):只需回退一格。因为splice把queue[i]删掉后,原来i+1处的元素前移到了i;for循环的i++会让它跳到i+1,从而漏掉这个刚前移的元素。回退一格再i++,正好停在i,重新检查它。为什么不整个重扫?因为"同一个 instance 的 job 不可能插到当前 index 之前"(同 instance 的 job id 相同,插入点一定 ≥ 当前位置)。 - 不按 instance 过滤(
i = flushIndex):必须从flushIndex重新扫描。因为被执行的 cb 可能在前面插入了一个 id 更小的 pre job(例如它触发了一个更早创建的组件),这个新 job 的插入位置可能落在[flushIndex+1, i]区间内——不重扫就会漏掉它。这是一个保守但正确的选择,源码注释明确写了"a job may queue a job with a smaller id ahead of this index"。
- 按
-
QUEUED 清位:与主循环完全一致的"执行前/执行后"策略(14.2.6)。
一个具体例子演示 i = instance ? i - 1 : flushIndex 的必要性:
初始:queue = [ J(0)执行中, J(1,PRE), J(2), J(1,PRE), J(3) ]
↑ ↑
flushIndex=0 时,i=1 第二个同 id 的 PRE
i=1 时:是 PRE 且 id 匹配 → splice(1,1) → queue 变为 [J(0), J(2), J(1,PRE), J(3)] → i = 1 - 1 = 0 → i++ → i=1 → 此时 queue[1] 是 J(2)(非 PRE,continue)→ i=2 → queue[2] 是第二个 J(1,PRE),命中。若不回退,splice 后 i++ 到 2,queue[2] 恰好还是那个 PRE... 这里碰巧也对(因为中间有个非 PRE 挡着)。真正会出错的场景是连续两个 PRE job:
queue = [ J(0), J(1,PRE), J(1,PRE), J(2) ]
splice(1,1) → [J(0), J(1,PRE), J(2)](第二个 PRE 前移到 index 1)。若不回退,i++ → i=2 → queue[2]=J(2),第二个 PRE job 被永久跳过——它不会在子组件渲染前执行,状态一致性被破坏。回退一格后 i=1,正好命中它。
14.8.4 post 队列从哪来
| 来源 | 源码位置 | 形态 |
|---|---|---|
mounted / updated / unmounted / activated 等钩子 | 渲染器用 queuePostRenderEffect(u, suspense) 排出 | 数组(钩子数组),queuePostFlushCb 数组分支逐项展开 |
watch(..., { flush: 'post' }) | apiWatch.ts:200-206 scheduler 调 queuePostRenderEffect(job, instance && instance.suspense) | 单个函数 |
| 模板 ref 赋值 | rendererTemplateRef.ts:192-198,job.id = -1 | 单个函数,id = -1 使其插到当前 post 游标之后 |
| Suspense 的 effects | Suspense.ts:585/647/950,queuePostFlushCb(effects) | 数组 |
| 异步组件补更新 | renderer.ts:1518 queuePostRenderEffect(() => update(), parentSuspense) | 单个函数 |
其中模板 ref 的 job.id = -1 与 queuePostFlushCb 里的那个特殊分支呼应:
export function queuePostFlushCb(cb: SchedulerJobs): void {
if (!isArray(cb)) {
if (activePostFlushCbs && cb.id === -1) {
activePostFlushCbs.splice(postFlushIndex + 1, 0, cb)
} else if (!(cb.flags! & SchedulerJobFlags.QUEUED)) {
pendingPostFlushCbs.push(cb)
cb.flags! |= SchedulerJobFlags.QUEUED
}
} else {
for (let i = 0; i < cb.length; i++) {
pendingPostFlushCbs.push(cb[i])
}
}
queueFlush()
}
(scheduler.ts:126-146)
id === -1 且 activePostFlushCbs 非 null 时,直接把 cb 插到 postFlushIndex + 1(紧接着当前正在执行的 cb 之后)。语义是:"ref 赋值必须紧跟触发它的那次组件更新之后,不能被塞到整个 post 批次的末尾。"否则若一个组件的 updated 钩子往队列里塞了大量 post cb,ref 赋值会被推迟到很远,读取 ref 的时机就可能拿到别的组件的中间态。
数组分支的 for 循环注释(scheduler.ts:135-140)解释了两个设计决定:
- 跳过去重:数组形态必然是"组件生命周期钩子",只能由已经去重的 job 触发;
- 不用
push(...cb)展开:数组可能极大(如 Suspense 缓冲的大量 effects),展开会溢出调用栈(#15142)。这个 issue 编号值得记住——它是"push(...arr)在超大数组上爆栈"的经典教训。
14.8.5 flush: 'sync':第三种模式
apiWatch.ts:182 的分支:if (flush === 'sync') 时不设置 scheduler,于是 effect.scheduler 为空,触发时走 trigger() 的 else 分支 → runIfDirty()(effect.ts:209-214)——立即同步执行。
| flush 值 | scheduler | 时机 | 是否合并 |
|---|---|---|---|
'pre'(默认) | queueJob(job)(首次同步) | 组件渲染前 | 是 |
'post' | queuePostRenderEffect(job, suspense) | 整棵树渲染后 | 是 |
'sync' | 无(null) | 写状态的当下 | 否 |
flush: 'sync' 的代价正是 14.1 描述的全部灾难:每次写都触发、父子顺序无保证、无递归守护(seen 表只在调度器的三个 flush 函数里维护,sync 路径不经过它们)。它之所以存在,是因为极少数场景需要"写入即刻生效"(例如需要在 effect 内部立刻读到更新后的值),但默认值绝不是它。
14.8.6 为什么默认是 pre 而不是 post
假设默认改成 post:watch(() => state.x, cb) 的回调会被推迟到"整棵树渲染之后"。于是:
- 如果
cb里写了state.y,而某个组件的渲染依赖state.y——这个组件这一轮已经渲染完了(读到的是旧y),必须等下一个 tick 才能看到新y。用户会观察到"一次点击导致两帧渲染"。 - pre 的语义恰恰相反:先执行所有派生的 watcher,把它们的写入合并进本次渲染。于是"一次点击一帧"成为常态。
post 更适合"我需要读 DOM"的场景(updated、ref),而 pre 更适合"我在算派生状态"的场景。默认选 pre,是因为绝大多数 watch 都在算派生状态(校验、格式化、同步到后端前的本地状态等)。post 只需显式声明。
14.9 与响应式批处理的协作:两层批处理如何衔接
Vue 的"合并更新"其实是由两层独立批处理协同完成的:@vue/reactivity 内部的 startBatch/endBatch(写状态时的合并)与 @vue/runtime-core 的微任务 flush(渲染的合并)。这两层常被混为一谈,但它们的职责边界完全不同。
14.9.1 两层批处理的分工
| 层 | 位置 | 合并的对象 | 何时结束 | 合并产出 |
|---|---|---|---|---|
| 第 1 层:响应式批处理 | reactivity/src/effect.ts:251-310 | "哪些 effect 被通知了" | 最外层 endBatch(batchDepth 归零) | 一串待 trigger() 的 subscriber |
| 第 2 层:调度器批处理 | runtime-core/src/scheduler.ts | "哪些任务要在本 tick 执行" | 微任务里的 flushJobs | 一次有序的渲染 + 一次 post 批 |
理解这一点很关键:第 1 层不产生 DOM 更新,它只把"被通知"的 effect 收集起来,在批次结束时逐个 trigger();而 trigger() 对组件渲染 effect 而言,只是 scheduler() → queueJob(job),把任务交给第 2 层。真正决定"何时 patch DOM"的是第 2 层。
14.9.2 batch / startBatch / endBatch 源码
let batchDepth = 0
let batchedSub: Subscriber | undefined
let batchedComputed: Subscriber | undefined
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
return
}
sub.next = batchedSub
batchedSub = sub
}
export function startBatch(): void {
batchDepth++
}
export function endBatch(): void {
if (--batchDepth > 0) {
return
}
if (batchedComputed) {
let e: Subscriber | undefined = batchedComputed
batchedComputed = undefined
while (e) {
const next: Subscriber | undefined = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
e = next
}
}
let error: unknown
while (batchedSub) {
let e: Subscriber | undefined = batchedSub
batchedSub = undefined
while (e) {
const next: Subscriber | undefined = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
if (e.flags & EffectFlags.ACTIVE) {
try {
// ACTIVE flag is effect-only
;(e as ReactiveEffect).trigger()
} catch (err) {
if (!error) error = err
}
}
e = next
}
}
if (error) throw error
}
(effect.ts:247-310)
逐点解读:
-
两个链表而非两个数组。
batchedSub/batchedComputed是通过sub.next串起来的单链表,O(1) 头部插入,且复用Subscriber已有的next字段(本用于 computed 依赖链),零额外内存——批次内可能被通知成千上万个订阅者,链表比新建数组更省。 -
NOTIFIED去重。batch前置NOTIFIED,但真正防重复入链的是notify()里的if (!(this.flags & NOTIFIED)) batch(this)(effect.ts:157-159):同一 effect 一个批次内只被通知一次——即使它依赖的 10 个 dep 全变了,也只trigger()一次。这是第 1 层的核心承诺。 -
batchDepth支持嵌套。内层endBatch只递减计数,只有最外层(归零)才排出——对应"watch回调里写状态 → 又触发内层 batch"的场景,真正的通知推迟到最外层结束。 -
computed 与 effect 分开处理。computed 被通知时不立即求值(只清 NOTIFIED、丢弃链表),因为它是惰性的;触发其下游的是
dep.ts:194-199的特判:if (link.sub.notify()) { // if notify() returns `true`, this is a computed. Also call notify // on its dep - it's called here instead of inside computed's notify // in order to reduce call stack depth. ;(link.sub as ComputedRefImpl).dep.notify() }computed 的
dep.notify()再通知自己的订阅者;注释说明"放在这里而非 computed 的 notify 内部,是为了减小调用栈深度"。 -
错误收集后统一抛出。
try/catch只记第一个错误、继续处理剩余 effect,最后throw——保证"一个 effect 抛错不阻止同批次其它 effect 被通知",与flushJobs的"单任务错误不中断 flush"同一哲学。 -
ACTIVE守卫。已stop()的 effect(ACTIVE已清)被跳过,是响应式层的自我防护(与调度器DISPOSED互补)。
14.9.3 通知顺序:为什么"逆序入链、正序执行"
dep.ts:193-200 的通知循环:
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) { ... }
}
this.subs 是链表尾(最近添加的 subscriber),prevSub 往前回溯到头(最早添加的)。所以循环是"从新到旧"遍历;而 batch 是头部插入(sub.next = batchedSub; batchedSub = sub),于是:
遍历顺序(从新到旧):s_tail, s_{n-1}, ..., s_head
每次头部插入后的链表:s_head, ..., s_{n-1}, s_tail
最终链表再从头遍历执行 trigger(),得到**从旧到新(原始插入顺序)**的执行顺序。源码注释(dep.ts:177-179)明确写了这一点:"subs are notified and batched in reverse-order and then invoked in original order at the end of the batch, but onTrigger hooks should be invoked in original order here."
为什么要"逆序入链"?这是一个与"依赖列表复用"配合的实现细节:反向遍历 prevSub 避免在遍历中修改 nextSub(新增订阅者会改动链表),同时头部插入恰好把顺序"倒回来"。对外表现是确定性的先进先出,用户可以依赖它。
14.9.4 一次写状态的完整调用链
flowchart TB
W["state.value++"] --> D["Dep.set → setValue → dep.trigger()"]
D --> V["dep.version++<br/>globalVersion++"]
V --> N["dep.notify()"]
N --> SB["startBatch() → batchDepth++"]
SB --> L1["遍历 subs(逆序)→ sub.notify()"]
L1 --> B1{"NOTIFIED 已置?"}
B1 -->|否| BT["batch(sub):置 NOTIFIED + 头部插入 batchedSub"]
B1 -->|是| SKIP["跳过(同批次去重)"]
BT --> EB["finally: endBatch() → batchDepth--"]
EB --> Q{"batchDepth == 0?"}
Q -->|否| RET["返回(交给更外层)"]
Q -->|是| TR["遍历 batchedSub:清 NOTIFIED,ACTIVE → trigger()"]
TR --> SCH["组件 effect 有 scheduler → queueJob(job)"]
SCH --> QF["queueFlush → 排 flushJobs 微任务"]
关键观察:queueJob 是在 endBatch 的执行阶段被调用的(trigger() → scheduler()),而不是在 Dep.set 的当下。这意味着"一次状态写"会经历"收集通知 → 批次结束 → 入队"三段,中间还有一层 try/finally(dep.ts:175-203)。如果某个 subscriber 的 trigger() 抛错,finally 里的 endBatch 仍会执行,保证批次正确收尾。
14.9.5 两层批处理的边界在哪
一个必须澄清的边界:startBatch/endBatch 的嵌套边界 ≠ tick 边界。考虑:
state.a++ // dep.a.notify → startBatch ... endBatch → queueJob(A)
state.b++ // dep.b.notify → startBatch ... endBatch → queueJob(B)
两次写、两个独立的 endBatch,但因为 queueJob 都排到同一个 currentFlushPromise 上,第 2 层把它们合并成一次 flush。反过来:
const stop = effect(() => {
state.a++
state.b++
})
在 effect 内部连续两次写,每次写各自触发一次 startBatch/endBatch——第 1 层不会跨两次写合并(除非用手动的 batch() 包裹)。但如果这两个写都通知了同一个组件 effect,第 2 层仍然只渲染一次。
所以两层的合并语义是"正交"的:第 1 层管"同一批次内哪些 effect 被通知"(防止重复通知同一个 effect),第 2 层管"本 tick 内哪些任务待执行"(防止重复渲染同一个组件)。
14.9.6 与 React 调度器、scheduler 包的对比
把 Vue 的调度器与 React 的 scheduler 包(react-scheduler)并排看,差异一目了然:
| 维度 | Vue 3 调度器 | React 18(及 scheduler 包) |
|---|---|---|
| 批处理载体 | 微任务(Promise.resolve().then) | 宏任务/MessageChannel + 时间切片 |
| 是否分优先级 | 否,所有任务同权、按 uid 排序 | 是,ImmediatePriority/UserBlockingPriority/NormalPriority/LowPriority/IdlePriority 五档 lane |
| 是否可中断 | 否,flushJobs 一旦开始跑到队列清空 | 是,可在帧预算(5ms)耗尽时 shouldYield 让出 |
| 排序依据 | 组件 uid(拓扑序) | lane 优先级 + 插入顺序 |
| "渲染自己改依赖"的守护 | RECURSION_LIMIT = 100 + ALLOW_RECURSE 白名单 | updateDepth/NESTED_UPDATE_LIMIT = 50 + 错误边界 |
| 数据结构 | 两个数组 + 三个游标 + 一个 Promise | 最小堆(taskQueue)+ expirationTimes + Task 对象 |
| 大小 | 318 行 | scheduler 包约 700+ 行,外加 React 内部的 lane 逻辑 |
核心哲学差异:React 认为"让出主线程"必须做(组件更新可能很重、需保持响应性),故引入优先级、时间切片、lane 模型;Vue 认为"组件更新足够轻量",同步、不可中断的 flush 就够了,用复杂度换"可预测性"。收益是 nextTick 语义极简——"flush 完成即 DOM 最新",没有"被打断的半成品树";代价是超大树重渲染会阻塞主线程,Vue 的对策是把成本前移到编译期(patchFlag、静态提升、Block 树,第 6–12 章)。
14.9.7 为什么必须有两层
第 1 层把"一组关联写入"包装成原子通知:如 arr.push(1) 会同时写 index 与 length 两个 dep,reactivity/src/arrayInstrumentations.ts:407-409 用 startBatch/endBatch 包住整个 push,避免下游 effect 看到"元素已加但 length 未变"的中间态;若没有第 1 层,a/b/c 三个 ref 各自写一次会通知下游 computed 3 次,有它则同一批次内下游只被通知一次、且发生在"三个都写完之后"。第 2 层解决另一层问题:跨 effect 的合并——多个被通知的 effect(组件渲染、watcher)被折算成"每个组件最多一次渲染"并给出确定顺序。两层各司其职,缺一不可。
14.10 createApp 与应用上下文:调度器运行的"全局环境"
调度器本身是无状态的模块级单例,但它服务的对象——组件任务——需要一个"全局环境"来决定"用哪套组件注册表、哪个 errorHandler、哪份 provides"。这个环境就是 AppContext,由 createApp 建立。本节把 createApp 的每一行拆开,看它如何为调度器提供上下文。
14.10.1 createAppAPI:闭包里的渲染器
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
hydrate?: RootHydrateFunction,
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
if (!isFunction(rootComponent)) {
rootComponent = extend({}, rootComponent)
}
if (rootProps != null && !isObject(rootProps)) {
__DEV__ && warn(`root props passed to app.mount() must be an object.`)
rootProps = null
}
const context = createAppContext()
const installedPlugins = new WeakSet()
const pluginCleanupFns: Array<() => any> = []
let isMounted = false
const app: App = (context.app = { ... })
...
return app
}
}
(apiCreateApp.ts:253-273、481-486)
三点:
createApp是一个工厂函数,render是它捕获的闭包变量。所以createApp与具体平台(DOM / 自定义渲染器 / SSR)解耦——renderer.ts:2512里createApp: createAppAPI(render, hydrate)把渲染器注入进来。rootComponent若为对象则被extend({}, ...)拷贝一份(apiCreateApp.ts:258-260)。这是防止"用户复用同一个组件选项对象创建多个 app"时互相污染。函数组件(isFunction)不拷贝,因为函数本身就是"新实例"。- 每个
createApp调用产生一份独立的context、installedPlugins、pluginCleanupFns、isMounted(apiCreateApp.ts:267-271)。这是"多次mount隔离"的物理基础——隔离不是靠运行时判断,而是靠每个 app 有自己的一套闭包变量。
14.10.2 AppContext 的字段全景
export function createAppContext(): AppContext {
return {
app: null as any,
config: {
isNativeTag: NO,
performance: false,
globalProperties: {},
optionMergeStrategies: {},
errorHandler: undefined,
warnHandler: undefined,
compilerOptions: {},
},
mixins: [],
components: {},
directives: {},
provides: Object.create(null),
optionsCache: new WeakMap(),
propsCache: new WeakMap(),
emitsCache: new WeakMap(),
}
}
(apiCreateApp.ts:224-244)
| 字段 | 类型 | 作用 | 组件如何使用 |
|---|---|---|---|
app | App | 反向引用(devtools 用) | 被 inject 的 app-level 兜底间接使用 |
config | AppConfig | 全局配置(错误处理、性能、编译器选项等) | 每个组件 instance.appContext.config |
mixins | ComponentOptions[] | 全局混入 | applyOptions 时合并 |
components | Record<string, Component> | 全局组件注册表 | 解析模板里的组件名时查这里 |
directives | Record<string, Directive> | 全局指令注册表 | 解析指令时查这里 |
provides | Record<string | symbol, any> | 应用级 provide 的根 | 根组件的 instance.provides 以它为原型 |
optionsCache | WeakMap | 合并后的组件选项缓存 | 每个 app 独立一份(注释说明:因为 mixins/optionMergeStrategies 会影响合并结果) |
propsCache | WeakMap | 归一化 props 缓存 | 同上 |
emitsCache | WeakMap | 归一化 emits 缓存 | 同上 |
reload | () => void | HMR 专用 | dev 下 mount 时设置 |
filters | Record<string, Function> | v2 compat | 仅 __COMPAT__ |
三处 WeakMap 缓存值得单独强调:它们是每个 app 一份而非全局一份。原因是 app.mixin() 与 config.optionMergeStrategies 会改变"同一个组件选项对象最终合并成什么",所以缓存键是"组件选项对象 + app 身份"。用 WeakMap 是防止组件定义被缓存后无法回收。
14.10.3 app 上的方法:每个都是一层薄封装
use(plugin: Plugin, ...options: any[]) {
if (installedPlugins.has(plugin)) {
__DEV__ && warn(`Plugin has already been applied to target app.`)
} else if (plugin && isFunction(plugin.install)) {
installedPlugins.add(plugin)
plugin.install(app, ...options)
} else if (isFunction(plugin)) {
installedPlugins.add(plugin)
plugin(app, ...options)
} else if (__DEV__) { warn(...) }
return app
}
(apiCreateApp.ts:295-311)
| 方法 | 核心职责 | 幂等性保护 | 关键副作用 |
|---|---|---|---|
use(plugin) | 安装插件 | installedPlugins(WeakSet) | 插件内部通常调 app.component/provide/directive |
mixin(mixin) | 全局混入 | context.mixins.includes(mixin) | 影响后续所有组件的选项合并 |
component(name, comp) | 注册全局组件 | dev 下重复注册警告 | 写入 context.components |
directive(name, dir) | 注册全局指令 | dev 下重复注册警告 | 写入 context.directives |
provide(key, value) | 应用级 provide | in 检查 + 覆盖警告 | 写入 context.provides |
runWithContext(fn) | 临时激活 app 上下文 | — | 让 inject() 能在组件外使用(14.11) |
onUnmount(cb) | 注册卸载清理 | — | 推入 pluginCleanupFns |
mixin 与 component 都受 feature flag 控制:mixin 在 __FEATURE_OPTIONS_API__ 关闭时给出"Mixins are only available in builds supporting Options API"的警告(apiCreateApp.ts:313-327)——这是"让出了名会增大体积的 Options API 在纯组合式构建中被摇掉"的一部分。
14.10.4 mount:把 appContext 钉进根 vnode
mount(rootContainer, isHydrate?, namespace?) {
if (!isMounted) {
// #5571
if (__DEV__ && (rootContainer as any).__vue_app__) {
warn(`There is already an app instance mounted on the host container...`)
}
const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
// store app context on the root VNode.
// this will be set on the root instance on initial mount.
vnode.appContext = context
...
if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, namespace)
}
isMounted = true
app._container = rootContainer
// for devtools and telemetry
;(rootContainer as any).__vue_app__ = app
...
return getComponentPublicInstance(vnode.component!)
} else if (__DEV__) {
warn(`App has already been mounted...`)
}
}
(apiCreateApp.ts:358-419)
四个关键动作:
vnode.appContext = context——把 app 上下文挂到根 vnode上。这是整条"上下文继承链"的起点:createComponentInstance里const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext(component.ts:637-639),根组件没有 parent,于是从vnode.appContext取;后代组件则从 parent 继承。这就是为什么app.mixin/app.config能在任何深度的组件里生效。render(vnode, rootContainer, namespace)——渲染器(第 11 章)的入口。注意render是createAppAPI的闭包参数,所以这里调用的是"平台渲染器",不是通用的 render 函数。isMounted = true+__vue_app__标记——isMounted防重复挂载;__vue_app__是给"同一个容器被两次createApp().mount()"这个常见错误提供明确警告(issue #5571)。这个警告文案直接写在源码里,堪称"最实用的开发期提示之一"。- 返回
getComponentPublicInstance(vnode.component!)——app.mount()返回根组件的公共实例(带$前缀那套),不是内部实例。
mount 与调度器的交点:render(vnode, container) 内部会走 mountComponent → setupRenderEffect → update()(首次挂载是同步的,renderer.ts:1650 的 update()),过程中产生的 post cb(mounted 钩子、post watcher)会进入 pendingPostFlushCbs,并由某个 flushOnAppMount/flushJobs 排出。首屏挂载的"同步渲染 + 异步 post"这个组合是调度器第一次登场的时刻。
14.10.5 多次 mount 的隔离
components/directives/config/provides/三份 WeakMap 缓存都是每个 app 一份,所以两个 app 互不串味(否则 A 注册的组件会被 B 误用、A 的 errorHandler 会捕获 B 的错误)。而不隔离的是:组件 uid 计数器(component.ts 的 uid++)与 app uid 计数器(apiCreateApp.ts:251)跨 app 共享——这是刻意的,uid 只用于排序与标识,共享反而保证"两个 app 的组件混在同一队列时不冲突";调度器队列本身也是共享的,这正是它必须是模块级单例而非每 app 一份的原因。实践上要"重新挂载"同一个 app,应把它包进工厂函数 const createMyApp = () => createApp(App),否则第二次 app.mount() 会被 isMounted 拦住并警告 "App has already been mounted"。
14.10.6 unmount 与 onUnmount
unmount() {
if (isMounted) {
callWithAsyncErrorHandling(
pluginCleanupFns,
app._instance,
ErrorCodes.APP_UNMOUNT_CLEANUP,
)
render(null, app._container)
...
delete app._container.__vue_app__
} else if (__DEV__) {
warn(`Cannot unmount an app that is not mounted.`)
}
}
(apiCreateApp.ts:431-447)
要点:
pluginCleanupFns在render(null, ...)之前执行,且经过callWithAsyncErrorHandling(支持异步清理函数,errorHandling.ts:83-110),错误码是APP_UNMOUNT_CLEANUP(errorHandling.ts:65,字符串'app unmount cleanup function')。render(null, container)触发整棵树的卸载,过程中会给每个组件的 job 打DISPOSED(14.7.7),并排出unmounted钩子到 post 队列。isMounted不复位——意味着unmount()不能"卸载后再挂载",这也是"用工厂函数创建新 app"建议的由来。
14.10.7 app.config 与调度器的关系
config 的字段里,与调度器/错误链路直接相关的有三个:
| 字段 | 与调度器的关系 |
|---|---|
errorHandler | handleError 在"没有 errorCaptured 拦截"时的兜底(errorHandling.ts:143-152),也用于调度器递归超限的报错(scheduler.ts:334) |
warnHandler | warn 的接管者(warning.ts:44-63),调度器/渲染器的警告都会经过它 |
performance | 影响渲染器是否打 startMeasure/endMeasure(性能标记) |
config 用 getter/setter 实现(apiCreateApp.ts:283-293):读返回 context.config,写则在 dev 下报警告"app.config cannot be replaced"——这防止用户用 app.config = {...} 整体替换而丢掉默认字段。
14.10.8 appContext 的继承:一行代码串起整棵组件树
// component.ts:637-639
const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext
根组件从 vnode.appContext(mount 时设置)取,非根组件从 parent.appContext 取——一级引用拷贝,O(1),无需遍历。这就是"app 级配置(mixin/config/provides)如何触达任意深度组件"的全部答案。emptyAppContext 是没有 app 上下文时(手动 render、SSR)的兜底。appContext 同时决定了 config.globalProperties 的可用性(即第 13 章那张查找优先级表的组成部分)。
14.11 provide / inject:原型链式查找与 app 级注入
provide/inject 表面上与调度器无关,但它是"组件树上下文"的核心机制,且与 app.runWithContext、app.provide 强耦合。它只用一行 Object.create 就实现了 O(深度) 的查找,是全书最精巧的数据结构技巧之一。
14.11.1 provide 源码
export function provide<T, K = InjectionKey<T> | string | number>(
key: K,
value: K extends InjectionKey<infer V> ? V : T,
): void {
if (__DEV__) {
if (!currentInstance || currentInstance.isMounted) {
warn(`provide() can only be used inside setup().`)
}
}
if (currentInstance) {
let provides = currentInstance.provides
// by default an instance inherits its parent's provides object
// but when it needs to provide values of its own, it creates its
// own provides object using parent provides object as prototype.
// this way in `inject` we can simply look up injections from direct
// parent and let the prototype chain do the work.
const parentProvides =
currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
provides = currentInstance.provides = Object.create(parentProvides)
}
// TS doesn't allow symbol as index type
provides[key as string] = value
}
}
(apiInject.ts:10-34)
14.11.2 原型链机制:一行 Object.create 的 O(深度) 查找
关键在 if (parentProvides === provides)——写时复制(copy-on-write):
- 组件创建时
provides直接引用父组件的provides对象(component.ts里provides: parent ? parent.provides : Object.create(appContext.provides))。此时provides === parentProvides。 - 当组件第一次调用
provide()时,"自己"与"父"引用相同,于是Object.create(parentProvides)造一个以父 provides 为原型的新对象,并替换currentInstance.provides。 - 之后再
provide(),两者已不同(parentProvides !== provides),不再重建,直接往自己的对象上写。
用一张图表示组件树 A → B → C,其中只有 B 和 C 调用了 provide:
A.provides ─┐
│ parentProvides === provides → 与父同一个对象
▼
(根) Object.create(appContext.provides)
▲ 原型
│
B.provides ──┴─ Object.create(A.provides) ← B 首次 provide 时创建
key_b ─────► 写入 B.provides(自有属性)
▲ 原型
│
C.provides ──┴─ Object.create(B.provides) ← C 首次 provide 时创建
key_c ─────► 写入 C.provides(自有属性)
inject(key_b) 在 C 里 → C.provides[key_b] 沿原型链命中 B.provides(1 跳)
inject(key_a) 在 C 里 → 2 跳 A
inject(app_key) 在 C 里 → 3 跳 appContext.provides
查找成本是 O(深度)(沿原型链跳),而不是 O(祖先数 × 每祖先属性数)。这正是源码注释强调的价值:"this way in inject we can simply look up injections from direct parent and let the prototype chain do the work"。
常见坑:因为 provides 是"引用共享 + 写时复制",如果某个祖先组件从不 provide,它的 provides 就是"直接引用父的"——多个兄弟组件可能共享同一个 provides 对象。此时若 A 与 B 是兄弟且都从根继承,B 调 provide 时会 Object.create(A_shared_provides),不会影响 A。这一点让"provide 的可见性是子树"这一语义得以正确实现:provide 只影响"自己与后代",不影响兄弟。
14.11.3 inject 源码与查找优先级
export function inject(
key: InjectionKey<any> | string,
defaultValue?: unknown,
treatDefaultAsFactory = false,
) {
// fallback to `currentRenderingInstance` so that this can be called in
// a functional component
const instance = getCurrentInstance()
// also support looking up from app-level provides w/ `app.runWithContext()`
if (instance || currentApp) {
// #2400 to support `app.use` plugins, fallback to appContext's `provides` if the instance is at root
// #11488, in a nested createApp, prioritize using the provides from currentApp
// #13212, for custom elements we must get injected values from its appContext
let provides = currentApp
? currentApp._context.provides
: instance
? instance.parent == null || instance.ce
? instance.vnode.appContext && instance.vnode.appContext.provides
: instance.parent.provides
: undefined
if (provides && (key as string | symbol) in provides) {
return provides[key as string]
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance && instance.proxy)
: defaultValue
} else if (__DEV__) {
warn(`injection "${String(key)}" not found.`)
}
} else if (__DEV__) {
warn(`inject() can only be used inside setup() or functional components.`)
}
}
(apiInject.ts:47-85)
查找源的优先级(按源码三元表达式的嵌套顺序):
flowchart TB
S["inject(key)"] --> C1{"currentApp 非空?<br/>(app.runWithContext 中)"}
C1 -->|是| P1["currentApp._context.provides"]
C1 -->|否| C2{"有 currentInstance?"}
C2 -->|是| C3{"instance.parent == null<br/>或 instance.ce?"}
C3 -->|是(根组件/自定义元素)| P2["instance.vnode.appContext.provides"]
C3 -->|否| P3["instance.parent.provides<br/>(沿原型链查找)"]
P1 --> H{"key in provides?"}
P2 --> H
P3 --> H
H -->|命中| R["返回 provides[key]"]
H -->|未命中| D{"arguments.length > 1 ?"}
D -->|是| D1["默认值 / 工厂函数"]
D -->|否| W["dev: warn 'not found'"]
三个特殊规则的来源:
currentApp优先(issue #11488):在嵌套createApp场景里,内层 app 的 provides 应该优先于"当前实例的祖先链",因为app.runWithContext明确表示"以这个 app 的上下文执行"。- 根组件走
vnode.appContext.provides(issue #2400):根组件的instance.provides在创建时就是Object.create(appContext.provides),所以理论上instance.parent.provides不适用(没有 parent)。走vnode.appContext.provides是为了支持"组件挂载前(app.use插件阶段)就能拿到 app 级 provide"。 - 自定义元素的
instance.ce(issue #13212):自定义元素可能继承宿主元素的provides,所以必须回到它自己的appContext去找,避免拿到"宿主页面"的错误注入。
14.11.4 默认值与工厂函数
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance && instance.proxy)
: defaultValue
}
三个语义要点:
- 用
arguments.length > 1而非defaultValue !== undefined判断"是否给了默认值"。这允许inject(key, undefined)明确表示"默认值就是 undefined",与"没给默认值"区分开(后者会走到warn)。 treatDefaultAsFactory === true时,若defaultValue是函数,则调用它(this绑定到instance.proxy)。这解决"默认值是一个对象/数组时,多个组件共享同一引用"的问题——工厂函数每次返回新对象。inject的第三个参数默认false,所以"直接传函数作为默认值"时它不会被调用,而是作为值返回。这与 Vue 2 的inject默认值语义不同,是一个刻意保持"显式"的设计。
14.11.5 app 级 provide 的合并
app.provide(key, value) 直接写 context.provides[key] = value(apiCreateApp.ts:465),带 dev 下的重复键警告(区分 hasOwn 与"从父继承"两种情形,apiCreateApp.ts:450-463)。这份 context.provides 是整棵树的原型链根:
- 根组件:
instance.provides = Object.create(appContext.provides)(component.ts); - 非根组件:
instance.provides = parent.provides(引用共享)。
于是 inject 沿链向上查找最终会到达 appContext.provides。这也解释了为什么"插件里 app.provide(...) 之后,任何组件都能 inject 到"——原型链把 app 级和组件级无缝连成一条链。
app.runWithContext(fn) 则是"临时把 currentApp 切到某个 app"(apiCreateApp.ts:470-478):
runWithContext(fn) {
const lastApp = currentApp
currentApp = app
try {
return fn()
} finally {
currentApp = lastApp
}
}
currentApp 是一个模块级变量(apiCreateApp.ts:493)。inject 与 hasInjectionContext()(apiInject.ts:92-94)都读它,从而支持"在组件外使用 inject"(例如在路由守卫、Pinia store 里)。
14.11.6 Symbol key:为什么推荐用 InjectionKey<T>
export type InjectionKey<T> = symbol & InjectionConstraint<T>——用 Symbol 作 key:天生唯一(避免两个库同用 "router" 冲突)、让 provide/inject 共享同一个 T(编译期校验值类型)、不可枚举(不进入 Object.keys,对 SSR 序列化友好)。库作者应导出 const myKey: InjectionKey<MyType> = Symbol('myKey');字符串 key 只适合应用内部、不跨包通信的场景。
14.11.7 三个高频陷阱
provide 只能在挂载前的 setup() 里调用(dev 警告条件 !currentInstance || currentInstance.isMounted,apiInject.ts:15),挂载后再 provide 后代看不到;inject 拿到的是快照值,要响应式就 provide(key, ref(...)) 或直接传响应式对象;跨 await 的 setup 中 currentInstance 可能已被清空(apiSetupHelpers.ts:522-573 的 withAsyncContext),所以 provide/inject 必须在第一个 await 之前完成。
14.12 错误处理链路:从 callWithErrorHandling 到 app.config.errorHandler
调度器、生命周期钩子、事件监听——所有"用户代码被框架调用"的位置,都统一走 errorHandling.ts 的三个函数。这条链路值得单独讲,因为它是"为什么我的错误没有冒泡到 try/catch"这个高频困惑的答案。
14.12.1 三个入口
export function callWithErrorHandling(fn, instance, type, args?) {
try {
return args ? fn(...args) : fn()
} catch (err) {
handleError(err, instance, type)
}
}
export function callWithAsyncErrorHandling(fn, instance, type, args?) {
if (isFunction(fn)) {
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => { handleError(err, instance, type) })
}
return res
}
if (isArray(fn)) {
const values = []
for (let i = 0; i < fn.length; i++) {
values.push(callWithAsyncErrorHandling(fn[i], instance, type, args))
}
return values
}
...
}
(errorHandling.ts:70-110)
| 函数 | 处理同步错误 | 处理异步错误 | 数组支持 | 典型调用者 |
|---|---|---|---|---|
callWithErrorHandling | 是(try/catch) | 否 | 否 | 渲染任务(scheduler.ts:297)、app.config.errorHandler 本身(errorHandling.ts:145) |
callWithAsyncErrorHandling | 是(内部调前者) | 是(res.catch) | 是(逐个递归) | 生命周期钩子(apiLifecycle.ts:41)、emits、app.unmount 的清理函数(apiCreateApp.ts:433) |
关键差异在"异步"。callWithErrorHandling 同步返回、不等待 Promise;如果 fn 返回 rejected Promise,错误不会被捕获。callWithAsyncErrorHandling 额外挂一个 res.catch,把异步 rejection 也交给 handleError。
flushJobs 用的是 callWithErrorHandling(scheduler.ts:297-301)——意味着"一个 async 渲染函数(如 async setup 里返回的渲染函数)抛出的 rejection"不会在调度器这一层被捕获,得由更上层(如 Suspense)处理。这是一个容易踩的坑:async setup 的错误由 Suspense 或 errorCaptured 承担,而不是调度器。
14.12.2 handleError:errorCaptured 的向上冒泡
export function handleError(
err: unknown,
instance: ComponentInternalInstance | null | undefined,
type: ErrorTypes,
throwInDev = true,
): void {
const contextVNode = instance ? instance.vnode : null
const { errorHandler, throwUnhandledErrorInProduction } =
(instance && instance.appContext.config) || EMPTY_OBJ
if (instance) {
let cur = instance.parent
const exposedInstance = instance.proxy
const errorInfo = __DEV__
? ErrorTypeStrings[type]
: `https://vuejs.org/error-reference/#runtime-${type}`
while (cur) {
const errorCapturedHooks = cur.ec
if (errorCapturedHooks) {
for (let i = 0; i < errorCapturedHooks.length; i++) {
if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) {
return
}
}
}
cur = cur.parent
}
if (errorHandler) {
pauseTracking()
callWithErrorHandling(errorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [
err, exposedInstance, errorInfo,
])
resetTracking()
return
}
}
logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction)
}
(errorHandling.ts:112-155)
冒泡顺序与终止条件:
flowchart TB
E["错误 err(来自某个 instance)"] --> C1["从 instance.parent 开始向上遍历"]
C1 --> H{"当前祖先有 errorCaptured 钩子?"}
H -->|有| I["依次调用每个 ec 钩子"]
I --> J{"某个钩子返回 false?"}
J -->|是| STOP["停止冒泡(已处理)"]
J -->|否| C1
H -->|无| C1
C1 --> C2{"走到根,仍有 app.config.errorHandler?"}
C2 -->|是| APP["errorHandler(err, exposedInstance, errorInfo)<br/>pauseTracking 包裹"]
C2 -->|否| LOG["logError: dev 抛错 / prod console.error"]
要点:
- 从
instance.parent开始,不包含instance自身。也就是说,组件自己的errorCaptured不捕获"自己内部"的错误——它捕获的是后代的错误。这与直觉一致:"错误边界"是父的职责。 === false才终止。errorCaptured返回false表示"我处理了,别继续冒泡";返回undefined(或其它值)则继续往上传。这是文档里那条"返回 false 阻止进一步传播"的实现。exposedInstance = instance.proxy——传给钩子的是出错组件的公共实例(不是当前遍历到的祖先),所以errorCaptured(err, vm, info)里的vm是"哪个组件炸了"。errorInfo:dev 下是ErrorTypeStrings[type](如'component update'、'scheduler flush'、'watcher getter'),prod 下是一个文档链接https://vuejs.org/error-reference/#runtime-N。用ErrorCodes的数值拼 URL,所以新增错误码必须保持数值稳定(errorHandling.ts:14-17有注释说明 watch 相关错误码被移到 reactivity 包但保持数值不变)。errorHandler用pauseTracking包裹:错误处理期间必须关闭依赖收集,否则errorHandler里读到的响应式数据会被记成"当前正在运行的 effect(通常是渲染 effect)的依赖",导致错误的依赖关系甚至无限循环。这与warn里pauseTracking()(warning.ts:41)是同一动机。
14.12.3 logError:dev 与 prod 的分野
function logError(err, type, contextVNode, throwInDev = true, throwInProd = false) {
if (__DEV__) {
const info = ErrorTypeStrings[type]
if (contextVNode) pushWarningContext(contextVNode)
warn(`Unhandled error${info ? ` during execution of ${info}` : ``}`)
if (contextVNode) popWarningContext()
// crash in dev by default so it's more noticeable
if (throwInDev) {
throw err
} else if (!__TEST__) {
console.error(err)
}
} else if (throwInProd) {
throw err
} else {
// recover in prod to reduce the impact on end-user
console.error(err)
}
}
(errorHandling.ts:157-185)
| 环境 | 默认行为 | 可控项 |
|---|---|---|
| dev | 抛出(让错误冒泡到调用栈,方便定位) | throwInDev = false 时改为 console.error |
| prod(测试环境) | console.error,不抛 | — |
| prod | console.error,不抛 | app.config.throwUnhandledErrorInProduction = true 时抛出 |
三条设计动机:
- dev 默认抛错:源码注释 "crash in dev by default so it's more noticeable"。dev 下"静默吞掉错误"会让 bug 藏得很深;抛出让调用栈直接暴露问题。
- prod 默认恢复:"recover in prod to reduce the impact on end-user"——生产环境下宁可让某个组件渲染失败,也不让整个应用崩溃。
throwUnhandledErrorInProduction(apiCreateApp.ts:159-163):给 SSR 等"希望 error 冒泡到服务端捕获"的场景留出口。SSR 时抛错比"渲染出半成品 HTML"更符合预期。
pushWarningContext(contextVNode) 让 warn 能带上组件调用栈(14.12.4)。
14.12.4 warn 链路:isWarning 重入保护与组件 trace
let isWarning = false
export function warn(msg: string, ...args: any[]): void {
if (isWarning) return
isWarning = true
// avoid props formatting or warn handler tracking deps that might be mutated
// during patch, leading to infinite recursion.
pauseTracking()
const instance = stack.length ? stack[stack.length - 1].component : null
const appWarnHandler = instance && instance.appContext.config.warnHandler
const trace = getComponentTrace()
if (appWarnHandler) {
callWithErrorHandling(appWarnHandler, instance, ErrorCodes.APP_WARN_HANDLER, [...])
} else {
const warnArgs = [`[Vue warn]: ${msg}`, ...args]
if (trace.length && !__TEST__) {
warnArgs.push(`\n`, ...formatTrace(trace))
}
console.warn(...warnArgs)
}
resetTracking()
isWarning = false
}
(warning.ts:33-79)
四个值得注意的点:isWarning 重入闸门——warn 内部可能再次 warn(如 getComponentTrace 格式化 props 时触发 getter),闸门丢弃嵌套调用以避免"警告导致更多警告"雪崩;pauseTracking()——源码注释解释"格式化 props 或 warnHandler 可能触发依赖收集,在 patch 期间收集会造成无限递归",与 handleError 同源;stack 与 getComponentTrace——stack 由 pushWarningContext/popWarningContext 维护(renderer.ts:1301、1620),但 getComponentTrace 不直接用 stack,而是从当前 vnode 沿 component.parent 重建父链(注释:"非根发起的更新中 stack 是残缺的",warning.ts:87-89),连续相同 vnode 折叠成 recurseCount 输出 ... (N recursive calls)——即 "at ... (3 recursive calls)" 的来源;__TEST__ 下不打印 trace(warning.ts:69)。
14.12.5 调度器中的错误码映射表
| 调用点 | ErrorTypes 取值 | dev 字符串 |
|---|---|---|
flushJobs 执行有归属的任务 | ErrorCodes.COMPONENT_UPDATE | 'component update' |
flushJobs 执行无归属的任务 | ErrorCodes.SCHEDULER | 'scheduler flush' |
| 递归超限报错 | ErrorCodes.APP_ERROR_HANDLER | 'app errorHandler' |
app.unmount 的清理函数 | ErrorCodes.APP_UNMOUNT_CLEANUP | 'app unmount cleanup function' |
| 生命周期钩子 | 对应 LifecycleHooks 枚举 | 'mounted hook'、'updated' 等 |
watch 的 getter/回调 | WatchErrorCodes(在 reactivity 包) | 'watcher getter'、'watcher callback' |
warnHandler 本身抛错 | ErrorCodes.APP_WARN_HANDLER | 'app warn handler' |
(来源:errorHandling.ts:34-66)
一个细节:LifecycleHooks.UPDATED 对应的字符串是 'updated'(没有 "hook" 后缀),而其它钩子都带 hook——这是历史遗留,errorHandling.ts:41 原文就是这样。
14.12.6 异步错误的三个层次
| 层次 | 机制 | 是否被 handleError 捕获 |
|---|---|---|
async setup 的 rejection | Suspense 的 asyncDep + handleError(ErrorCodes.SETUP_FUNCTION) | 是(由 Suspense 内部处理) |
| 生命周期钩子返回 Promise 的 rejection | callWithAsyncErrorHandling 的 res.catch | 是 |
普通同步代码里 await 抛错 | 无框架介入,用户自己的 try/catch | 否 |
事件处理器(@click) | callWithAsyncErrorHandling(ErrorCodes.NATIVE_EVENT_HANDLER) | 是(同步与异步都覆盖) |
第四项是很多人不知道的:模板上的 @click 处理器并不是裸函数,而是被 callWithAsyncErrorHandling 包装过的(在 patchEvent / invokeArrayFns 路径下,错误码 NATIVE_EVENT_HANDLER = 5)。所以"事件处理器里抛错"也会走 errorCaptured → app.config.errorHandler。
14.12.7 一个完整例子:从组件内抛错到 errorHandler
const Boom = { setup() { throw new Error('boom in setup') } } // 被 callWithErrorHandling 捕获
const Middle = {
errorCaptured(err, vm, info) {
console.log('captured by Middle:', err.message, '| info =', info)
return true // 非 false → 继续冒泡
},
render: () => h(Boom),
}
const app = createApp({ render: () => h(Middle) })
app.config.errorHandler = (err, vm, info) => {
console.log('app errorHandler:', err.message, '| info =', info)
}
app.mount('#app')
输出 captured by Middle: boom in setup | info = setup function,随后 app errorHandler: ... | info = setup function(info 来自 ErrorCodes.SETUP_FUNCTION,errorHandling.ts:49)。若 Middle.errorCaptured 返回 false,则只剩第一行——冒泡被终止,app.config.errorHandler 不会被调用。
14.13 实战实验:三个实验验证前文的时序结论
以下三个实验都用 runtime-dom 构建运行(node_modules/vue 或打包产物均可)。为便于观察,统一用 console.log 打印,并用 @vue/runtime-core 的内部 API 关闭批处理的影响——不需要,下面的输出已经是"观察到的顺序"。
说明:以下标注的输出是"可预期的关键行与相对顺序"。同一阶段内相邻行的先后(例如同一 post 批次里的两个钩子)由
getId稳定性决定,源码保证了顺序,但请以你本地运行为准。
14.13.1 实验一:同一 tick 多次改状态
import { createApp, ref, nextTick, h, onUpdated } from 'vue'
const a = ref(0)
const b = ref(0)
let renders = 0
const Counter = {
setup() {
onUpdated(() => console.log('[updated] a=%d b=%d', a.value, b.value))
return () => {
renders++
console.log('[render] a=%d b=%d', a.value, b.value)
return h('div', `${a.value}|${b.value}`)
}
},
}
const app = createApp(Counter)
app.mount('#app')
const el = document.querySelector('#app')
console.log('[after mount] renders =', renders, '| dom =', el.innerHTML)
console.log('--- 同步修改 a 与 b(同一 tick)---')
a.value++ // 入队 A
b.value++ // 与 A 合并(同一 flush)
console.log('[after write, sync] dom =', el.innerHTML)
await nextTick()
console.log('[after nextTick] dom =', el.innerHTML, '| renders =', renders)
a.value++ // 只改 a
b.value = b.value // 写同值:hasChanged 短路,不触发
await nextTick()
console.log('[after 2nd nextTick] renders =', renders)
预期输出:
[render] a=0 b=0
[after mount] renders = 1 | dom = <div>0|0</div>
--- 同步修改 a 与 b(同一 tick)---
[after write, sync] dom = <div>0|0</div>
[render] a=1 b=1
[updated] a=1 b=1
[after nextTick] dom = <div>1|1</div> | renders = 2
[after 2nd nextTick] renders = 3
逐行解释:
[after mount] renders = 1:首次挂载是同步渲染(setupRenderEffect末尾update(),renderer.ts:1650),且onUpdated不在挂载时触发。[after write, sync] dom = <div>0|0</div>:两次写之后 DOM 仍是旧值。这就是"同步代码看不到新 DOM"的直接证据——flushJobs还没跑(它在微任务里)。[render] a=1 b=1只出现一次:两次写合并为一次渲染。a与b在同一次dep.notify批次里被通知(若它们各自独立地触发同一个 effect,NOTIFIED也会去重)。[updated] a=1 b=1在[render]之后、[after nextTick]之前:updated是 post cb,nextTick(fn)挂到currentFlushPromise的后续.then,所以晚于 post。这验证了 14.6.4 的结论。- 第二次实验里
b.value = b.value不产生渲染(renders从 2 → 3 只因a.value++),因为hasChanged短路,dep.trigger根本没被调用。
14.13.2 实验二:watch(pre) 与组件更新顺序
import { createApp, ref, watch, h, onUpdated } from 'vue'
const n = ref(0)
const log = []
const Child = {
props: ['n'],
setup(props) {
onUpdated(() => log.push('child updated'))
return () => h('span', `child=${props.n}`)
},
}
const App = {
setup() {
watch(n, () => log.push(`watch(pre) n=${n.value}`)) // 默认 pre
watch(n, () => log.push(`watch(post) n=${n.value}`), { flush: 'post' })
onUpdated(() => log.push('app updated'))
return () => h('div', [h('b', `app=${n.value}`), h(Child, { n: n.value })])
},
}
createApp(App).mount('#app')
log.length = 0 // 清掉挂载期无关记录
n.value++
await new Promise(r => setTimeout(r, 0)) // 等所有微任务与宏任务收尾
console.log(log)
预期输出:
[ 'watch(pre) n=1', 'watch(post) n=1', 'child updated', 'app updated' ]
逐条解释:
-
watch(pre)最先。pre job 与 App 的更新 job 同 id(都是 App 的 uid),而findInsertionIndex的 tie-break 保证 PRE 排在"同 id 的更新 job 之前"(14.4.2)。无论入队先后如何,执行顺序都是确定性的。这验证了排序策略的必要性——如果按 FIFO,顺序会依赖订阅顺序,不可预测。 -
watch(post)第二。它在n.value++的当下就被queuePostRenderEffect排进了pendingPostFlushCbs(早于任何渲染),所以它在 post 批次里排在最前。 -
child updated早于app updated。两者都是无 id 的 post cb(getId === Infinity),靠Array.prototype.sort的稳定排序保留入队顺序。入队顺序是:Child 的updated在 Child 被instance.update()同步驱动时排出,这一时刻早于 App 的componentUpdateFn在 patch 完子树后才排自己updated的时刻;所以入队顺序是 Child → App,稳定排序原样保留。这正是本章最值得记住的一课:父组件先渲染,但子的
updated可能先于父的updated——post 批次只保证入队序,不保证父子序。 主队列靠uid保证父子序,post 批次没有这一保证(getId全是Infinity),只能靠稳定排序维持"先来先服务"。 -
watch(post)与updated的相对顺序:watch(post)在同步阶段入队(更早),updated在 flush 期间入队(更晚),稳定排序下前者在前。
14.13.3 实验三:nextTick 拿 DOM
import { createApp, ref, nextTick, h, onMounted, onUpdated } from 'vue'
const log = []
let run // 暴露给全局,便于手动触发
const Child = {
setup() {
onMounted(() => log.push('child mounted'))
onUpdated(() => log.push('child updated'))
return () => h('div', { class: 'child' }, 'I am child')
},
}
const App = {
setup() {
const show = ref(false)
onMounted(() => log.push('app mounted'))
onUpdated(() => log.push('app updated'))
run = async function () {
log.push('--- run start ---')
show.value = true
// 同步读 DOM:Child 还没挂载
log.push('sync dom = ' + document.querySelector('#app').innerHTML)
await nextTick()
log.push('after nextTick dom = ' + document.querySelector('#app').innerHTML)
show.value = false // 第二轮:卸载 Child
log.push('--- second change ---')
await nextTick()
log.push('after 2nd nextTick dom = ' + document.querySelector('#app').innerHTML)
}
return () => h('div', [show.value ? h(Child) : null])
},
}
createApp(App).mount('#app')
log.length = 0 // 清掉挂载期记录
await run()
console.log(log)
预期输出:
--- run start ---
sync dom = <div></div>
child mounted
app updated
after nextTick dom = <div><div class="child">I am child</div></div>
--- second change ---
app updated
after 2nd nextTick dom = <div></div>
逐行解释:
sync dom = <div></div>:show.value = true之后同步读 DOM,Child还没挂载——再次印证"同步代码看不到新 DOM"。child mounted先于app updated:与实验二同理,Child的mounted在Child挂载时(App 的 render 期间)入队,App 的updated在 App 的 patch 完成后入队。稳定排序保持入队序。after nextTick:DOM 已经包含 Child。nextTick的回调挂在currentFlushPromise.then上,而flushPostFlushCbs在flushJobs内部——所以nextTick回调确实晚于mounted/updated。这验证 14.6.4。- 第二个变更后:
show.value = false触发 App 更新(卸载 Child)。app updated出现;child unmounted(若注册了)也会出现在 post 批次。after 2nd nextTick时 DOM 已清空。
14.13.4 三个实验的结论汇总
| 实验 | 验证的结论 | 对应小节 |
|---|---|---|
| 一 | 同一 tick 的多次写合并为一次渲染;同步代码读不到新 DOM;updated 早于 nextTick(fn) | 14.1.1、14.5、14.6.4 |
| 二 | PRE 与同 id 更新任务的确定性相对顺序;post 批次只有入队序、getId 相同时稳定 | 14.4.2、14.4.4、14.5.3 |
| 三 | nextTick 的 DOM 可见性保证;mounted 与 updated 在 post 阶段、先于 nextTick | 14.6.4、14.7.6、14.8.4 |
14.14 本章要点回顾
14.14.1 核心不变量速查
| 不变量 | 载体 | 保证方式 |
|---|---|---|
| 同一 job 单次 flush 内只跑一遍 | job.flags & QUEUED | queueJob 开头 O(1) 判定(scheduler.ts:104) |
| 同 id 任务按入队序稳定 | findInsertionIndex 二分 | 找到最后一个 <= 目标 id 的位置(scheduler.ts:79-98) |
| 无 id 更新任务排最后 | getId 返回 Infinity | Infinity - Infinity = NaN,比较时当 0(scheduler.ts:270) |
| 无 id 的 PRE 任务排在更新之前 | getId 返回 -1 | 负数天然小于所有 uid(scheduler.ts:271) |
| post 队列严格 FIFO | push + 升序排序 | 数组分支不去重、不用 spread(scheduler.ts:126-146) |
| flush 期间新入队 job 仍被执行 | while (flushIndex < queue.length) | 游标不冻结队列长度(scheduler.ts:280) |
14.14.2 一次 flush 的三段式骨架
flushJobs 的结构恒定:① 排序 + 主循环(try)→ ② finally 清空 queue、重置游标并 flushPostFlushCbs → ③ currentFlushPromise = null,若主循环期间又有人入队则递归 flushJobs。主循环对每个 job 做两件事:RECURSION_LIMIT 计数(超限 continue 而非中断整个 flush)与 flushPreFlushCbs 抢占式插入。nextTick 即 (currentFlushPromise || resolvedPromise).then(fn),故它天然晚于 post 批次。
14.14.3 与大纲的差异汇总
三处与提纲不符,均以源码为准:其一,3.5.44 已无 isFlushPending,其职责由 currentFlushPromise 承担;其二,去重不用 queue.includes(job),改用 job 自身的 QUEUED 位;其三,flushPostFlushCbs 是升序排序(不同于旧实现的逆序)。
14.14.4 面试深挖
Q:调度器为什么用 Promise 而不是 setTimeout? 微任务在同一宏任务末尾清空,能在浏览器渲染前一次性应用全部变更;宏任务会多引入一帧延迟,且 nextTick 需要精确的 flush 后时机。resolvedPromise 是模块级单例,避免反复分配。
Q:父子组件同 tick 更新会重复渲染父吗? 不会。父更新时 toggleRecurse(instance, false) 关闭 ALLOW_RECURSE,再由 componentUpdateFn 末尾 effect.notify();RUNNING && !ALLOW_RECURSE 提前 return,子组件不会被重复入队(见 14.5.5)。
Q:queueJob 为何插入而非 push? 直接 push 会破坏 id 有序性——PRE 任务与中途入队的 job 必须按 id 就位;而用 unshift 又会颠倒后到任务的相对顺序。二分插入是唯一能同时满足"有序 + 稳定"的选择。