Vue 3 设计与实现
第 12 章 生命周期与调度
第 12 章 生命周期与调度
本章要点
- 组件生命周期的完整图谱:从 setup 到 unmounted,每个钩子的触发时机和内部实现
- 生命周期钩子的注册机制:injectHook 如何将钩子函数绑定到组件实例
- 调度器的核心设计:异步批量更新的队列模型和 flush 时机
- nextTick 的实现本质:为什么它能保证在 DOM 更新后执行
- 三种队列的优先级:pre 队列、queue 队列、post 队列的协作关系
- Suspense 对生命周期的影响:异步组件如何改变钩子触发顺序
- 调度器的错误处理与递归保护机制
在上一章中,我们深入了虚拟 DOM 和 Diff 算法——这是 Vue 渲染管线的”引擎”。但引擎不能随意启动,它需要一个精密的调度系统来协调”何时更新”、“以什么顺序更新”、“更新完之后做什么”。
同时,每个组件都有自己的”生命”——从诞生到消亡,在不同的阶段,开发者需要介入执行特定的逻辑。这就是生命周期系统的使命。
调度器和生命周期看似独立,实则紧密耦合。生命周期钩子的触发时机由调度器控制,而调度器的行为又受组件状态(是否挂载、是否激活)的制约。本章将一并剖析。
调度器:前端框架最容易被低估的部分
先说一句可能颠覆你认知的话——Vue 3 相对 Vue 2 的真正技术跃迁,调度器比响应式更大。
响应式从 Object.defineProperty 到 Proxy,性能提升、能监听新增属性、语法更整洁——这些是看得见的改进,也是每个讲 Vue 3 的文章都会提的。但调度器的改进更”深”:Vue 2 时代没有真正独立的调度器,所有响应式触发的 render 被堆在一个微任务里 flush、顺序不一定合理、和副作用冲突时没法精细控制。Vue 3 的调度器把”何时 render”、“何时触发 watchEffect”、“何时执行生命周期钩子”全部放在一个统一的队列模型里——你能想象到的每一种边界情况,都有专门的策略。
这个调度器的重要性在于:它是所有”魔法表现”背后的指挥家。你写 count.value++; console.log(count.value) 然后没有立即看到 DOM 变化——那是调度器在工作;你写 watch(x, ..., { flush: 'post' }) 让回调在 DOM 更新后触发——那是调度器在工作;你写 nextTick 等 render 完成——那是调度器在工作。这些 API 的”表现”不是凭空来的,都是调度器用队列顺序一步步兑现的。
丛书卷《React 19 源码解读》讨论 Fiber 调度器时讲过同样的观察——调度器是框架和”只是模板引擎”之间的分水岭。Vue 3 的调度器设计在这条分水岭上走得比任何开源前端框架(除了 React)都更远。
本章的导图
本章的 11 节分两块:
- 12.1-12.3 生命周期:钩子的注册、存储、触发机制。重点在”钩子函数是怎么和组件实例关联的”——Composition API 下的
onMounted看起来像”空气中漂浮的函数”,但实际上通过 currentInstance 挂到了具体组件上。 - 12.4-12.11 调度器:队列模型、flush 策略、Suspense/KeepAlive/错误处理的交互。重点在”异步批量更新的代价和收益”——为什么同步改状态后 DOM 不立刻变、调度器怎么把多个 trigger 合并成一次 render。
丛书关联:本章是前两章(第 10 章 组件系统、第 11 章 VDOM)的”指挥层”——之前讲的每个流程(组件挂载、render、diff、patch)都由调度器触发。没读过前两章的建议先补一下;已读过的,本章相当于给那些流程加上时间维度。
12.1 组件生命周期全景
生命周期的完整流程
生命周期的核心图谱应该印在每个 Vue 开发者的脑子里。不是因为要应付面试——而是因为写组件时脑子里有这张图,很多”为什么这段代码不 work”的问题就能自己调出来。
典型场景:你在 setup 里写 const el = document.querySelector('.foo')——拿到的是 null。为什么?因为 setup 运行时 DOM 还没创建。你应该用 onMounted(() => document.querySelector('.foo'))——这时候 DOM 已经挂到文档里了。这个判断只能来自脑子里的生命周期图谱。
下面这张完整图谱,你可以收藏。里面每个节点都会在 12.3 节详细讲它的触发时机和内部实现。
graph TD
A[创建组件实例] --> B[setup / beforeCreate / created]
B --> C{有模板/渲染函数?}
C -->|是| D[编译模板 → render 函数]
C -->|否| E[跳过渲染]
D --> F[beforeMount]
F --> G[执行 render → 生成 VNode 树]
G --> H[patch → 创建真实 DOM]
H --> I[mounted]
I --> J{响应式数据变化?}
J -->|是| K[调度器排队]
K --> L[beforeUpdate]
L --> M[re-render → 新 VNode 树]
M --> N[patch/diff → 更新 DOM]
N --> O[updated]
O --> J
J -->|组件卸载| P[beforeUnmount]
P --> Q[卸载子组件/清理副作用]
Q --> R[unmounted]
Composition API 中的生命周期
Composition API 引入的 onMounted / onUnmounted / onBeforeUpdate 等 API 改变了生命周期 API 的使用感觉。Vue 2 的 Options API 里你在组件 options 里写 mounted() { ... }——钩子是”组件的方法”。Vue 3 的 Composition API 里你在 setup 里调 onMounted(() => ...)——钩子是”可以调用的函数”。
两种风格从技术上都能工作——Vue 3 同时保留两套 API 本身就是”不强加 opinion”的体现。但 Composition API 的风格有一个 Options API 给不了的关键优势——逻辑聚合。
这种改变看起来只是”语法换了一种”,其实对代码组织能力有巨大影响。Options API 下,“拉数据 + 初始化组件 + 加事件监听”这些逻辑都分散在 data / created / mounted / beforeDestroy 等不同钩子里——关联的代码不在一起。Composition API 让你可以写:
function useDataLoading() {
const data = ref(null)
onMounted(() => loadData())
onUnmounted(() => abortRequest())
return { data }
}
三个相关逻辑(声明、初始化、清理)全在一个函数里——这就是 Composable 的核心价值:把散在多个钩子里的相关逻辑聚到一处。理解了这一点,你对”Composition API 到底好在哪”会有具体的认知。React Hooks 的出现是为了同一个问题(class 组件里逻辑被迫按生命周期方法而非业务关注点拆分)——两个框架又一次独立走到同一个答案。
在 Vue 3 的 Composition API 中,生命周期钩子通过 onXxx 函数注册:
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onActivated,
onDeactivated,
onErrorCaptured,
onRenderTracked,
onRenderTriggered
} from 'vue'
export default {
setup() {
onBeforeMount(() => {
console.log('DOM 即将创建')
})
onMounted(() => {
console.log('DOM 已创建,可以访问 this.$el')
})
onBeforeUpdate(() => {
console.log('DOM 即将更新')
})
onUpdated(() => {
console.log('DOM 已更新')
})
onBeforeUnmount(() => {
console.log('组件即将卸载')
})
onUnmounted(() => {
console.log('组件已卸载,所有副作用已清理')
})
}
}
注意:setup 本身就是在 beforeCreate 和 created 之间执行的,所以 Composition API 中没有这两个钩子的对应函数——setup 就是它们的替代品。
12.2 钩子注册机制:injectHook
Composition API 的 onMounted(() => ...) 能”知道”自己属于哪个组件,这是整个 Vue 运行时最容易被当做魔法的部分。这一节把这个魔法拆开——核心只有两个概念:currentInstance 作为瞬态全局上下文 + injectHook 把函数存到组件实例上。
第 10 章讲 setup 时提过 currentInstance 的机制——在调 setup 之前 setCurrentInstance、调完 unsetCurrentInstance,这期间 onMounted 这些 API 可以通过全局 currentInstance 变量感知到”我属于哪个组件”。本节进一步讲:感知到之后,钩子函数怎么被存起来、什么时候被触发。
答案出奇朴素——每个组件实例上有几个数组字段(bm / m / bu / u / bum / um 等),onMounted(cb) 就是往 currentInstance.m.push(cb)。生命周期到达对应节点时、调度器遍历这个数组把每个 cb 跑一遍。没有魔法,只有”在对的时间访问对的全局变量”这个朴素机制。
所有 onXxx 函数内部都调用同一个底层函数 injectHook:
// packages/runtime-core/src/apiLifecycle.ts
export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT)
export const onMounted = createHook(LifecycleHooks.MOUNTED)
export const onBeforeUpdate = createHook(LifecycleHooks.BEFORE_UPDATE)
export const onUpdated = createHook(LifecycleHooks.UPDATED)
export const onBeforeUnmount = createHook(LifecycleHooks.BEFORE_UNMOUNT)
export const onUnmounted = createHook(LifecycleHooks.UNMOUNTED)
// createHook 只是一层柯里化
export const createHook = <T extends Function = () => any>(
lifecycle: LifecycleHooks
) => {
return (hook: T, target: ComponentInternalInstance | null = currentInstance) =>
injectHook(lifecycle, (...args: unknown[]) => hook(...args), target)
}
injectHook 的核心逻辑:
// packages/runtime-core/src/apiLifecycle.ts
export function injectHook(
type: LifecycleHooks,
hook: Function & { __weh?: Function },
target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false
): Function | undefined {
if (target) {
// 获取或创建钩子数组
// 组件实例上用简写存储:bm=beforeMount, m=mounted 等
const hooks = target[type] || (target[type] = [])
// 包装钩子函数,确保调用时 currentInstance 正确
const wrappedHook =
hook.__weh ||
(hook.__weh = (...args: unknown[]) => {
if (target.isUnmounted) {
return
}
// 暂停追踪,防止钩子中的响应式访问被错误收集
pauseTracking()
// 设置当前实例,确保钩子内部能访问组件上下文
const reset = setCurrentInstance(target)
// 调用钩子,捕获错误
const res = callWithAsyncErrorHandling(hook, target, type, args)
reset()
resetTracking()
return res
})
if (prepend) {
hooks.unshift(wrappedHook)
} else {
hooks.push(wrappedHook)
}
return wrappedHook
}
}
几个关键设计点:
-
currentInstance 绑定:钩子函数在注册时捕获当前组件实例,调用时恢复。这保证了即使钩子被延迟执行(如
mounted在异步 flush 中执行),也能正确访问组件上下文。 -
暂停追踪:钩子函数中的响应式访问不应该被收集为依赖,否则会导致不可预期的重渲染。
-
错误处理:所有钩子调用都通过
callWithAsyncErrorHandling包装,支持onErrorCaptured的错误冒泡机制。 -
卸载检查:如果组件已经卸载,钩子直接跳过,避免操作已清理的状态。
生命周期枚举
Vue 把生命周期的种类定义成一个枚举——每个钩子对应枚举里的一个字符串键。这个选择的价值在于让 injectHook 不关心具体类型——它是一个通用工具,接受”任意生命周期 kind + 回调函数”,做完全一致的处理(去重、绑 instance、push 到对应数组)。
这种用字符串枚举做类型维度的做法比”为每种钩子写专用注册函数”好很多——扩展性强(新增一个钩子只要加一个枚举项),代码复用(onMounted / onBeforeMount 等 API 都是 createHook('bm' | 'm') 的简单包装)。这是软件工程里”数据表驱动 vs 硬编码”的经典抉择——选数据表驱动的一方。
// packages/runtime-core/src/enums.ts
export enum LifecycleHooks {
BEFORE_CREATE = 'bc',
CREATED = 'c',
BEFORE_MOUNT = 'bm',
MOUNTED = 'm',
BEFORE_UPDATE = 'bu',
UPDATED = 'u',
BEFORE_UNMOUNT = 'bum',
UNMOUNTED = 'um',
DEACTIVATED = 'da',
ACTIVATED = 'a',
RENDER_TRIGGERED = 'rtg',
RENDER_TRACKED = 'rtc',
ERROR_CAPTURED = 'ec',
SERVER_PREFETCH = 'sp'
}
注意这些简写——bm、m、bu、u——它们直接作为组件实例的属性名。这不是偷懒,而是刻意的优化:短属性名在 V8 的隐藏类中占用更少的内存。
12.3 钩子的触发时机
钩子的时机是整个生命周期系统的最复杂部分——每一个钩子都对应组件渲染流程里的一个精确节点,理解每个节点在前后紧邻什么阶段,才能解释”为什么 onMounted 里 DOM 可用、onBeforeMount 里不可用”这种听起来细碎但实际影响开发的细节。
setupRenderEffect:生命周期的指挥中心
这个函数在第 10 章讲过一次——现在从钩子触发时机的视角再看它。同一段代码、不同视角下能看出完全不同的信息。
setupRenderEffect 里的 render effect 函数本质是一个闭包——闭包里按挂载还是更新分了两条路径:
- 挂载路径(
!instance.isMounted):依次触发 beforeMount、render 产出 subTree、patch 到 DOM、triggerMounted 钩子。 - 更新路径(
instance.isMounted):依次触发 beforeUpdate、render 产出新 subTree、和旧 subTree diff、patch、triggerUpdated 钩子。
钩子在每条路径上的位置都是精确挑选的——beforeMount 在真正创建 DOM 之前、Mounted 在真正 insert 到父容器之后;beforeUpdate 在新 subTree 生成之前、Updated 在 DOM 更新之后。这个精确性是你写指令、写组件时能依赖的契约——“我在 onMounted 里查 DOM,Vue 保证 DOM 已经存在”。
组件的生命周期钩子不是由一个统一的”生命周期管理器”触发的——它们散布在渲染流程的各个关键节点。最核心的触发点在 setupRenderEffect 中:
// packages/runtime-core/src/renderer.ts(简化)
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace,
optimized
) => {
const componentUpdateFn = () => {
if (!instance.isMounted) {
// ======== 首次挂载 ========
const { bm, m, parent } = instance
// 触发 beforeMount
if (bm) {
invokeArrayFns(bm)
}
// 执行渲染函数
const subTree = (instance.subTree = renderComponentRoot(instance))
// 递归 patch(创建真实 DOM)
patch(null, subTree, container, anchor, instance, parentSuspense, namespace)
// 真实 DOM 已创建,保存引用
initialVNode.el = subTree.el
// 触发 mounted(通过调度器 post 队列延迟执行)
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
instance.isMounted = true
} else {
// ======== 更新 ========
let { next, bu, u, parent, vnode } = instance
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 触发 beforeUpdate
if (bu) {
invokeArrayFns(bu)
}
// 执行渲染函数
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
// Diff 并更新 DOM
patch(prevTree, nextTree, hostParentNode(prevTree.el!)!, getNextHostNode(prevTree), instance, parentSuspense, namespace)
next.el = nextTree.el
// 触发 updated(通过调度器 post 队列延迟执行)
if (u) {
queuePostRenderEffect(u, parentSuspense)
}
}
}
// 创建渲染 effect
const effect = (instance.effect = new ReactiveEffect(componentUpdateFn, NOOP, () => queueJob(instance.update)))
const update: SchedulerJob = (instance.update = () => {
if (effect.dirty) {
effect.run()
}
})
update.id = instance.uid
// 首次执行
update()
}
注意 beforeMount 和 beforeUpdate 是同步调用的(invokeArrayFns 直接执行),而 mounted 和 updated 是通过 queuePostRenderEffect 延迟到 post 队列执行的。
这意味着:
beforeMount时,DOM 还不存在mounted时,DOM 已经创建并插入到文档中(因为 post 队列在所有 DOM 操作完成后执行)beforeUpdate时,数据已变但 DOM 还是旧的updated时,DOM 已经更新完成
卸载流程
组件卸载是一个需要严格按序收尾的过程——顺序错了就是资源泄漏、事件监听器还在工作但组件已经消失这种”鬼魂”bug。
顺序:beforeUnmount(让用户代码有机会清理)→ 卸载子树(递归卸载所有子组件)→ 触发 unmounted(用户最后的收尾机会)→ 清理 effect(取消所有响应式订阅)→ 实例垃圾回收。
这个顺序有几个不能颠倒的点:beforeUnmount 必须在 effect 清理前——否则用户在 beforeUnmount 里访问响应式数据会拿到 null;unmounted 必须在 DOM 移除后——这样用户清理 DOM 引用时能确认元素不在文档里;子组件的卸载必须在父 unmounted 前——自底向上的析构符合常识。
这些”小细节”合起来让 Vue 的卸载行为在所有边界场景下都符合直觉——开发者不需要记住一堆时序规则、按常识写就是对的。
// packages/runtime-core/src/renderer.ts(简化)
const unmountComponent = (
instance: ComponentInternalInstance,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean
) => {
const { bum, scope, update, subTree, um } = instance
// 触发 beforeUnmount
if (bum) {
invokeArrayFns(bum)
}
// 停止组件的 effect scope(清理所有 watch、computed 等副作用)
scope.stop()
// 停止渲染 effect
if (update) {
update.active = false
unmount(subTree, instance, parentSuspense, doRemove)
}
// 触发 unmounted(post 队列)
if (um) {
queuePostRenderEffect(um, parentSuspense)
}
// 标记卸载状态
queuePostRenderEffect(() => {
instance.isUnmounted = true
}, parentSuspense)
}
beforeUnmount 是同步的——此时组件的 DOM 还在,你可以做最后的 DOM 操作。unmounted 是异步的——此时 DOM 已被移除,适合做清理工作(如取消事件监听、断开 WebSocket)。
12.4 调度器架构
到这里我们从”组件的生命”切换到”帧的生命”——调度器关心的是:一个 tick 内可能有成百上千次响应式 trigger、怎么把它们合并、按什么顺序执行、什么时候 flush。这是 Vue 运行时最工程化的一段代码。
读调度器源码的心理准备:代码不长(几百行)但每一行都在解决一个具体的真实问题。读的时候不要一眼滑过、要慢——每段代码配合本节的解释,你会看到一个个小得几乎不起眼的设计决策,是怎么撑起整个 Vue 运行时”看起来很稳”的表现。
为什么需要调度器?
没有调度器的世界是什么样?——每次响应式数据变化都立刻同步触发 re-render。这听起来没问题,但真实场景下你有代码:
user.name = 'Alice'
user.age = 30
user.email = 'alice@example.com'
三行代码修改三个字段。没有调度器的话,render 跑 3 次——每次 render 完整经过 Vue 模板、生成新 VNode 树、diff、patch。明明用户只期待”这三个字段合起来更新一次”,却跑了 3 次完整的渲染管线。
调度器的第一使命是”合并”——让同一个组件在同步代码块内的多次 trigger 最终只 render 一次。实现方法是把触发 render 的任务放队列、用 microtask 延迟 flush。同步代码段跑完、microtask 才执行——这期间所有 trigger 都进同一个队列、去重后只 render 一次。这是”异步批量更新”的由来。
调度器的第二使命是”排序”——有了队列就能按顺序执行。父组件 render 先于子组件、watchEffect 的 pre 回调早于 render、post 回调晚于 render。这些”执行顺序”是让 Vue 应用行为可预测的关键。
考虑这段代码:
const count = ref(0)
function handleClick() {
count.value++
count.value++
count.value++
}
如果每次 count.value 变化都立刻触发重渲染,那么一次点击会导致三次渲染——显然是浪费。调度器的核心任务就是将同步代码中的多次状态变化合并为一次更新。
三级队列模型
Vue 3 的调度器用三个独立队列:pendingPreFlushCbs / queue / pendingPostFlushCbs。对应的语义分别是:
- pre 队列:render 之前执行(例如
watchEffect(..., { flush: 'pre' })),典型场景是”我想在 render 读到新值之前更新某些状态”。 - main 队列(queue):组件 render 本身。按组件 uid 排序——父组件 uid 小、子组件 uid 大,所以按 uid 升序排是”父先于子”。
- post 队列:render 之后执行(例如
watchEffect(..., { flush: 'post' })、nextTick的回调),典型场景是”我想在 DOM 更新后测量/操作 DOM”。
每次 flush 时严格按 pre → queue → post 的顺序。一个 flush 可能跑多轮——因为 pre 回调里可能触发新的 trigger、然后 render 又可能触发新的 post 回调、甚至 post 回调本身可能修改响应式数据又触发新的 render。调度器用循环 + 去重处理这种嵌套,直到所有队列都为空。
这种”三层流水线 + 循环 flush”的设计在调度器里很常见——想象一个 OS 的事件循环,或者一个 GPU 的 render pipeline,都是类似的”阶段化 + 相邻阶段可以触发回退”结构。丛书卷《Rust 编译器与运行时揭秘》第 9 章讲 tokio 的 scheduler 时也画过类似的图——调度的本质问题在所有领域里都有相似的解法。
Vue 3 的调度器维护三个队列:
// packages/runtime-core/src/scheduler.ts
const queue: SchedulerJob[] = [] // 主队列
const pendingPreFlushCbs: SchedulerJob[] = [] // pre 回调
const pendingPostFlushCbs: SchedulerJob[] = [] // post 回调
sequenceDiagram
participant User as 用户代码
participant Scheduler as 调度器
participant PreQ as Pre 队列
participant MainQ as 主队列
participant PostQ as Post 队列
participant DOM as 真实 DOM
User->>Scheduler: 状态变化 → queueJob
Scheduler->>Scheduler: 标记 isFlushing = false
Scheduler->>Scheduler: Promise.then(flushJobs)
Note over Scheduler: 微任务开始
Scheduler->>PreQ: 1. flushPreFlushCbs
PreQ->>PreQ: 执行 watch(pre) 回调
Scheduler->>MainQ: 2. 按 id 排序,逐个执行
MainQ->>DOM: 组件渲染/更新
Scheduler->>PostQ: 3. flushPostFlushCbs
PostQ->>PostQ: 执行 mounted/updated/watch(post)
执行顺序:Pre 队列 → 主队列 → Post 队列。
- Pre 队列:
watchEffect(flush: ‘pre’)的回调、组件更新前需要执行的逻辑 - 主队列:组件的渲染更新函数(
instance.update) - Post 队列:
mounted、updated钩子、watchEffect(flush: ‘post’)的回调
queueJob:入队逻辑
入队是调度器最被频繁调用的函数——每次响应式 trigger 都会走一次 queueJob。设计追求快、去重、顺序正确三件事。
快:用数组而不是链表、用 Set 做去重检查的 key 空间、用数字索引比较。去重:同一个 job 不能入队两次(去重用 Set 判断)。顺序:入队按 uid 升序插入(父组件在前、子组件在后)。
这几件事单独看都朴素,合起来构成了调度器”毫秒级响应、千次调用不卡”的性能基础。读这一段代码最能体会到”热点路径的代码要多么精打细算”——Vue 团队显然跑过大量 benchmark 才敲定这些细节。
// packages/runtime-core/src/scheduler.ts
export function queueJob(job: SchedulerJob): void {
// 去重:同一个 job 不会重复入队
if (
!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)
) {
if (job.id == null) {
queue.push(job)
} else {
// 按 id 插入到正确位置(保持有序)
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
queueFlush 使用 Promise.resolve().then() 将 flush 操作推入微任务队列。这保证了当前同步代码全部执行完毕后,才开始处理更新。
flushJobs:调度主循环
flushJobs 是整个调度器的心脏——在 microtask 里被触发一次、负责把三个队列按正确顺序跑完一轮。
主循环的复杂度来自”flush 过程中新增的 job 怎么办”——前面讲过,pre/queue/post 阶段都可能产生新的 job。flushJobs 的答案是每个阶段内部用 while 循环处理到队列为空,而不是简单地”遍历初始快照”——这样新增的 job 会被当前 flush cycle 处理完。
但**“处理到为空”有死循环风险**——如果回调总是往同一队列里加新 job(见 12.6 的递归情况)。flushJobs 用”同 job 连续触发计数”做保护,超过阈值就 warn 并跳过这个 job。这是调度器”尽量让用户代码跑完但绝不自己死循环”的设计。
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
// 1. 执行 Pre 队列
flushPreFlushCbs(seen)
// 2. 主队列排序
// 父组件的 id < 子组件的 id(因为父组件先创建)
// 保证从父到子的更新顺序
queue.sort(comparator)
// 3. 执行主队列
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
// 4. 清理
flushIndex = 0
queue.length = 0
// 5. 执行 Post 队列
flushPostFlushCbs(seen)
// 6. 复位状态
isFlushing = false
currentFlushPromise = null
// 7. 如果在 flush 过程中有新的 job 入队,递归 flush
if (queue.length || pendingPreFlushCbs.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
排序使用 comparator:
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}
排序的意义:组件的 uid 在创建时递增分配,父组件的 uid 必然小于子组件。排序保证了从父到子的更新顺序。为什么这很重要?因为如果子组件先更新,然后父组件更新导致子组件的 props 变化,子组件又会再更新一次——双重渲染。从父到子更新可以避免这个问题。
nextTick 的实现
nextTick(cb) 是 Vue 面向用户最著名的调度 API——保证 cb 在下一次 DOM 更新之后执行。它的实现朴素得让人意外——就几行:
const resolvedPromise = Promise.resolve()
let currentFlushPromise = null
function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(fn) : p
}
关键是 currentFlushPromise——正在进行的 flush 对应的 Promise。如果调度器正在跑,nextTick 挂到这个 Promise 的 then;如果没在跑,挂到一个已经 resolved 的 Promise(会立刻在下一个 microtask 执行)。
这段代码的精妙不在于用了什么魔法,而在于它站在 JavaScript 微任务系统这个底座上——一个正在排队的 microtask 之后挂新 then,那个 then 就排到同一批 microtask 的末尾、所有更早的 microtask(包括调度器自己的 flush)跑完后才会执行。Vue 不需要自己实现异步原语,复用了 V8 已经优化到极致的 Promise 执行模型。这是”站在巨人肩上”的工程智慧。
// packages/runtime-core/src/scheduler.ts
export function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
就这么简单——nextTick 就是在当前的 flush promise 之后追加一个 .then()。如果当前没有 pending 的 flush,它就追加到一个已 resolved 的 promise 上(立即在微任务中执行)。
这解释了为什么 nextTick 总是在 DOM 更新后执行:DOM 更新发生在 flushJobs 的主队列阶段,而 nextTick 的回调追加在同一个 promise 链的末尾。
12.5 Pre/Post 队列详解
pre 和 post 的概念上面讲过——本节细化每个队列里”到底装了什么”、“以什么顺序跑”。这些细节在日常开发里不一定立刻用得到,但一旦遇到”watch 回调顺序怪怪的”类型的 bug,这一节就是救命稻草。
flushPreFlushCbs
pre 队列由 flushPreFlushCbs 负责清空。它在 flushJobs 主循环的最开头跑——保证 render 看到的状态是”被 pre 回调更新过的”。
这个时机点特别关键的场景:带派生状态的表单验证。用户改了一个字段、某些依赖它的派生状态(比如”输入是否合法”)需要更新、然后 render 要用这个派生状态决定要不要给输入框加红框。flush:pre 在这个场景下就是”让派生状态在 render 之前被刷新”的保证。如果用 flush:post,render 用的是旧派生状态、render 完回调才改、又触发一次 render——多一次 render、还可能闪一下红框。
export function flushPreFlushCbs(
instance?: ComponentInternalInstance,
seen?: CountMap
) {
if (pendingPreFlushCbs.length) {
currentPreFlushParentJob = instance
// 去重
let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0
for (let i = 0; i < activePreFlushCbs.length; i++) {
activePreFlushCbs[i]()
}
currentPreFlushParentJob = null
// 递归处理(pre 回调可能产生新的 pre 回调)
flushPreFlushCbs(instance, seen)
}
}
Pre 队列的典型使用者是 watch 的 flush: 'pre'(默认值)。这意味着 watcher 的回调在组件更新前执行,可以在回调中修改状态而不会导致额外的渲染。
flushPostFlushCbs
post 队列在 flushJobs 主循环里 render 跑完后执行。这个时机点保证 post 回调看到的是”DOM 更新完成的世界”。
常见用法:测量 DOM 高度决定是否启用虚拟滚动、滚动到刚添加的新消息位置、调用第三方库(Chart.js / 地图 SDK)告诉它”容器可用了、开始画吧”。这类”必须等 DOM 就位才能做”的事全部走 post。
生产上一个典型 bug:需求 “切到某个 tab 时要把其中的列表滚动到底部”。开发者写 watch(activeTab, () => scrollToBottom())——发现滚动不到位或者完全没滚。根因是默认 flush:pre,列表还没被 render 出来(或者新数据还没 render),scrollToBottom 拿到的是旧的 scrollHeight。加上 { flush: 'post' } 立刻修好。这种问题几乎只有读过调度器源码的人才能 10 秒内诊断出来。
export function flushPostFlushCbs(seen?: CountMap) {
if (pendingPostFlushCbs.length) {
// 去重并排序
const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0
if (activePostFlushCbs) {
activePostFlushCbs.push(...deduped)
return
}
activePostFlushCbs = deduped
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) {
activePostFlushCbs[postFlushIndex]()
}
activePostFlushCbs = null
postFlushIndex = 0
}
}
Post 队列的使用者包括:mounted / updated 钩子、watch 的 flush: 'post'、watchPostEffect。它们在 DOM 更新完成后执行。
三种 watch 的 flush 策略
Vue 3 的 watch / watchEffect 提供 flush 选项,允许你显式选择回调在何时执行:
flush: 'pre'(默认)—— 在组件 render 之前执行。适合需要”先更新派生状态、再 render”的场景(比如表单验证、数据转换)。flush: 'post'—— 在 DOM 更新之后执行。适合需要读取 DOM 的场景(测量尺寸、滚动到某个位置)。flush: 'sync'—— 立即同步执行,不进队列。适合需要”极小的响应延迟、接受不合并”的场景,比如要求++count之后立刻能观察到新值。
这三个选项对应三种完全不同的”时序需求”——不是随便挑的。flush 选错会造成明显的业务 bug——最常见的是把”要读 DOM 的逻辑”放在默认的 pre 里,结果取到的是上一帧的 DOM,百思不得其解。看到 “watch 里读 DOM 拿到旧值”类型的 bug,第一反应就是检查 flush 选项。
// flush: 'pre'(默认)
watch(source, callback) // 在组件更新前执行
// flush: 'post'
watch(source, callback, { flush: 'post' }) // 在 DOM 更新后执行
// flush: 'sync'
watch(source, callback, { flush: 'sync' }) // 同步执行(谨慎使用)
flush: 'sync' 不走调度器,直接在响应式变化时同步触发。这意味着每次数据变化都会执行回调,失去了批量更新的优化。只在需要立即响应每一次变化时使用。
12.6 调度器的递归保护
调度器面临一个危险场景:回调里又修改了响应式数据。典型例子:
watchEffect(() => {
count.value++ // 在 watchEffect 里改了它的依赖
})
这段代码如果不加保护,会无限递归——watchEffect 因为 count 变化被触发、里面又改 count、又触发、又改……栈溢出。
调度器用多层保护避免这种情况:每个 job 有一个执行计数、同一 tick 内触发超过 100 次认为是循环、直接 warn 并中止。这个阈值 100 是经验值——正常业务代码不会让同一个 job 在同一 tick 跑 100 次以上。
这种防御性设计在系统软件里很常见——既不让用户的错误代码造成整站崩溃、又给出清晰的诊断信息让用户能修复。丛书卷《Rust 编译器与运行时揭秘》第 9 章讲 tokio 的调度器时也提到类似的starvation prevention——长时间占用 executor 的任务会被检测并警告。“防止调度器被用户代码卡死”是任何调度器必须处理的问题。
调度器有一个重要的安全机制——防止无限递归:
const RECURSION_LIMIT = 100
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
if (!seen.has(fn)) {
seen.set(fn, 1)
} else {
const count = seen.get(fn)!
if (count > RECURSION_LIMIT) {
const instance = (fn as ComponentJob).ownerInstance
const componentName = instance && getComponentName(instance.type)
warn(
`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.`
)
return
} else {
seen.set(fn, count + 1)
}
}
}
当一个 watch 回调修改了自己监听的数据源时,就会触发自身的重新执行,形成递归。调度器允许最多 100 次递归后终止并发出警告。
12.7 组件更新的完整时序
把前面各节的碎片拼起来——一次 count.value++ 触发的全部事件序列。这个时序图是全章的综合演练。
这种”把复杂事情压成一个时序图”的做法在系统软件学习里极有用——你不记得每段代码的细节不要紧、但记住一张清晰的时序图,能让你在遇到具体问题时快速定位”问题发生在哪个阶段”。推荐把这张图打印出来贴在显示器旁边,写 Vue 的时候顺手看一眼,建立肌肉记忆。
让我们用一个完整的例子来追踪从”状态变化”到”DOM 更新完成”的全过程:
const App = defineComponent({
setup() {
const count = ref(0)
watch(count, (newVal) => {
console.log('watch callback:', newVal) // Pre 队列
})
onBeforeUpdate(() => {
console.log('beforeUpdate') // 同步,在 render 前
})
onUpdated(() => {
console.log('updated') // Post 队列
})
const increment = () => {
count.value++ // 触发响应式更新
console.log('sync code after mutation')
}
return { count, increment }
}
})
执行 increment() 后的时序:
sequenceDiagram
participant User as increment()
participant Reactive as 响应式系统
participant Scheduler as 调度器
participant Microtask as 微任务
User->>Reactive: count.value++ (触发 trigger)
Reactive->>Scheduler: queueJob(component.update)
Reactive->>Scheduler: queuePreFlushCb(watch callback)
Scheduler->>Scheduler: queueFlush → Promise.then(flushJobs)
User->>User: console.log('sync code after mutation')
Note over User: 同步代码结束
Microtask->>Scheduler: flushJobs 开始
Scheduler->>Scheduler: 1. flushPreFlushCbs
Note right of Scheduler: watch callback: 1
Scheduler->>Scheduler: 2. 执行 component.update
Note right of Scheduler: beforeUpdate(同步)
Note right of Scheduler: render → Diff → DOM 更新
Scheduler->>Scheduler: 3. flushPostFlushCbs
Note right of Scheduler: updated
输出顺序:
sync code after mutation
watch callback: 1
beforeUpdate
updated
12.8 Suspense 对生命周期的影响
Suspense 是 Vue 3 引入的”异步边界”——它让父组件能优雅处理”子组件在异步加载中”的情况。Suspense 激活时,生命周期的常规节奏会被打破——这是让很多开发者懵的原因。
规则核心:Suspense 边界内的子组件的 mounted 钩子,不在子组件自己 mount 时触发,而是等 Suspense 整体 resolve 后统一触发。这让”在 mounted 里测 DOM 高度”这种逻辑在 Suspense 场景下依然可用——如果 Suspense 还没 resolve、DOM 还没进文档,mounted 就压着不 fire。
这种”调整时机以保持契约”的设计是 Vue 3 调度器最成熟的一块代码——它让 Suspense 的使用者可以继续按常识写生命周期、不需要为异步特例额外记忆一套规则。丛书卷《React 19 源码解读》第 10 章讲 React Suspense 时解决的是同类问题,但路径略有不同(React 用 Concurrent Mode 的”延迟提交”处理)——两个框架在这块都投入了巨大的工程资源。
Suspense 改变了组件挂载的语义——异步组件的 mounted 钩子需要等待异步操作完成后才能触发:
// packages/runtime-core/src/components/Suspense.ts(简化)
function mountSuspense(
vnode: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
// ...
) {
const suspense = (vnode.suspense = createSuspenseBoundary(vnode, parentSuspense, parentComponent, container, hiddenContainer, anchor, namespace, slotScopeIds, optimized, rendererInternals))
// 渲染默认内容
patch(null, (suspense.pendingBranch = vnode.ssContent!), hiddenContainer, null, parentComponent, suspense, namespace, slotScopeIds)
if (suspense.deps > 0) {
// 有异步依赖:先显示 fallback
triggerEvent(vnode, 'onPending')
suspense.isInFallback = true
patch(null, vnode.ssFallback!, container, anchor, parentComponent, null, namespace, slotScopeIds)
} else {
// 没有异步依赖:直接 resolve
suspense.resolve(false, true)
}
}
当 Suspense 内的异步组件 resolve 后,其 mounted 钩子才会从 Post 队列中被 flush。这意味着:
// ParentComponent
onMounted(() => {
// 可能在子组件的 mounted 之前执行(如果子组件是异步的)
console.log('parent mounted')
})
// AsyncChildComponent (inside <Suspense>)
const data = await fetchData()
onMounted(() => {
// 在 Suspense resolve 后才执行
console.log('async child mounted')
})
queuePostRenderEffect 的 Suspense 处理
queuePostRenderEffect 是调度器的一个内部 helper——把一个回调插入到”post 队列”或”Suspense 边界的 effects 队列”,具体选哪个取决于调用时是否在 Suspense 上下文里。
这个”根据上下文选择队列”的设计,正是 Suspense 能延迟子组件 mounted 的机制底层。mounted 钩子本质是通过 queuePostRenderEffect 排队的——如果当前在 Suspense 内,就塞进 Suspense 自己的队列;Suspense resolve 时再把这个队列刷到全局 post 队列。一层间接指针,解决整个异步边界的时机问题。
export const queuePostRenderEffect = __FEATURE_SUSPENSE__
? __queuePostRenderEffect
: queuePostFlushCb
function __queuePostRenderEffect(
fn: SchedulerJobs,
suspense: SuspenseBoundary | null
) {
if (suspense && suspense.pendingBranch && !suspense.isResolved) {
// Suspense 还未 resolve:暂存到 suspense 的 effects 列表中
if (isArray(fn)) {
suspense.effects.push(...fn)
} else {
suspense.effects.push(fn)
}
} else {
// 正常入 post 队列
queuePostFlushCb(fn)
}
}
这就是 Suspense 的魔法——它拦截了 mounted 等钩子的入队,暂存到自己的 effects 列表中,等 resolve 时一并释放。
12.9 KeepAlive 与 activated / deactivated
KeepAlive 引入两个独有的钩子——activated 和 deactivated。它们不是普通组件的生命周期一部分,只有被 KeepAlive 包裹的组件会触发。
两者语义:
activated:组件从缓存中被重新激活时触发。相当于”假的 mounted”——组件其实没有真的重新挂载,只是从 DOM 缓存池里拿出来重新展示。deactivated:组件被送入缓存时触发。相当于”假的 unmounted”——组件其实还活着,只是 DOM 从父节点里被摘下来。
这两个钩子的价值在于让开发者能正确地暂停/恢复副作用——在 deactivated 里暂停动画、停止定时器;在 activated 里恢复。如果只用 mounted/unmounted,因为组件实际不 unmount,定时器会一直跑、内存泄漏、CPU 浪费。
这是 Vue 对”有缓存的组件需要独特生命周期语义”的回应——干脆定义一对新钩子、让开发者明确表达。比起”在 mounted 里加 flag 区分是否首次”之类的 hack,这种”为特殊场景加专门 API”的做法直截了当。
KeepAlive 引入了两个额外的生命周期钩子:
// packages/runtime-core/src/components/KeepAlive.ts(简化)
const KeepAliveImpl: ComponentOptions = {
setup(props, { slots }) {
const cache: Cache = new Map()
const keys: Keys = new Set()
let current: VNode | null = null
const instance = getCurrentInstance()!
const sharedContext = instance.ctx as KeepAliveContext
// 激活
sharedContext.activate = (vnode, container, anchor, namespace, optimized) => {
const instance = vnode.component!
// 将缓存的 DOM 移回文档
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
// 可能需要更新 props
patch(instance.vnode, vnode, container, anchor, instance, parentSuspense, namespace, vnode.slotScopeIds, optimized)
queuePostRenderEffect(() => {
instance.isDeactivated = false
// 触发 activated 钩子
if (instance.a) {
invokeArrayFns(instance.a)
}
}, parentSuspense)
}
// 停用
sharedContext.deactivate = (vnode: VNode) => {
const instance = vnode.component!
// 将 DOM 移到隐藏容器(不销毁)
move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
queuePostRenderEffect(() => {
// 触发 deactivated 钩子
if (instance.da) {
invokeArrayFns(instance.da)
}
instance.isDeactivated = true
}, parentSuspense)
}
return () => {
// ... 渲染逻辑:从缓存中取出或创建新的组件 VNode
}
}
}
KeepAlive 的组件不会走正常的 mount / unmount 流程——它们被”停用”到一个隐藏的 DOM 容器中,“激活”时再移回来。这就是为什么 KeepAlive 的组件只触发 activated / deactivated,而不是 mounted / unmounted。
12.10 错误处理链
一个生产级的调度器必须处理回调里的异常——如果某个 render 抛错把整个调度器崩了,整站白屏。Vue 设计了层级化的错误处理链:每个组件可以通过 onErrorCaptured 钩子注册错误处理器,错误沿组件树冒泡直到被某层捕获;都没捕获则冒到 app.config.errorHandler 这个全局 handler;还没有就丢给 console.error。
这种”冒泡 + 末端兜底”的设计和 JavaScript try/catch、React Error Boundary 都是同一家族。丛书卷《React 19 源码解读》详细讲过 Error Boundary 的实现——Vue 的 onErrorCaptured 是对标 API,语义几乎 1:1。
Vue 3 的错误处理不是简单的 try-catch,而是一个沿组件树向上冒泡的链式机制:
// packages/runtime-core/src/errorHandling.ts
export function handleError(
err: unknown,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
throwInDev = true
) {
const contextVNode = instance ? instance.vnode : null
if (instance) {
let cur = instance.parent
const exposedInstance = instance.proxy
// 沿父组件链向上查找 errorCaptured 钩子
while (cur) {
const errorCapturedHooks = cur.ec // errorCaptured 钩子数组
if (errorCapturedHooks) {
for (let i = 0; i < errorCapturedHooks.length; i++) {
// 如果钩子返回 true,表示错误已处理,停止冒泡
if (errorCapturedHooks[i](err, exposedInstance, type) === true) {
return
}
}
}
cur = cur.parent
}
}
// 全局错误处理器
const appErrorHandler = instance?.appContext?.config?.errorHandler
if (appErrorHandler) {
callWithErrorHandling(appErrorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [err, exposedInstance, type])
return
}
// 兜底:输出到控制台
logError(err, type, contextVNode, throwInDev)
}
错误处理的优先级:onErrorCaptured(组件级,逐级冒泡)→ app.config.errorHandler(全局)→ console.error(兜底)。
12.11 调试工具:追踪渲染与触发
讲完原理、该讲”在真实调试场景下怎么用”——因为调度器的问题很多时候只有在特定时刻暴露(时序错、某个回调没跑、更新漏了),要能看到调度器内部状态才能排查。
调度器相关的 bug 有个特点:表现和根因距离远。用户看到”这个数字没更新”——你去查对应的组件,组件代码没问题;去查响应式源,响应式源也没问题;最后发现是某个 watchEffect 的 flush 时机错了、取到了上一帧的值。这种”调试链条长”的场景没有工具就只能凭猜——下面讲的两个钩子是 Vue 官方给的最透彻的内视工具。
Vue 提供了两个专门的钩子——onRenderTracked 和 onRenderTriggered。前者告诉你”render 过程中收集了哪些依赖”——相当于给响应式订阅打点;后者告诉你”是哪个响应式变更触发了这次 render”——相当于给调度器的”上升沿”打点。
配合 Vue DevTools 的 Timeline / Performance 面板一起用,这两个钩子能让你清楚看到每次 render 的因果链:什么时候触发、触发源是什么、依赖了什么、下一次什么时候会再触发。这种调试能力不是”锦上添花”——是生产级 Vue 应用里排查性能和时序问题的核心武器。
Vue 3 提供了两个调试专用的生命周期钩子:
onRenderTracked((event) => {
// 响应式依赖被追踪时触发
console.log('tracked:', event)
// event: { effect, target, type, key }
})
onRenderTriggered((event) => {
// 响应式变化触发重渲染时
console.log('triggered:', event)
// event: { effect, target, type, key, newValue, oldValue }
})
这两个钩子在生产环境中被 tree-shake 掉(通过 __DEV__ 编译时条件),所以不会有运行时开销。
// packages/runtime-core/src/renderer.ts
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
NOOP,
() => queueJob(instance.update),
instance.scope
))
if (__DEV__) {
effect.onTrack = instance.rtc
? e => invokeArrayFns(instance.rtc!, e)
: void 0
effect.onTrigger = instance.rtg
? e => invokeArrayFns(instance.rtg!, e)
: void 0
effect.ownerInstance = instance
}
12.12 本章小结
生命周期和调度器是 Vue 3 运行时的”时间维度”——它们决定了”什么时候做什么”:
-
生命周期钩子通过
injectHook注册到组件实例上,每个钩子都被包装以确保正确的currentInstance上下文和错误处理。 -
调度器维护三级队列(Pre → 主 → Post),通过微任务实现异步批量更新。同一个组件的多次状态变化只触发一次渲染。
-
排序保证父到子的更新顺序(uid 递增),避免子组件的双重渲染。
-
nextTick 就是追加到当前 flush promise 的
.then(),保证在 DOM 更新后执行。 -
Suspense 拦截异步组件的
mounted等钩子,暂存到自己的 effects 列表中,resolve 后统一释放。 -
KeepAlive 用
activated/deactivated替代mounted/unmounted,通过隐藏容器实现 DOM 的保留与复用。 -
错误处理沿组件树冒泡:
onErrorCaptured→app.config.errorHandler→console.error。
思考题
-
如果在
onUpdated钩子中修改了一个响应式变量,会发生什么?调度器如何处理这种情况? -
为什么
watch默认使用flush: 'pre'而不是flush: 'post'?在什么场景下你应该使用flush: 'post'? -
nextTick和queuePostFlushCb的回调执行顺序是怎样的?如果在一个watch回调中调用nextTick,回调会在什么时候执行? -
考虑一个深层嵌套的组件树(A → B → C → D),当 A 和 C 同时触发更新时,调度器如何保证正确的更新顺序?
-
KeepAlive 组件的缓存上限(
maxprop)是如何实现的?当缓存满了时,哪个组件会被淘汰?这使用了什么策略?
回到那个最初的问题
回到本章开头那句被低估的话——“调度器是前端框架最容易被低估的部分”。读到这里,你应该能感受到这句话背后的分量:所有我们日常写 Vue 时觉得”理所应当”的体验——多次 setState 只 render 一次、父组件先于子组件更新、nextTick 里能读到最新 DOM、watch 默认拿到新数据前的旧 DOM——都不是”编译器自动帮我做了”,而是调度器在运行时一次次把乱序的触发按正确顺序排列起来。如果把调度器换成最朴素的”谁改谁立刻渲染”,React 曾经的”cascading update 雪崩”、Angular 曾经的”脏检查抖动”会立刻在 Vue 身上重演。框架之间的差距,很多时候就在这种”用户看不到但每一帧都在起作用”的地方拉开。
把调度器放到更大的图景里看
调度器的意义不止在”性能优化”——它是响应式系统与 DOM 之间的阻抗匹配层。响应式系统按”事件**“工作(一个 set 触发一批 effect),DOM 按”帧”工作(一帧一次 reflow / paint)。中间没有调度器,两端的节奏就会错位——要么一帧里跑几百次无意义的 effect(性能崩了),要么 effect 漏掉某一帧里的更新(正确性崩了)。调度器做的事,本质上是把”事件驱动”翻译成”帧驱动”**——和 React 的 Fiber、浏览器的 requestAnimationFrame、游戏引擎的主循环是同一层设计思想的不同变体。理解了这一点,你看 React 的 concurrent rendering、Svelte 的 invalidate 机制、SolidJS 的细粒度 reactivity,就不再是”另一套 API”,而是”对同一个问题的另一种回答”。
和前面章节的呼应
本章多次引用了前面章节的内容——这不是偶然,而是因为生命周期和调度器本身就是整个运行时的”指挥棒”:第 9 章讲的响应式 effect 最终要被调度器 flush;第 10 章讲的组件实例在每个生命周期节点都要被正确设置为 currentInstance;第 11 章讲的 patch 过程就是 componentUpdateFn 在主队列里的执行体;第 14 章将讲到的 <Transition> 会依赖本章的 post queue 保证”DOM 插入后再启动动画”。你如果回头再读这几章,会发现它们其实早就在为本章铺路——Vue 3 的源码组织得非常像一本按主题展开的教科书,只是每个主题都只讲了”自己这一面”,要把它们串起来才能看到运行时的全貌。
进一步阅读
- Vue 3 源码
packages/runtime-core/src/scheduler.ts:整份文件只有 300 行左右,强烈建议从头到尾通读一遍,你会发现”三级队列 + flush 状态机”的核心逻辑比想象中简单得多,真正复杂的是边界条件。 - Vue 3 源码
packages/runtime-core/src/apiLifecycle.ts:injectHook的完整实现只有 40 行,是理解”为什么 setup 里能写onMounted”的最短路径。 - React 源码
packages/scheduler/src/forks/Scheduler.js:和 Vue scheduler 对比阅读,你会看到”微任务调度”和”时间切片 + 优先级”两种哲学的差异——前者拼正确性,后者拼长任务友好性。 - 浏览器 Event Loop 规范(HTML Standard 8.1.4):Vue 的 nextTick 之所以选择 microtask,根本依据就在这份规范里——理解了 task 和 microtask 的执行时机,你才能真正理解
flush: 'pre' / 'post'的设计取舍。
下一章讨论指令系统——指令的 beforeMount / mounted / beforeUpdate / updated / beforeUnmount / unmounted 钩子触发时机都由本章的 scheduler 决定,建议对照阅读。