Vue 3 设计与实现

第 12 章 生命周期与调度

作者 杨艺韬 · 13,083 字 · 发布于 · 更新于

第 12 章 生命周期与调度

本章要点

  • 组件生命周期的完整图谱:从 setup 到 unmounted,每个钩子的触发时机和内部实现
  • 生命周期钩子的注册机制:injectHook 如何将钩子函数绑定到组件实例
  • 调度器的核心设计:异步批量更新的队列模型和 flush 时机
  • nextTick 的实现本质:为什么它能保证在 DOM 更新后执行
  • 队列的优先级:pre job、组件更新 job 与 post 队列的协作关系
  • Suspense 对生命周期的影响:异步组件如何改变钩子触发顺序
  • 调度器的错误处理与递归保护机制

在上一章中,我们深入了虚拟 DOM 和 Diff 算法——这是 Vue 渲染管线的"引擎"。但引擎不能随意启动,它需要一个精密的调度系统来协调"何时更新"、"以什么顺序更新"、"更新完之后做什么"。

同时,每个组件都有自己的"生命"——从诞生到消亡,在不同的阶段,开发者需要介入执行特定的逻辑。这就是生命周期系统的使命。

调度器和生命周期看似独立,实则紧密耦合。生命周期钩子的触发时机由调度器控制,而调度器的行为又受组件状态(是否挂载、是否激活)的制约。本章将一并剖析。

调度器:前端框架最容易被低估的部分

先说一句可能颠覆你认知的话——Vue 3 相对 Vue 2 的真正技术跃迁,调度器比响应式更大。

响应式从 Object.defineProperty 到 Proxy,性能提升、能监听新增属性、语法更整洁——这些是看得见的改进,也是每个讲 Vue 3 的文章都会提的。但调度器的改进更"深":Vue 2 也有独立的调度模块(同样按 watcher id 排序保证父先于子),但它围绕 watcher 对象搭建,2.7 之前的用户 watcher 没有 flush: 'post' 这样的时机选项,和副作用冲突时没法精细控制。Vue 3 的调度器把"何时 render"、"何时触发 watchEffect"、"何时执行生命周期钩子"全部放在一个统一的队列模型里——你能想象到的每一种边界情况,都有专门的策略。

这个调度器的重要性在于:它是所有"魔法表现"背后的指挥家。你写 count.value++; console.log(count.value) 然后没有立即看到 DOM 变化——那是调度器在工作;你写 watch(x, ..., { flush: 'post' }) 让回调在 DOM 更新后触发——那是调度器在工作;你写 nextTick 等 render 完成——那是调度器在工作。这些 API 的"表现"不是凭空来的,都是调度器用队列顺序一步步兑现的。

可以说,调度器是框架和"只是模板引擎"之间的分水岭。Vue 3 的调度器设计在这条分水岭上走得相当远。

本章的导图

本章的 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 官方文档也明言 React Hooks 是 Composition API 的主要灵感来源。

在 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 之前——setup() 由 setupStatefulComponent 调用(packages/runtime-core/src/component.ts:947),而 Options API 的 beforeCreate / created 要等后面的 finishComponentSetup → applyOptions 才跑(packages/runtime-core/src/component.ts:1149;beforeCreate 在 packages/runtime-core/src/componentOptions.ts:533,created 在 packages/runtime-core/src/componentOptions.ts:714 的 LifecycleHooks.CREATED 调用处)。所以 Composition API 中没有这两个钩子的对应函数——setup 覆盖了它们的用途。(这一点与 Vue 2.7 / @vue/composition-api 插件不同,那里 setup 确实夹在两者之间。)

12.2 钩子注册机制:injectHook

Composition API 的 onMounted(() => ...) 能"知道"自己属于哪个组件,这是整个 Vue 运行时最容易被当做魔法的部分。这一节把这个魔法拆开——核心只有两个概念:currentInstance 作为瞬态全局上下文 + injectHook 把函数存到组件实例上。

第 10 章讲 setup 时提过 currentInstance 的机制——在调 setup 之前 setCurrentInstance、调完 restoreCurrentInstance 还原,这期间 onMounted 这些 API 可以通过全局 currentInstance 变量感知到"我属于哪个组件"。本节进一步讲:感知到之后,钩子函数怎么被存起来、什么时候被触发。

答案出奇朴素——每个组件实例上有几个数组字段(bm / m / bu / u / bum / um 等),onMounted(cb) 就是往 currentInstance.m.push(cb)。生命周期到达对应节点时,渲染器(直接或经调度器的 post 队列)遍历这个数组把每个 cb 跑一遍。没有魔法,只有"在对的时间访问对的全局变量"这个朴素机制。

所有 onXxx 函数内部都调用同一个底层函数 injectHook:

// packages/runtime-core/src/apiLifecycle.ts:66
// createHook 只是一层柯里化
const createHook =
  <T extends Function = () => any>(lifecycle: LifecycleHooks) =>
  (hook: T, target: GenericComponentInstance | null = currentInstance): void => {
    // SSR 的 setup 阶段不注册客户端钩子(onServerPrefetch 除外)
    if (!isInSSRComponentSetup || lifecycle === LifecycleHooks.SERVER_PREFETCH) {
      injectHook(lifecycle, (...args: unknown[]) => hook(...args), target)
    }
  }

// packages/runtime-core/src/apiLifecycle.ts:85-94(省略了 CreateHook 类型标注)
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)

injectHook 的核心逻辑:

// packages/runtime-core/src/apiLifecycle.ts:17
export function injectHook(
  type: LifecycleHooks,
  hook: Function & { __weh?: Function },
  target: GenericComponentInstance | null = currentInstance,
  prepend: boolean = false
): Function | undefined {
  if (target) {
    // 获取或创建钩子数组
    // 组件实例上用简写存储:bm=beforeMount, m=mounted 等
    const hooks = target[type] || (target[type] = [])

    // 包装钩子函数(__weh = "with error handling"),确保调用时 currentInstance 正确
    const wrappedHook =
      hook.__weh ||
      (hook.__weh = (...args: unknown[]) => {
        // 清空当前活跃订阅者,防止钩子中的响应式访问被外层 effect 错误收集
        const prevSub = setActiveSub()
        // 设置当前实例,确保钩子内部能访问组件上下文
        const prev = setCurrentInstance(target)
        try {
          // 调用钩子,捕获错误
          return callWithAsyncErrorHandling(hook, target, type, args)
        } finally {
          restoreCurrentInstance(prev)
          setActiveSub(prevSub)
        }
      })

    if (prepend) {
      hooks.unshift(wrappedHook)
    } else {
      hooks.push(wrappedHook)
    }

    return wrappedHook
  } else if (__DEV__) {
    warn(`... is called when there is no active component instance ...`)
  }
}

几个关键设计点:

  1. currentInstance 绑定:钩子函数在注册时捕获当前组件实例,调用时恢复。这保证了即使钩子被延迟执行(如 mounted 在异步 flush 中执行),也能正确访问组件上下文。

  2. 切断依赖追踪:setActiveSub() 不带参数调用会把"当前活跃订阅者"置空(3.6 里 pauseTracking 本身也只是它的一层封装),钩子函数中的响应式访问不会被收集为任何 effect 的依赖,否则会导致不可预期的重渲染。

  3. 错误处理:所有钩子调用都通过 callWithAsyncErrorHandling 包装,支持 onErrorCaptured 的错误冒泡机制。

  4. 注册时机守卫:没有 currentInstance 时(例如在 setup 之外、或 await 之后调用)开发环境会警告;SSR 的 setup 阶段则直接跳过客户端钩子的注册。

生命周期枚举

Vue 把生命周期的种类定义成一个枚举——每个钩子对应枚举里的一个字符串键(packages/runtime-core/src/enums.ts:1-16,14 项与下表逐一对应;injectHook 本体在 packages/runtime-core/src/apiLifecycle.ts:17-63,实测 47 行)。这个选择的价值在于让 injectHook 不关心具体类型——它是一个通用工具,接受"任意生命周期 kind + 回调函数",做完全一致的处理(包一层错误处理、绑 instance、push 到对应数组)。

这种用字符串枚举做类型维度的做法比"为每种钩子写专用注册函数"好很多——扩展性强(新增一个钩子只要加一个枚举项),代码复用(onMounted / onBeforeMount 等 API 都是 createHook('bm' | 'm') 的简单包装)。这是软件工程里"数据表驱动 vs 硬编码"的经典抉择——选数据表驱动的一方。

// packages/runtime-core/src/enums.ts:1
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——它们直接作为组件实例的属性名。这不是偷懒,而是刻意的优化:这些键在运行时是字符串,压缩器无法重命名,短名字直接省下产物体积。

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:1458(简化)
// rc.4 把渲染 effect 写成 ReactiveEffect 的子类,fn() 就是原来的 componentUpdateFn
class SetupRenderEffect extends ReactiveEffect {
  job: SchedulerJob

  constructor(private instance, private initialVNode, private container, private anchor,
              private parentSuspense, private namespace, private optimized) {
    super()
    // 调度器里排队的是 job,而不是 effect 本身
    this.job = instance.job = () => {
      if (this.dirty) {
        this.run()
      }
    }
    this.job.i = instance
  }

  // 响应式触发链:propagate 打上 Pending 标志(reactive 写入再由 shallowPropagate 升为 Dirty)→ effect.notify() → queueJob
  notify(): void {
    if (!(this.flags & EffectFlags.PAUSED)) {
      const job = this.job
      queueJob(job, job.i!.uid)
    }
  }

  fn() {
    const { instance, initialVNode, container, anchor, parentSuspense, namespace, optimized } = this
    if (!instance.isMounted) {
      // ======== 首次挂载 ========
      const { bm, parent } = instance

      // 触发 beforeMount
      toggleRecurse(instance, false)
      if (bm) {
        invokeArrayFns(bm)
      }
      toggleRecurse(instance, true)

      // 执行渲染函数
      const subTree = (instance.subTree = renderComponentRoot(instance))

      // 递归 patch(创建真实 DOM)
      patch(null, subTree, container, anchor, instance, parentSuspense, namespace)

      // 真实 DOM 已创建,保存引用
      initialVNode.el = subTree.el

      // 触发 mounted(通过调度器 post 队列延迟执行)
      if (instance.m) {
        queuePostRenderEffect(instance.m, undefined, parentSuspense)
      }

      instance.isMounted = true

    } else {
      // ======== 更新 ========
      let { next, bu, u, parent, vnode } = instance

      toggleRecurse(instance, false)
      if (next) {
        next.el = vnode.el
        updateComponentPreRender(instance, next, optimized)
      } else {
        next = vnode
      }

      // 触发 beforeUpdate
      if (bu) {
        invokeArrayFns(bu)
      }
      toggleRecurse(instance, true)

      // 执行渲染函数
      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, undefined, parentSuspense)
      }
    }
  }
}

const setupRenderEffect: SetupRenderEffectFn = (
  instance, initialVNode, container, anchor, parentSuspense, namespace, optimized
) => {
  const effect = (instance.effect = new SetupRenderEffect(
    instance, initialVNode, container, anchor, parentSuspense, namespace, optimized
  ))
  instance.update = effect.run.bind(effect)

  // #1801, #2043 渲染 effect 允许递归触发自身
  toggleRecurse(instance, true)

  // 首次执行
  effect.run()
}

这里能看清响应式系统和调度器的接缝:3.6 的 ReactiveEffect 不再接受 scheduler 参数,SetupRenderEffect 通过覆写 notify() 把"依赖变了"翻译成 queueJob(job, uid);job 再在 flush 时检查 dirty(拉校验)决定要不要真的 run()。

注意 beforeMount 和 beforeUpdate 是同步调用的(invokeArrayFns 直接执行),而 mounted 和 updated 是通过 queuePostRenderEffect 延迟到 post 队列执行的。

这意味着:

  • beforeMount 时,DOM 还不存在
  • mounted 时,DOM 已经创建并插入到文档中(因为 post 队列在所有 DOM 操作完成后执行)
  • beforeUpdate 时,数据已变但 DOM 还是旧的
  • updated 时,DOM 已经更新完成

卸载流程

组件卸载是一个需要严格按序收尾的过程——顺序错了就是资源泄漏、事件监听器还在工作但组件已经消失这种"鬼魂"bug。

顺序:beforeUnmount(让用户代码有机会清理)→ 停掉 effectScope 与渲染 effect(取消所有响应式订阅)→ 卸载子树(递归卸载所有子组件、移除 DOM)→ 触发 unmounted(用户最后的收尾机会,走 post 队列)→ 实例垃圾回收。

这个顺序有几个不能颠倒的点:beforeUnmount 必须在 effect 清理前——此时组件还是"活的",watch / computed 都还在工作;unmounted 必须在卸载子树之后、经 post 队列触发——这样用户清理 DOM 引用时元素通常已不在文档里;子组件的卸载必须在父 unmounted 前——自底向上的析构符合常识。

这些"小细节"合起来让 Vue 的卸载行为在所有边界场景下都符合直觉——开发者不需要记住一堆时序规则、按常识写就是对的。

// packages/runtime-core/src/renderer.ts:2606(简化)
const unmountComponent: UnmountComponentFn = (instance, parentSuspense, doRemove) => {
  const { bum, scope, effect, subTree, um, m, a } = instance
  // 还没来得及 flush 的 mounted / activated 钩子直接作废
  invalidateMount(m)
  invalidateMount(a)

  // 触发 beforeUnmount
  if (bum) {
    invokeArrayFns(bum)
  }

  // 停止组件的 effect scope(清理所有 watch、computed 等副作用)
  scope.stop()

  // 停止渲染 effect,再卸载子树
  if (effect) {
    effect.stop()
    unmount(subTree, instance, parentSuspense, doRemove)
  }

  // 触发 unmounted(post 队列)
  if (um) {
    queuePostRenderEffect(um, undefined, parentSuspense)
  }

  // 标记卸载状态
  queuePostRenderEffect(() => (instance.isUnmounted = true), undefined, parentSuspense)
}

beforeUnmount 是同步的——此时组件的 DOM 还在,你可以做最后的 DOM 操作。unmounted 是异步的——此时 DOM 通常已被移除(外层有 <Transition> 离场动画时元素可能还在,见第 13 章 13.9 节),适合做清理工作(如取消事件监听、断开 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 变化都立刻触发重渲染,那么一次点击会导致三次渲染——显然是浪费。调度器的核心任务就是将同步代码中的多次状态变化合并为一次更新。

队列模型:一个有序 jobs 数组 + 一个 post 队列

Vue 3 的调度器早期版本有三个独立数组(pre / queue / post)。到 rc.4 只剩两个数组:jobs 和 postJobs。pre 回调不再单独排队,而是和组件更新 job 放在同一个 jobs 数组里、靠 order 字段区分先后:

  • pre job:render 之前执行(例如 watch 默认的 flush: 'pre'),order = uid * 2(偶数)。典型场景是"我想在 render 读到新值之前更新某些状态"。
  • 组件更新 job:组件 render 本身,order = uid * 2 + 1(奇数)。父组件 uid 小、子组件 uid 大,所以按 order 升序排是"父先于子",而同一个组件的 pre job 总是紧贴在它自己的 render 之前。
  • post 队列(postJobs):render 之后执行(例如 watch(..., { flush: 'post' })、mounted / updated 钩子),典型场景是"我想在 DOM 更新后测量/操作 DOM"。

没有关联组件的 job 也有位置:pre 排在最前(order = -2),普通 job 排在最后(order = Infinity)。

每次 flush 先跑完 jobs,再跑 postJobs。一个 flush 可能跑多轮——因为 pre 回调里可能触发新的 trigger、然后 render 又可能触发新的 post 回调、甚至 post 回调本身可能修改响应式数据又触发新的 render。调度器用循环 + 去重处理这种嵌套,直到所有队列都为空。

这种"分阶段流水线 + 循环 flush"的设计在调度器里很常见——想象一个 OS 的事件循环,或者一个 GPU 的 render pipeline,都是类似的"阶段化 + 相邻阶段可以触发回退"结构——调度的本质问题在所有领域里都有相似的解法。

rc.4 调度器的核心状态:

// packages/runtime-core/src/scheduler.ts:5
export enum SchedulerJobFlags {
  QUEUED = 1 << 0,          // 已在队列里(去重用)
  ALLOW_RECURSE = 1 << 1,   // 允许 job 在执行期间再次入队
  DISPOSED = 1 << 2,        // 已作废,flush 时跳过
  REQUEUE_ON_SUSPENSE_DISCARD = 1 << 3,
}

export interface SchedulerJob extends Function {
  order?: number            // 排序键:pre = uid*2,组件更新 = uid*2+1
  flags?: SchedulerJobFlags
  i?: GenericComponentInstance  // 所属组件实例
}

const jobs: SchedulerJob[] = []       // pre job + 组件更新 job,按 order 有序
let postJobs: SchedulerJob[] = []     // post 回调
let activePostJobs: SchedulerJob[] | null = null
let currentFlushPromise: Promise<void> | null = null
sequenceDiagram
    participant User as 用户代码
    participant Scheduler as 调度器
    participant Jobs as jobs(pre + 组件更新)
    participant PostQ as postJobs
    participant DOM as 真实 DOM
    User->>Scheduler: 状态变化 → queueJob
    Scheduler->>Scheduler: resolvedPromise.then(flushJobs)
    Note over Scheduler: 微任务开始
    Scheduler->>Jobs: 1. 按 order 逐个执行
    Jobs->>Jobs: 父 pre → 父 render → 子 pre → 子 render …
    Jobs->>DOM: 组件渲染/更新
    Scheduler->>PostQ: 2. flushPostFlushCbs
    PostQ->>PostQ: 执行 mounted/updated/watch(post)

执行顺序:jobs(每个组件的 pre job 紧接它自己的更新 job)→ Post 队列。

  • pre job:watch / watchEffect(flush: 'pre')的回调
  • 组件更新 job:组件的渲染更新函数(instance.job)
  • Post 队列:mounted、updated 钩子、watch(flush: 'post')的回调

queueJob:入队逻辑

入队是调度器最被频繁调用的函数——每次响应式 trigger 都会走一次 queueJob。设计追求快、去重、顺序正确三件事。

快:用数组而不是链表、用二分查找定位插入位置、用数字 order 比较。去重:同一个 job 不能入队两次(靠 SchedulerJobFlags.QUEUED 这一位判断,不需要 includes 扫描)。顺序:入队按 order 升序插入(父组件在前、子组件在后)。

这几件事单独看都朴素,合起来构成了调度器在高频调用下依然轻量的性能基础。读这一段代码最能体会到"热点路径的代码要多么精打细算"。

// packages/runtime-core/src/scheduler.ts:103(把源码里内联的嵌套三元提成局部变量 jobOrder,逻辑等价)
export function queueJob(job: SchedulerJob, id?: number, isPre = false, order = 0): void {
  // 把 (uid, isPre, order) 编码成一个排序键。order 那一支用的是
  // `order / (order + 1)`——单调且恒小于 1,于是同组件内的 Vapor effect 能分出
  // 先后,又不会越过下一个组件的 id * 2 + 2(源码 :112-122)
  const jobOrder =
    id === undefined
      ? isPre ? -2 : Infinity
      : isPre
        ? id * 2
        : order
          ? id * 2 + 1 + order / (order + 1)
          : id * 2 + 1
  if (queueJobWorker(job, jobOrder, jobs, jobsLength, flushIndex)) {
    jobsLength++
    queueFlush()
  }
}

function queueJobWorker(job, order, queue, length, flushIndex) {
  const flags = job.flags!
  // 去重:已在队列里的 job 不再入队
  if (!(flags & SchedulerJobFlags.QUEUED)) {
    job.flags! = flags | SchedulerJobFlags.QUEUED
    job.order = order
    if (flushIndex === length || order >= queue[length - 1].order!) {
      queue[length] = job          // 常见情况:直接追加到末尾
    } else {
      // 二分查找插入位置(只在尚未执行的区间 [flushIndex, length) 里找)
      queue.splice(findInsertionIndex(order, queue, flushIndex, length), 0, job)
    }
    return true
  }
  return false
}

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

queueFlush 使用 Promise.resolve().then() 将 flush 操作推入微任务队列。这保证了当前同步代码全部执行完毕后,才开始处理更新。currentFlushPromise 同时兼任"是否已经安排了 flush"的标志——rc.4 里没有 isFlushPending,isFlushing 也只剩应用挂载时的 flushOnAppMount 在用。

flushJobs:调度主循环

flushJobs 是整个调度器的心脏——在 microtask 里被触发一次、负责把 jobs 与 postJobs 两个队列按正确顺序跑完一轮。

主循环的复杂度来自"flush 过程中新增的 job 怎么办"——前面讲过,pre job、render job 和 post 回调执行时都可能产生新的 job。flushJobs 的答案是jobs 与 postJobs 各自用 while 循环处理到队列为空、跑完一轮再检查是否有剩余,而不是简单地"遍历初始快照"——这样新增的 job 会被当前 flush cycle 处理完。

但"处理到为空"有死循环风险——如果回调总是往同一队列里加新 job(见 12.6 的递归情况)。开发构建里 flushJobs 用"同一 job 在本轮 flush 中的执行计数"做保护,超过阈值就经 handleError 报错并跳过这个 job。这是调度器"尽量让用户代码跑完但绝不自己死循环"的设计。

// packages/runtime-core/src/scheduler.ts:281
function flushJobs(seen?: CountMap) {
  if (__DEV__) {
    seen ||= new Map()
  }

  try {
    // 1. 执行 jobs:pre job 和组件更新 job 已按 order 有序,无需再 sort
    while (flushIndex < jobsLength) {
      const job = jobs[flushIndex]
      jobs[flushIndex++] = undefined as any

      if (!(job.flags! & SchedulerJobFlags.DISPOSED)) {
        // 开发环境:递归保护
        if (__DEV__ && checkRecursiveUpdates(seen!, job)) {
          continue
        }
        // 允许递归的 job(组件更新 job、watch 回调)先清掉 QUEUED,让它在执行期间还能再入队
        if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
          job.flags! &= ~SchedulerJobFlags.QUEUED
        }
        try {
          job()
        } catch (err) {
          handleError(err, job.i, job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER)
        } finally {
          if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
            job.flags! &= ~SchedulerJobFlags.QUEUED
          }
        }
      }
    }
  } finally {
    // 2. 清理(异常中断时也要把剩余 job 的 QUEUED 位清掉)
    while (flushIndex < jobsLength) {
      jobs[flushIndex].flags! &= ~SchedulerJobFlags.QUEUED
      jobs[flushIndex++] = undefined as any
    }
    flushIndex = 0
    jobsLength = 0
    jobs.length = 0

    // 3. 执行 Post 队列
    flushPostFlushCbs(seen)

    // 4. 复位状态
    currentFlushPromise = null

    // 5. 如果在 flush 过程中有新的 job 入队,递归 flush
    if (jobsLength || postJobs.length) {
      flushJobs(seen)
    }
  }
}

这里没有 queue.sort(...)——排序在入队时就完成了(findInsertionIndex 二分插入),flush 只管从头到尾跑。

排序的意义:组件的 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,queueFlush 一安排 flush 就设上它。如果已经安排了 flush,nextTick 挂到这个 Promise 的 then;如果没有,挂到一个已经 resolved 的 Promise(会立刻在下一个 microtask 执行)。

这段代码的精妙不在于用了什么魔法,而在于它站在 JavaScript 微任务系统这个底座上——一个正在排队的 microtask 之后挂新 then,那个 then 就排到同一批 microtask 的末尾、所有更早的 microtask(包括调度器自己的 flush)跑完后才会执行。Vue 不需要自己实现异步原语,复用了 JavaScript 引擎已经优化到极致的 Promise 执行模型。这是"站在巨人肩上"的工程智慧。

// packages/runtime-core/src/scheduler.ts:61-72(简化:源码是三个重载签名,这里合并成一个)
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 job 与 post 队列详解

pre 和 post 的概念上面讲过——本节细化 pre job 和 post 队列各自"到底装了什么"、"以什么顺序跑"。这些细节在日常开发里不一定立刻用得到,但一旦遇到"watch 回调顺序怪怪的"类型的 bug,这一节就是救命稻草。

flushPreFlushCbs

pre job 有两条出路。常规路径是留在 jobs 里,靠偶数 order 排在自己组件的 render 之前被 flushJobs 顺带跑掉。另一条是 flushPreFlushCbs(instance):父组件 render 时通过 updateComponentPreRender 更新了子组件的 props,props 变化触发的 pre watcher 此时还排在队列后面——于是在子组件 render 之前主动把这个实例的 pre job 抽出来先执行。两条路都在保证 render 看到的状态是"被 pre 回调更新过的"。

这个时机点特别关键的场景:带派生状态的表单验证。用户改了一个字段、某些依赖它的派生状态(比如"输入是否合法")需要更新、然后 render 要用这个派生状态决定要不要给输入框加红框。flush:pre 在这个场景下就是"让派生状态在 render 之前被刷新"的保证。如果用 flush:post,render 用的是旧派生状态、render 完回调才改、又触发一次 render——多一次 render、还可能闪一下红框。

// packages/runtime-core/src/scheduler.ts:197(签名在源码里折成三行)
export function flushPreFlushCbs(instance?: GenericComponentInstance, seen?: CountMap): void {
  if (__DEV__) {
    seen = seen || new Map()
  }
  for (let i = flushIndex; i < jobsLength; i++) {
    const cb = jobs[i]
    // 只处理 pre job:order 为偶数(奇数是组件更新,Infinity 是无 id 的普通 job)
    if (cb.order! & 1 || cb.order === Infinity) {
      continue
    }
    // 指定了 instance 时只处理属于它的 pre job
    if (instance && cb.order !== instance.uid * 2) {
      continue
    }
    if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
      continue
    }
    // 从队列里摘出来立刻执行
    jobs.splice(i, 1)
    i--
    jobsLength--
    if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
      cb.flags! &= ~SchedulerJobFlags.QUEUED
    }
    cb()
    if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
      cb.flags! &= ~SchedulerJobFlags.QUEUED
    }
  }
}

pre job 的典型来源是 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' } 立刻修好。读过调度器源码的人,能很快诊断出这种问题。

// packages/runtime-core/src/scheduler.ts:228
export function flushPostFlushCbs(seen?: CountMap): void {
  if (postJobs.length) {
    // 已经在 flush post 队列时又来了新的 post job:追加到正在执行的数组尾部
    if (activePostJobs) {
      for (let i = 0; i < postJobs.length; i++) {
        activePostJobs.push(postJobs[i])
      }
      postJobs.length = 0
      return
    }

    // 入队时已按 id 有序(queuePostFlushCb 同样走 queueJobWorker),不再 sort
    activePostJobs = postJobs
    postJobs = []

    if (__DEV__) {
      seen = seen || new Map()
    }

    while (postFlushIndex < activePostJobs.length) {
      const cb = activePostJobs[postFlushIndex++]
      if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
        continue
      }
      if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
        cb.flags! &= ~SchedulerJobFlags.QUEUED
      }
      if (!(cb.flags! & SchedulerJobFlags.DISPOSED)) {
        try {
          cb()
        } finally {
          cb.flags! &= ~SchedulerJobFlags.QUEUED
        }
      }
    }

    activePostJobs = 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、又触发、又改……栈溢出。

Vue 用多层保护避免这种情况:默认不允许 effect / job 在自己执行期间再次触发自己(上面这个 watchEffect 就属于这种情况,实际只会跑一次),只有组件更新 job 和带回调的 watch 打开了 ALLOW_RECURSE;对允许递归的 job,开发构建里还有执行计数,同一轮 flush 内执行超过 100 次就认为是循环,经 handleError 报错并跳过它。这个阈值 100 是经验值——正常业务代码不会让同一个 job 在同一 tick 跑 100 次以上。

这种防御性设计在系统软件里很常见——既不让用户的错误代码造成整站崩溃、又给出清晰的诊断信息让用户能修复。《Tokio 源码深度解析》专栏第 5 章讲多线程调度器时也有类似的反饥饿设计——连续从 LIFO slot 取任务会被计数封顶,每隔若干次调度还要强制去全局队列取一个,防止某几个任务一直霸占 worker。"防止调度器被用户代码卡死"是任何调度器必须处理的问题。

调度器有一个重要的安全机制——防止无限递归:

// packages/runtime-core/src/scheduler.ts:57
const RECURSION_LIMIT = 100

// 只在 __DEV__ 下被调用;返回 true 表示这个 job 本轮应被跳过
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
  }
  seen.set(fn, count + 1)
  return false
}

注意这层保护只存在于开发构建(__DEV__)——seen 这张计数表在生产构建里根本不会创建。

当一个 watch 回调修改了自己监听的数据源时,就会触发自身的重新执行,形成递归。开发构建里调度器允许它在一轮 flush 中执行约 100 次,之后经 handleError 报错(没有配置 errorHandler 时开发环境默认直接抛出)并跳过它。

12.7 组件更新的完整时序

把前面各节的碎片拼起来——一次 count.value++ 触发的全部事件序列。这个时序图是全章的综合演练。

这种"把复杂事情压成一个时序图"的做法在系统软件学习里极有用——你不记得每段代码的细节不要紧、但记住一张清晰的时序图,能让你在遇到具体问题时快速定位"问题发生在哪个阶段"。推荐把这张图打印出来贴在显示器旁边,写 Vue 的时候顺手看一眼,建立肌肉记忆。

让我们用一个完整的例子来追踪从"状态变化"到"DOM 更新完成"的全过程:

const App = defineComponent({
  setup() {
    const count = ref(0)

    watch(count, (newVal) => {
      console.log('watch callback:', newVal)  // pre job
    })

    onBeforeUpdate(() => {
      console.log('beforeUpdate')  // 同步,在 render 前
    })

    onUpdated(() => {
      console.log('updated')  // Post 队列
    })

    const increment = () => {
      count.value++  // 触发响应式更新
      console.log('sync code after mutation')
    }

    return { count, increment }  // 假设模板里渲染了 {{ count }}
  }
})

执行 increment() 后的时序:

sequenceDiagram
    participant User as increment()
    participant Reactive as 响应式系统
    participant Scheduler as 调度器
    participant Microtask as 微任务
    User->>Reactive: count.value++ (触发 trigger)
    Reactive->>Scheduler: queueJob(watchJob, uid, true)  → order = uid*2
    Reactive->>Scheduler: queueJob(componentJob, uid)   → order = uid*2+1
    Scheduler->>Scheduler: queueFlush → resolvedPromise.then(flushJobs)
    User->>User: console.log('sync code after mutation')
    Note over User: 同步代码结束
    Microtask->>Scheduler: flushJobs 开始
    Scheduler->>Scheduler: 1. 执行 watchJob(pre,order 偶数在前)
    Note right of Scheduler: watch callback: 1
    Scheduler->>Scheduler: 2. 执行 componentJob
    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 内核探秘》专栏第 9 章讲 React Suspense 时解决的是同类问题,但路径略有不同(React 用 Concurrent Mode 的"延迟提交"处理)——两个框架在这块都投入了巨大的工程资源。

Suspense 改变了组件挂载的语义——异步组件的 mounted 钩子需要等待异步操作完成后才能触发:

// packages/runtime-core/src/components/Suspense.ts:160(简化)
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')
    triggerEvent(vnode, 'onFallback')
    patch(null, vnode.ssFallback!, container, anchor, parentComponent, null, namespace, slotScopeIds)
    setActiveBranch(suspense, vnode.ssFallback!)
  } else {
    // 没有异步依赖:直接 resolve
    suspense.resolve(false, true)
  }
}

当 Suspense 内的异步组件 resolve 后,其 mounted 钩子才会从 Post 队列中被 flush。这意味着:

// ParentComponent
onMounted(() => {
  // 可能在子组件的 mounted 之前执行(如果子组件是异步的)
  console.log('parent mounted')
})

// AsyncChildComponent (inside <Suspense>)
// 钩子要在第一个 await 之前注册(<script setup> 编译产物会自动恢复实例,普通 async setup 不会)
onMounted(() => {
  // 在 Suspense resolve 后才执行
  console.log('async child mounted')
})
const data = await fetchData()

queuePostRenderEffect 的 Suspense 处理

queuePostRenderEffect 是调度器的一个内部 helper——把一个回调插入到"post 队列"或"Suspense 边界的 effects 队列",具体选哪个取决于调用时是否在 Suspense 上下文里。

这个"根据上下文选择队列"的设计,正是 Suspense 能延迟子组件 mounted 的机制底层。mounted 钩子本质是通过 queuePostRenderEffect 排队的——如果当前在 Suspense 内,就塞进 Suspense 自己的队列;Suspense resolve 时再把这个队列刷到全局 post 队列。一层间接指针,解决整个异步边界的时机问题。

// packages/runtime-core/src/renderer.ts:316(去掉了类型标注和 __TEST__ 分支;
// 源码是三层三元:__FEATURE_SUSPENSE__ ? (__TEST__ ? 包一层的闭包 : queueEffectWithSuspense) : queuePostFlushCb)
export const queuePostRenderEffect = __FEATURE_SUSPENSE__
  ? queueEffectWithSuspense
  : queuePostFlushCb

// packages/runtime-core/src/components/Suspense.ts:887
export function queueEffectWithSuspense(
  fn: Function | Function[],
  id: number | undefined,
  suspense: SuspenseBoundary | null
): void {
  if (suspense && suspense.pendingBranch) {
    // Suspense 还有 pending 分支(未 resolve):暂存到 suspense 的 effects 列表中
    if (isArray(fn)) {
      suspense.effects.push(...fn)
    } else {
      suspense.effects.push(fn)
    }
  } else {
    // 正常入 post 队列
    queuePostFlushCb(fn, id)
  }
}

这就是 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:466(简化)
// rc.4 把 activate / deactivate 抽成独立导出函数,runtime-vapor 也复用它们
export function activate(vnode, container, anchor, { p: patch, m: move }, parentComponent, parentSuspense, namespace, optimized) {
  const instance = vnode.component!
  // 将缓存的 DOM 移回文档
  move(vnode, container, anchor, MoveType.ENTER, parentComponent, 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)
    }
  }, undefined, parentSuspense)
}

export function deactivate(vnode, container, { m: move }, parentComponent, parentSuspense) {
  const instance = vnode.component!
  // 还没 flush 的 mounted / activated 钩子作废
  invalidateMount(instance.m)
  invalidateMount(instance.a)

  // 将 DOM 移到隐藏容器 storageContainer(不销毁)
  move(vnode, container, null, MoveType.LEAVE, parentComponent, parentSuspense)

  queuePostRenderEffect(() => {
    // 触发 deactivated 钩子
    if (instance.da) {
      invokeArrayFns(instance.da)
    }
    instance.isDeactivated = true
  }, undefined, parentSuspense)
}

const KeepAliveImpl: ComponentOptions = {
  setup(props, { slots }) {
    const cache: Cache = new Map()
    const keys: Keys = new Set()
    let current: VNode | null = null

    const keepAliveInstance = getCurrentInstance()!
    const sharedContext = keepAliveInstance.ctx as KeepAliveContext
    const storageContainer = createElement('div')

    sharedContext.activate = (vnode, container, anchor, namespace, optimized) =>
      activate(vnode, container, anchor, renderer, keepAliveInstance, parentSuspense, namespace, optimized)
    sharedContext.deactivate = (vnode: VNode) =>
      deactivate(vnode, storageContainer, renderer, keepAliveInstance, parentSuspense)

    return () => {
      // ... 渲染逻辑:从缓存中取出或创建新的组件 VNode
    }
  }
}

KeepAlive 的组件不会走正常的 mount / unmount 流程——它们被"停用"到一个隐藏的 DOM 容器中,"激活"时再移回来。这就是为什么 KeepAlive 的组件在缓存切换时只触发 activated / deactivated,而不是 mounted / unmounted(首次挂载时 mounted 和 activated 都会触发)。

12.10 错误处理链

一个生产级的调度器必须处理回调里的异常——如果某个 render 抛错把整个调度器崩了,整站白屏。Vue 设计了层级化的错误处理链:每个组件可以通过 onErrorCaptured 钩子注册错误处理器,错误沿组件树冒泡,某层钩子返回 false 才会停下;都没拦住则冒到 app.config.errorHandler 这个全局 handler;还没有就交给 logError(开发环境默认直接抛出,生产环境 console.error)。

这种"冒泡 + 末端兜底"的设计和 JavaScript try/catch、React Error Boundary 都是同一家族。《React 19 内核探秘》详细讲过 Error Boundary 的实现——Vue 的 onErrorCaptured 是对标 API,定位相近(Error Boundary 靠渲染降级 UI 兜底,onErrorCaptured 靠返回值控制是否继续冒泡)。

Vue 3 的错误处理不是简单的 try-catch,而是一个沿组件树向上冒泡的链式机制:

// packages/runtime-core/src/errorHandling.ts:111
export function handleError(
  err: unknown,
  instance: GenericComponentInstance | null | undefined,
  type: ErrorTypes,
  throwInDev = true
): void {
  const { errorHandler, throwUnhandledErrorInProduction } =
    (instance && instance.appContext.config) || EMPTY_OBJ

  if (instance) {
    let cur = instance.parent
    const exposedInstance = instance.proxy || instance
    // 第三个参数:开发环境是可读的出错来源描述,生产环境是错误参考 URL
    const errorInfo = __DEV__
      ? ErrorTypeStrings[type]
      : `https://vuejs.org/error-reference/#runtime-${type}`

    // 沿父组件链向上查找 errorCaptured 钩子
    while (cur) {
      const errorCapturedHooks = cur.ec  // errorCaptured 钩子数组
      if (errorCapturedHooks) {
        for (let i = 0; i < errorCapturedHooks.length; i++) {
          // 钩子显式返回 false 才表示"到此为止",停止冒泡
          if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) {
            return
          }
        }
      }
      cur = cur.parent
    }

    // 全局错误处理器
    if (errorHandler) {
      const prevSub = setActiveSub()
      callWithErrorHandling(errorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [err, exposedInstance, errorInfo])
      setActiveSub(prevSub)
      return
    }
  }

  // 兜底:开发环境抛出(throwInDev),生产环境 console.error
  logError(err, type, instance, throwInDev, throwUnhandledErrorInProduction)
}

错误处理的优先级:onErrorCaptured(组件级,逐级冒泡,返回 false 截停)→ app.config.errorHandler(全局)→ logError(兜底:开发环境抛出,生产环境 console.error,app.config.throwUnhandledErrorInProduction 可改成抛出)。

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, oldTarget }
})

这两个钩子本身(onRenderTriggered / onRenderTracked,packages/runtime-core/src/apiLifecycle.ts:100-103)没有 __DEV__ 守卫,生产构建里照样能注册,但永远不会被触发——把 onTrack / onTrigger 接到渲染 effect 上的那段代码才是 __DEV__ 独有的(packages/runtime-core/src/renderer.ts:1481-1488,Vapor 侧同理 packages/runtime-vapor/src/renderEffect.ts:68-72),所以运行时基本没有开销。

// packages/runtime-core/src/renderer.ts:1474(SetupRenderEffect 构造函数节选)
this.job = instance.job = () => {
  if (this.dirty) {
    this.run()
  }
}
this.job.i = instance

if (__DEV__) {
  this.onTrack = instance.rtc
    ? e => invokeArrayFns(instance.rtc!, e)
    : void 0
  this.onTrigger = instance.rtg
    ? e => invokeArrayFns(instance.rtg!, e)
    : void 0
}

12.12 本章小结

生命周期和调度器是 Vue 3 运行时的"时间维度"——它们决定了"什么时候做什么":

  1. 生命周期钩子通过 injectHook 注册到组件实例上,每个钩子都被包装以确保正确的 currentInstance 上下文和错误处理。

  2. 调度器维护一个按 order 有序的 jobs 数组(pre job 紧贴自己组件的更新 job)加一个 post 队列,通过微任务实现异步批量更新。同一个组件的多次状态变化只触发一次渲染。

  3. 排序保证父到子的更新顺序(uid 递增),避免子组件的双重渲染。

  4. nextTick 就是追加到当前 flush promise 的 .then(),保证在 DOM 更新后执行。

  5. Suspense 拦截异步组件的 mounted 等钩子,暂存到自己的 effects 列表中,resolve 后统一释放。

  6. KeepAlive 用 activated / deactivated 替代 mounted / unmounted,通过隐藏容器实现 DOM 的保留与复用。

  7. 错误处理沿组件树冒泡:onErrorCaptured(返回 false 截停)→ app.config.errorHandler → logError。


思考题

  1. 如果在 onUpdated 钩子中修改了一个响应式变量,会发生什么?调度器如何处理这种情况?

  2. 为什么 watch 默认使用 flush: 'pre' 而不是 flush: 'post'?在什么场景下你应该使用 flush: 'post'?

  3. nextTick 和 queuePostFlushCb 的回调执行顺序是怎样的?如果在一个 watch 回调中调用 nextTick,回调会在什么时候执行?

  4. 考虑一个深层嵌套的组件树(A → B → C → D),当 A 和 C 同时触发更新时,调度器如何保证正确的更新顺序?

  5. KeepAlive 组件的缓存上限(max prop)是如何实现的?当缓存满了时,哪个组件会被淘汰?这使用了什么策略?


回到那个最初的问题

回到本章开头那句被低估的话——"调度器是前端框架最容易被低估的部分"。读到这里,你应该能感受到这句话背后的分量:所有我们日常写 Vue 时觉得"理所应当"的体验——多次 setState 只 render 一次、父组件先于子组件更新、nextTick 里能读到最新 DOM、watch 默认拿到新数据前的旧 DOM——都不是"编译器自动帮我做了",而是调度器在运行时一次次把乱序的触发按正确顺序排列起来。如果把调度器换成最朴素的"谁改谁立刻渲染",React 曾经的"cascading update 雪崩"、Angular 曾经的"脏检查抖动"会立刻在 Vue 身上重演。框架之间的差距,很多时候就在这种"用户看不到但每一帧都在起作用"的地方拉开。

把调度器放到更大的图景里看

调度器的意义不止在"性能优化"——它是响应式系统与 DOM 之间的阻抗匹配层。响应式系统按"事件"工作(一个 set 触发一批 effect),DOM 按"帧"工作(一帧一次 reflow / paint)。中间没有调度器,两端的节奏就会错位——要么一帧里跑几百次无意义的 effect(性能崩了),要么 effect 漏掉某一帧里的更新(正确性崩了)。调度器做的事,本质上是把同一轮同步代码里零散的 trigger 攒成一批,在微任务里统一 flush——它并不与帧对齐(nextTick 基于 Promise.resolve() 微任务,不是 requestAnimationFrame),但要解决的同样是"两端节奏错配",React 的 Scheduler、游戏引擎的主循环是对这类问题的不同回答。理解了这一点,你看 React 的 concurrent rendering、Svelte 的 invalidate 机制、SolidJS 的细粒度 reactivity,就不再是"另一套 API",而是"对同一个问题的另一种回答"。

和前面章节的呼应

本章多次引用了前面章节的内容——这不是偶然,而是因为生命周期和调度器本身就是整个运行时的"指挥棒":第 5 章讲的响应式 effect 最终要被调度器 flush;第 10 章讲的组件实例在每个生命周期节点都要被正确设置为 currentInstance;第 11 章讲的 patch 过程就是 SetupRenderEffect.fn()(即以前的 componentUpdateFn)在 jobs 队列里的执行体;第 13 章(13.9 节)讲指令与 <Transition> 的时序配合时,还会回到本章的 post queue。你如果回头再读这几章,会发现它们其实早就在为本章铺路——Vue 3 的源码组织得非常像一本按主题展开的教科书,只是每个主题都只讲了"自己这一面",要把它们串起来才能看到运行时的全貌。

进一步阅读

  • Vue 3 源码 packages/runtime-core/src/scheduler.ts:整份文件只有 350 行左右,强烈建议从头到尾通读一遍,你会发现"有序 jobs + post 队列"的核心逻辑比想象中简单得多,真正复杂的是边界条件。
  • Vue 3 源码 packages/runtime-core/src/apiLifecycle.ts:injectHook 的完整实现只有 47 行(:17-63),是理解"为什么 setup 里能写 onMounted"的最短路径。
  • React 源码 packages/scheduler/src/forks/Scheduler.js:和 Vue scheduler 对比阅读,你会看到"微任务调度"和"时间切片 + 优先级"两种哲学的差异——前者拼正确性,后者拼长任务友好性。
  • 浏览器 Event Loop 规范(HTML Standard 的 8.1.7 Event loops 一节):Vue 的 nextTick 之所以选择 microtask,根本依据就在这份规范里——理解了 task 和 microtask 的执行时机,你才能真正理解 flush: 'pre' / 'post' 的设计取舍。

下一章讨论指令系统——指令的 mounted / updated / unmounted 钩子经 queuePostRenderEffect 进入本章的 post 队列,而 created / beforeMount / beforeUpdate / beforeUnmount 由渲染器在 patch 过程中同步调用,建议对照阅读。