Vue 3 核心源码深度剖析系列(v3.5.44)-第 14 章 运行时调度器:队列、批处理、nextTick 与应用上下文

0 阅读51分钟

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 行,却承载了四条互相约束的设计原则:

  1. 合并(batching)。同一个 tick 内对同一个状态的 N 次修改,只产生 1 次组件重渲染。这是"声明式 UI 框架"能跑赢手写 DOM 操作的唯一根本原因。
  2. 有序(ordering)。队列不是 FIFO,而是按组件实例 uid 升序排列。父组件的 uid 必然小于子组件(先创建先分配),因此"父先于子"不是靠调用栈保证,而是靠排序这一数据结构不变量保证。
  3. 可重入且安全(reentrancy)。任务的执行过程本身可能再次写脏状态、再次入队。调度器必须在同一次 flush 中把新任务也算进去,同时用 RECURSION_LIMIT = 100 兜底,防止"渲染自己改自己依赖"的自激发死循环把页面冻死。
  4. 可观测(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 的核心不变量有两条:

  1. 有序:从 flushIndex + 1 起到 queue.length - 1,元素按 getId(job) 非递减排列(14.4 证明)。
  2. 已执行的前缀被保留直到本轮结束。这是最反直觉的一点:任务执行后不会从 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 想拿到稳定的子组件实例)。

三个状态的职责划分:

状态类型生命周期承担的不变量
pendingPostFlushCbsSchedulerJob[]跨 flush 存在存放"已入队但尚未开始执行"的 post cb。数组顺序无意义(排出前会整体排序)
activePostFlushCbsSchedulerJob[] | null仅在一次 flushPostFlushCbs 内null 表示"当前没有正在执行的 post 批次";非 null 表示"正在执行",且此时新来的 cb 要追加到它尾部(scheduler.ts:212-217)
postFlushIndexnumber仅在一次 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 承担双重身份:

  1. 净化开关(去重闸门)。queueFlush 里 if (!currentFlushPromise) 保证一次 tick 内只排一个 flush 微任务(scheduler.ts:120-124)。一旦 currentFlushPromise 非 null,后续所有 queueJob 都只是往 queue 里塞,不再重复排微任务。
  2. 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
}
标志值语义谁设置谁消费
QUEUED1该 job 已在某条队列中,防止重复入队queueJob(scheduler.ts:114)、queuePostFlushCb(scheduler.ts:132)queueJob/queuePostFlushCb 的入队判定;flushJobs 执行前后清位
PRE2该 job 属于 pre 队列(要在组件渲染前执行)apiWatch 的 augmentJob(apiWatch.ts:225-231)findInsertionIndex(同 id 时的前后关系)、flushPreFlushCbs(筛选)、getId(无 id 的 pre job 取 -1)
ALLOW_RECURSE4允许在 flush 中自触发toggleRecurse(renderer.ts:2530-2541)、apiWatch(apiWatch.ts:222-224)flushJobs/flushPreFlushCbs/flushPostFlushCbs 的 QUEUED 清位时机
DISPOSED8该 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是否 PREgetId 返回排序位置
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"。三个条件的含义:

  1. !lastJob:队列空,直接 push。
  2. !(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 一律走精确插入。
  3. 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

三个动作:

  1. new Set(cbs) 去重:post cb 没有 QUEUED 位那种"入队即置位"的保护吗?有的(scheduler.ts:130-133 的非数组分支会置位),但数组形态的 cb(组件生命周期钩子数组)在 queuePostFlushCb 的数组分支里是直接 push 的、不查重(scheduler.ts:134-144)。源码注释解释了原因:"数组形态必然是组件生命周期钩子,它们只能由已经去重过的 job 触发,所以这里跳过去重以提升性能。"尽管如此,为了兜底(同一钩子数组可能被两次 queuePostRenderEffect 排出),排出时统一用 Set 去重。
  2. .sort(...) 按 getId 升序:让 post cb 也按组件 uid 排列。这样"父组件的 updated 先于子组件的 updated",与主队列的父先子后一致。
  3. [...] 拷贝:不原地改 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 想要的"先来先服务"语义。

下表总结两条队列的定位方式差异:

维度主队列 queuepost 队列 pendingPostFlushCbs
插入时是否排序是(二分定位)否(直接 push,数组序无意义)
排出时是否排序否(已有序)是(sort 整体排序)
去重机制QUEUED 位(O(1))数组分支靠"上游 job 已去重";排出时 Set 兜底
排序键相同时的 tie-breakPRE 优先 + 保持入队顺序稳定排序保持入队顺序
特殊 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)

逐行含义:

  1. flushIndex = 0 初始化:每轮 flush 从头扫。注意 queue.length 在循环中可能增长(任务执行时入队),而 flushIndex < queue.length 是每次都重新求值的动态条件——所以"执行期间新增的任务"会被同一轮循环吃掉。这正是 for 条件用 queue.length(而非缓存的长度变量)的原因。
  2. if (job && !DISPOSED):job 可能为 undefined(flushPreFlushCbs 用 splice 删过元素,数组可能出现空洞);DISPOSED 表示组件已卸载,跳过。二者是两道独立防线。
  3. check(job) → continue:dev 下的递归守护(14.3.5)。continue 会触发 flushIndex++,任务被跳过且不会被清 QUEUED——严格说这是个"故意留下的不一致",但因为在递归超限场景下该 job 已经被判定为病态,留着 QUEUED 可避免它在下一轮又被立即排队。
  4. 执行前清 QUEUED(仅 ALLOW_RECURSE):见 14.2.6 的详细解释。
  5. 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 之外的上下文"的来源。
  6. 执行后按需清 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 队列元素。

步骤事件queuependingPostFlushCbsflushIndexcurrentFlushPromise
S0初始(挂载已完成)[][]-1null
S1n.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>
S4flushIndex=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>
S5flushIndex=2 取 J(2)(Sibling 更新)。Sibling 重渲染,其 updated 钩子被排出[...同上][…, P(SiblingUpdated)]2<pending>
S6flushIndex=3 → 3 < queue.length(3) 为假,主循环结束同 S53 个 P3<pending>
S7finally:flushIndex 已等于 queue.length,兜底清位循环不执行;flushIndex=-1;queue.length=0[]3 个 P-1<pending>
S8flushPostFlushCbs:deduped = sort(3 个 P),三者 getId 均为 Infinity → 稳定排序保持 App→Child→Sibling 顺序;依次执行[][]-1<pending>
S9currentFlushPromise = null;queue.length === 0 && pendingPostFlushCbs.length === 0 → 不递归[][]-1null
S10微任务结束。此前注册的 nextTick(fn) Promise 现在 resolve[][]-1null

几个必须理解的细节:

  • 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)

三点解释:

  1. 为什么会"queue 又有内容"? 三种来源:(a) 某任务在执行中 queueJob 了新任务,但在当前循环里已被吃掉(queue.length 动态求值),所以这种情况不需要递归;(b) flushPostFlushCbs 执行 post cb 时,某个 post cb queueJob 了新任务——此时主循环已结束,必须递归;(c) post cb 又排出了新的 post cb(但 activePostFlushCbs 机制通常已在本轮吃掉,只有"post 批次结束后又有人 push"才会落到这里)。所以"递归"主要服务的是 (b)。
  2. currentFlushPromise = null 放在递归之前:递归的那一轮 flushJobs 会重新检查 queue.length 并清空,结束时再次置 null。若直到最后仍有任务残留(理论上不会,因为循环会跑干净),闸门保持打开状态也无害——但正常路径下一定以 null 收尾。
  3. 递归而非 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 = 本轮任务数游标推进,不删元素
flushPostFlushCbsO(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非 nullcurrentFlushPromise本次 flushJobs 完成之后(含 post cb)
同步代码里、没有等待中的 flushnullresolvedPromise下一个微任务(当前微任务批次 FIFO)
flush 过程中(任务/post cb 内)非 nullcurrentFlushPromise仍等本次 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 微任务:&#34;flushJobs&#34;
  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.effectnew ReactiveEffect(componentUpdateFn)渲染副作用,负责依赖收集间接:update/job
instance.updateeffect.run.bind(effect)强制同步更新,跳过脏检查updateComponent(父驱动更新)、Suspense 的 nonHydratedAsyncRoot 补更新(renderer.ts:1519)
instance.jobeffect.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++)。它的两个关键性质:

  1. 全局单调递增,跨 app 实例共享一个计数器。多个 createApp() 创建的 app 共享同一计数器,因此"不同 app 的组件"之间也有稳定的相对顺序。
  2. 父组件先创建。因为组件实例的创建发生在父组件渲染子组件 vnode 时,所以 parent.uid < child.uid。

job.i = instance 则给任务打上"归属",供错误上报(scheduler.ts:333 的 getComponentName(instance.type))与 post 队列的 minUid 过滤使用。

14.7.3 两条更新路径:同步 vs 入队

flowchart TB
  A[&#34;状态被写脏&#34;] --> B[&#34;dep.notify → effect.notify → batch&#34;]
  B --> C{&#34;endBatch 时 trigger&#34;}
  C -->|有 scheduler| D[&#34;effect.scheduler() → queueJob(instance.job)&#34;]
  D --> E[&#34;进入主队列,等微任务 flush&#34;]
  F[&#34;父组件 patch 子组件&#34;] --> G[&#34;updateComponent(n1,n2)&#34;]
  G --> H{&#34;shouldUpdateComponent?&#34;}
  H -->|是| I[&#34;instance.next = n2&#34;]
  I --> J[&#34;instance.update()(同步 effect.run)&#34;]
  H -->|否| K[&#34;只复制 el/placeholder,替换 instance.vnode&#34;]

两条路径的差异:

维度依赖写脏 → 入队路径父 patch → 同步路径
入口effect.scheduler → queueJob(job)updateComponent → instance.update()
执行时机本 tick 的微任务 flush立刻(在父组件这一趟渲染中)
用的绑定effect.runIfDirty(脏检查)effect.run(无条件)
instance.nextnull(组件自己改了自己依赖的状态)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)

三个分支:

  1. shouldUpdateComponent 为假:props/slots 都没变(或变化的只有不会触发子渲染的部分),只需把 n2 的 DOM 引用复制过来、替换 instance.vnode,不执行任何渲染。这是"父组件重渲染但子组件没必要更新"的快速路径,shouldUpdateComponent 里对 dynamicProps/patchFlag 有一整套判定(第 13 章已展开)。
  2. 异步组件尚未解析(asyncDep && !asyncResolved):不能渲染(渲染 effect 还没建立),只更新 props 与 slots,并把 el/placeholder 从旧 vnode 搬运过来(注释特意提到"hydration 被中断时,卸载已认领 DOM 依赖这个 el")。
  3. 正常路径: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 放大,看"父先子后 + 无重复渲染"是怎么达成的:

  1. t0:n.value++ → App 的 render effect 通过 notify → batch 被标记 NOTIFIED;endBatch 时 trigger() → scheduler() → queueJob(J_app)。
  2. t1(微任务):flushJobs 执行 J_app → effect.runIfDirty → isDirty(J_app.effect) 为真(n 的 dep.version 变了)→ run() → 重渲染 App → patch Child。
  3. 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 排出。
  4. t3:App 的 updated 也排出。
  5. 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)

执行顺序的语义:

  1. 先写 props/slots(updateProps / updateSlots)——把父传来的新值落进子实例。
  2. 再 flushPreFlushCbs(instance)——props 写入可能触发了子组件内 flush: 'pre' 的 watcher(它们依赖这些 props)。这些 pre watcher 必须在子组件渲染之前执行完,否则渲染会读到它们尚未处理的状态。
  3. 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 写入触发的 watcherupdated/mounted/unmounted 钩子、watch(..., {flush:'post'})、模板 ref 赋值、Suspense effects
谁"抢跑"updateComponentPreRender 调 flushPreFlushCbs(instance)flushJobs 末尾调 flushPostFlushCbs()

一句话记忆:pre 是"渲染前先把派生状态算清楚",post 是"等 DOM 稳定后再做副作用"。

14.8.2 pre 队列从哪来

三个来源,全部通过 queueJob 进入主队列:

  1. 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。

  2. 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)。

  3. 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 的 effectsSuspense.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[&#34;state.value++&#34;] --> D[&#34;Dep.set → setValue → dep.trigger()&#34;]
  D --> V[&#34;dep.version++<br/>globalVersion++&#34;]
  V --> N[&#34;dep.notify()&#34;]
  N --> SB[&#34;startBatch() → batchDepth++&#34;]
  SB --> L1[&#34;遍历 subs(逆序)→ sub.notify()&#34;]
  L1 --> B1{&#34;NOTIFIED 已置?&#34;}
  B1 -->|否| BT[&#34;batch(sub):置 NOTIFIED + 头部插入 batchedSub&#34;]
  B1 -->|是| SKIP[&#34;跳过(同批次去重)&#34;]
  BT --> EB[&#34;finally: endBatch() → batchDepth--&#34;]
  EB --> Q{&#34;batchDepth == 0?&#34;}
  Q -->|否| RET[&#34;返回(交给更外层)&#34;]
  Q -->|是| TR[&#34;遍历 batchedSub:清 NOTIFIED,ACTIVE → trigger()&#34;]
  TR --> SCH[&#34;组件 effect 有 scheduler → queueJob(job)&#34;]
  SCH --> QF[&#34;queueFlush → 排 flushJobs 微任务&#34;]

关键观察: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)

三点:

  1. createApp 是一个工厂函数,render 是它捕获的闭包变量。所以 createApp 与具体平台(DOM / 自定义渲染器 / SSR)解耦——renderer.ts:2512 里 createApp: createAppAPI(render, hydrate) 把渲染器注入进来。
  2. rootComponent 若为对象则被 extend({}, ...) 拷贝一份(apiCreateApp.ts:258-260)。这是防止"用户复用同一个组件选项对象创建多个 app"时互相污染。函数组件(isFunction)不拷贝,因为函数本身就是"新实例"。
  3. 每个 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)

字段类型作用组件如何使用
appApp反向引用(devtools 用)被 inject 的 app-level 兜底间接使用
configAppConfig全局配置(错误处理、性能、编译器选项等)每个组件 instance.appContext.config
mixinsComponentOptions[]全局混入applyOptions 时合并
componentsRecord<string, Component>全局组件注册表解析模板里的组件名时查这里
directivesRecord<string, Directive>全局指令注册表解析指令时查这里
providesRecord<string | symbol, any>应用级 provide 的根根组件的 instance.provides 以它为原型
optionsCacheWeakMap合并后的组件选项缓存每个 app 独立一份(注释说明:因为 mixins/optionMergeStrategies 会影响合并结果)
propsCacheWeakMap归一化 props 缓存同上
emitsCacheWeakMap归一化 emits 缓存同上
reload() => voidHMR 专用dev 下 mount 时设置
filtersRecord<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)应用级 providein 检查 + 覆盖警告写入 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)

四个关键动作:

  1. 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 能在任何深度的组件里生效。
  2. render(vnode, rootContainer, namespace)——渲染器(第 11 章)的入口。注意 render 是 createAppAPI 的闭包参数,所以这里调用的是"平台渲染器",不是通用的 render 函数。
  3. isMounted = true + __vue_app__ 标记——isMounted 防重复挂载;__vue_app__ 是给"同一个容器被两次 createApp().mount()"这个常见错误提供明确警告(issue #5571)。这个警告文案直接写在源码里,堪称"最实用的开发期提示之一"。
  4. 返回 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 的字段里,与调度器/错误链路直接相关的有三个:

字段与调度器的关系
errorHandlerhandleError 在"没有 errorCaptured 拦截"时的兜底(errorHandling.ts:143-152),也用于调度器递归超限的报错(scheduler.ts:334)
warnHandlerwarn 的接管者(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[&#34;inject(key)&#34;] --> C1{&#34;currentApp 非空?<br/>(app.runWithContext 中)&#34;}
  C1 -->|是| P1[&#34;currentApp._context.provides&#34;]
  C1 -->|否| C2{&#34;有 currentInstance?&#34;}
  C2 -->|是| C3{&#34;instance.parent == null<br/>或 instance.ce?&#34;}
  C3 -->|是(根组件/自定义元素)| P2[&#34;instance.vnode.appContext.provides&#34;]
  C3 -->|否| P3[&#34;instance.parent.provides<br/>(沿原型链查找)&#34;]
  P1 --> H{&#34;key in provides?&#34;}
  P2 --> H
  P3 --> H
  H -->|命中| R[&#34;返回 provides[key]&#34;]
  H -->|未命中| D{&#34;arguments.length > 1 ?&#34;}
  D -->|是| D1[&#34;默认值 / 工厂函数&#34;]
  D -->|否| W[&#34;dev: warn 'not found'&#34;]

三个特殊规则的来源:

  • 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
}

三个语义要点:

  1. 用 arguments.length > 1 而非 defaultValue !== undefined 判断"是否给了默认值"。这允许 inject(key, undefined) 明确表示"默认值就是 undefined",与"没给默认值"区分开(后者会走到 warn)。
  2. treatDefaultAsFactory === true 时,若 defaultValue 是函数,则调用它(this 绑定到 instance.proxy)。这解决"默认值是一个对象/数组时,多个组件共享同一引用"的问题——工厂函数每次返回新对象。
  3. 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[&#34;错误 err(来自某个 instance)&#34;] --> C1[&#34;从 instance.parent 开始向上遍历&#34;]
  C1 --> H{&#34;当前祖先有 errorCaptured 钩子?&#34;}
  H -->|有| I[&#34;依次调用每个 ec 钩子&#34;]
  I --> J{&#34;某个钩子返回 false?&#34;}
  J -->|是| STOP[&#34;停止冒泡(已处理)&#34;]
  J -->|否| C1
  H -->|无| C1
  C1 --> C2{&#34;走到根,仍有 app.config.errorHandler?&#34;}
  C2 -->|是| APP[&#34;errorHandler(err, exposedInstance, errorInfo)<br/>pauseTracking 包裹&#34;]
  C2 -->|否| LOG[&#34;logError: dev 抛错 / prod console.error&#34;]

要点:

  1. 从 instance.parent 开始,不包含 instance 自身。也就是说,组件自己的 errorCaptured 不捕获"自己内部"的错误——它捕获的是后代的错误。这与直觉一致:"错误边界"是父的职责。
  2. === false 才终止。errorCaptured 返回 false 表示"我处理了,别继续冒泡";返回 undefined(或其它值)则继续往上传。这是文档里那条"返回 false 阻止进一步传播"的实现。
  3. exposedInstance = instance.proxy——传给钩子的是出错组件的公共实例(不是当前遍历到的祖先),所以 errorCaptured(err, vm, info) 里的 vm 是"哪个组件炸了"。
  4. 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 包但保持数值不变)。
  5. 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,不抛—
prodconsole.error,不抛app.config.throwUnhandledErrorInProduction = true 时抛出

三条设计动机:

  1. dev 默认抛错:源码注释 "crash in dev by default so it's more noticeable"。dev 下"静默吞掉错误"会让 bug 藏得很深;抛出让调用栈直接暴露问题。
  2. prod 默认恢复:"recover in prod to reduce the impact on end-user"——生产环境下宁可让某个组件渲染失败,也不让整个应用崩溃。
  3. 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 的 rejectionSuspense 的 asyncDep + handleError(ErrorCodes.SETUP_FUNCTION)是(由 Suspense 内部处理)
生命周期钩子返回 Promise 的 rejectioncallWithAsyncErrorHandling 的 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' ]

逐条解释:

  1. watch(pre) 最先。pre job 与 App 的更新 job 同 id(都是 App 的 uid),而 findInsertionIndex 的 tie-break 保证 PRE 排在"同 id 的更新 job 之前"(14.4.2)。无论入队先后如何,执行顺序都是确定性的。这验证了排序策略的必要性——如果按 FIFO,顺序会依赖订阅顺序,不可预测。

  2. watch(post) 第二。它在 n.value++ 的当下就被 queuePostRenderEffect 排进了 pendingPostFlushCbs(早于任何渲染),所以它在 post 批次里排在最前。

  3. 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),只能靠稳定排序维持"先来先服务"。

  4. 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>

逐行解释:

  1. sync dom = <div></div>:show.value = true 之后同步读 DOM,Child 还没挂载——再次印证"同步代码看不到新 DOM"。
  2. child mounted 先于 app updated:与实验二同理,Child 的 mounted 在 Child 挂载时(App 的 render 期间)入队,App 的 updated 在 App 的 patch 完成后入队。稳定排序保持入队序。
  3. after nextTick:DOM 已经包含 Child。nextTick 的回调挂在 currentFlushPromise.then 上,而 flushPostFlushCbs 在 flushJobs 内部——所以 nextTick 回调确实晚于 mounted/updated。这验证 14.6.4。
  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 阶段、先于 nextTick14.6.4、14.7.6、14.8.4

14.14 本章要点回顾

14.14.1 核心不变量速查

不变量载体保证方式
同一 job 单次 flush 内只跑一遍job.flags & QUEUEDqueueJob 开头 O(1) 判定(scheduler.ts:104)
同 id 任务按入队序稳定findInsertionIndex 二分找到最后一个 <= 目标 id 的位置(scheduler.ts:79-98)
无 id 更新任务排最后getId 返回 InfinityInfinity - Infinity = NaN,比较时当 0(scheduler.ts:270)
无 id 的 PRE 任务排在更新之前getId 返回 -1负数天然小于所有 uid(scheduler.ts:271)
post 队列严格 FIFOpush + 升序排序数组分支不去重、不用 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 又会颠倒后到任务的相对顺序。二分插入是唯一能同时满足"有序 + 稳定"的选择。