Skip to content

生命周期:钩子的注册时机与执行机制

生命周期钩子是 Vue 组件在特定阶段(如挂载、更新、卸载)执行的回调函数。开发者通过这些钩子,可以在组件的不同时期注入自定义逻辑。

本节将分析两个核心问题:

  1. 注册onMounted(...) 这样的函数,是如何将回调函数注册到组件实例上的?
  2. 执行:Vue 的渲染器(Renderer)是在什么时机、以何种顺序去执行这些回调的?

核心:实例上的回调数组

一个组件实例(ComponentInternalInstance)是一个内部对象,它包含了组件的所有信息。所有生命周期钩子,最终都会被存储在这个实例对象上的特定数组中。

Vue 3 使用简短的内部标识符来管理这些钩子:

typescript
// core/packages/runtime-core/src/enums.ts
export const enum LifecycleHooks {
  BEFORE_MOUNT = 'bm',  // 挂载前
  MOUNTED = 'm',        // 挂载后
  BEFORE_UPDATE = 'bu', // 更新前
  UPDATED = 'u',        // 更新后
  BEFORE_UNMOUNT = 'bum', // 卸载前
  UNMOUNTED = 'um',     // 卸载后
  ACTIVATED = 'a',      // (KeepAlive) 激活
  DEACTIVATED = 'da',   // (KeepAlive) 失活
  // ... 其他
}

当你调用 onMounted(fn) 时,目的就是将 fn 添加instance.m 这个数组里。


注册:setup 期间的 injectHook

在组合式 API 中,我们使用 onMounted, onUpdated 等函数注册钩子。这些函数由 createHook 高阶函数生成:

typescript
// core/packages/runtime-core/src/apiLifecycle.ts

// createHook 是一个工厂函数,用于创建 onMounted, onUpdated...
export const createHook =
  <T extends Function = () => any>(lifecycle: LifecycleHooks) =>
  (hook: T, target: ComponentInternalInstance | null = currentInstance) =>
    injectHook(lifecycle, (...args: unknown[]) => hook(...args), target)

// "on" 系列 API
export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT)
export const onMounted = createHook(LifecycleHooks.MOUNTED)
export const onBeforeUpdate = createHook(LifecycleHooks.BEFORE_UPDATE)
// ...

createHook 将注册工作委托给了 injectHook

injectHook:注册的核心

injectHook 的逻辑很明确:找到当前组件实例,然后把钩子函数添加到它对应的回调数组中

typescript
// core/packages/runtime-core/src/apiLifecycle.ts
export function injectHook(
  type: LifecycleHooks, // e.g., 'm' (MOUNTED)
  hook: Function,
  target: ComponentInternalInstance | null = currentInstance, // 默认是“当前实例”
  prepend: boolean = false
): Function | undefined {
  if (target) {
    // 1. 找到或创建该类型的钩子数组
    const hooks = target[type] || (target[type] = [])
    
    // 2. 包装钩子(用于错误处理和 unmounted 检查)
    const wrappedHook = (...) => { /* ... */ }

    // 3. 【核心】将包装后的钩子添加到数组
    if (prepend) {
      hooks.unshift(wrappedHook)
    } else {
      hooks.push(wrappedHook)
    }
  }
}

getCurrentInstance:获取当前实例

injectHook 依赖一个关键的 currentInstance 变量。这个变量是在组件 setup() 函数执行之前,由 setCurrentInstance 设置的;在 setup() 执行完毕后,再被恢复为 null

typescript
// core/packages/runtime-core/src/component.ts
let currentInstance: ComponentInternalInstance | null = null

export const getCurrentInstance: () => ComponentInternalInstance | null = () =>
  currentInstance

export const setCurrentInstance = (instance: ComponentInternalInstance) => {
  const prev = currentInstance
  currentInstance = instance
  return () => { // 返回一个“重置”函数
    currentInstance = prev
  }
}

这就是为什么 onMounted 只能在 setup() 同步执行期间被调用的原因。一旦 setup 执行完毕,currentInstance 就会被重置为 nullinjectHook 也就无法找到目标实例了。


5.1.3 执行:渲染器的工作时机

回调函数被注册到 instance.bm, instance.m 等数组后,由**渲染器(Renderer)**在渲染流程的特定时机负责调用。

组件的“挂载”和“更新”都由一个核心的 effectcomponentUpdateFn)来驱动。

挂载阶段:beforeMount (同步) 与 mounted (异步)

componentUpdateFn 首次执行时,会执行“挂载”逻辑:

typescript
// core/packages/runtime-core/src/renderer.ts - setupRenderEffect
const componentUpdateFn = () => {
  if (!instance.isMounted) { // 首次挂载
    const { bm, m } = instance // 取出钩子数组

    // 1. 【beforeMount】
    // 在 patch() 之前同步执行
    if (bm) {
      invokeArrayFns(bm) // 遍历并执行 'bm' 数组
    }
    
    // 2. 【渲染】
    // render() 被调用,生成 subTree (VNode 树)
    const subTree = (instance.subTree = renderComponentRoot(instance))
    // patch() 被调用,递归地创建真实 DOM
    patch(
      null,
      subTree,
      container,
      // ...
    )
    
    instance.isMounted = true

    // 3. 【mounted】
    // 在 patch() 之后,异步地推入“后置刷新队列”
    if (m) {
      queuePostRenderEffect(m, parentSuspense)
    }
  } else {
    // ... 更新阶段 ...
  }
}

执行时机分析:

  • beforeMount (bm)同步执行的。它发生在 render()patch() 之前,此时 DOM 节点尚未创建。
  • mounted (m)异步执行的。它被 queuePostRenderEffect 函数推入一个队列中。这个队列会在 Vue 的整个更新(包括所有子组件的 patch全部完成之后才被清空。这保证了当 mounted 钩子执行时,所有子孙 DOM 均已挂载完毕。

更新阶段:beforeUpdate (同步) 与 updated (异步)

componentUpdateFn 第二次(及以后)执行时,会执行“更新”逻辑:

typescript
// core/packages/runtime-core/src/renderer.ts - (componentUpdateFn 的 else 分支)
else {
  let { next, bu, u } = instance // 'bu' = beforeUpdate, 'u' = updated

  // 1. 【beforeUpdate】
  // 在 patch() 之前同步执行
  if (bu) {
    invokeArrayFns(bu)
  }

  // 2. 【渲染与 Diff】
  // 重新 render(),获取新的 VNode 树
  const nextTree = renderComponentRoot(instance)
  const prevTree = instance.subTree
  instance.subTree = nextTree

  // patch() 被调用,执行 Diff 算法,更新 DOM
  patch(
    prevTree,
    nextTree,
    // ...
  )

  // 3. 【updated】
  // 在 patch() 之后,异步地推入“后置刷新队列”
  if (u) {
    queuePostRenderEffect(u, parentSuspense)
  }
}

这个逻辑与挂载阶段一致:beforeUpdate 在 Diff 之前同步执行,updated 在 DOM 更新完毕后异步执行。

卸载阶段:beforeUnmount (同步) 与 unmounted (异步)

当组件被卸载时,渲染器会调用 unmountComponent

typescript
// core/packages/runtime-core/src/renderer.ts
const unmountComponent = (
  instance: ComponentInternalInstance,
  // ...
) => {
  const { bum, scope, subTree, um } = instance // 'bum' = beforeUnmount, 'um' = unmounted

  // 1. 【beforeUnmount】
  // 同步执行
  if (bum) {
    invokeArrayFns(bum)
  }

  // 2. 【清理】
  // 停止组件的所有响应式 effect (computed, watch...)
  scope.stop()

  // 递归卸载子树 (移除 DOM)
  unmount(subTree, instance, parentSuspense)
  
  // 3. 【unmounted】
  // 异步推入“后置刷新队列”
  if (um) {
    queuePostRenderEffect(um, parentSuspense)
  }
  
  // ...
  instance.isUnmounted = true
}

执行时机分析:

  • beforeUnmount (bum)同步的。它在所有子组件被卸载、effect 被停止之前执行。这是清理定时器、解绑全局事件的最佳时机,因为此时 DOM 和实例都还存在。
  • unmounted (um)异步的,它在所有清理工作(scope.stop, unmount(subTree))都完成后才执行。

5.1.4 父子组件生命周期顺序

父子组件的执行顺序是 patch 函数递归执行的必然结果,而不是一个单独的规定。

挂载阶段: Parent(bm) -> Child(bm) -> Child(m) -> Parent(m)

  1. Parent(bm): 父组件 patch,调用 Parent(bm) (同步)。
  2. Parent(patch): 父组件 patch 遍历到子组件...
  3. Child(bm): 子组件 patch 开始,调用 Child(bm) (同步)。
  4. Child(patch): 子组件 patch 子树,创建 DOM。
  5. Child(m): 子组件 patch 结束,Child(m)推入 queuePostRenderEffect 队列。 (队列:[ Child(m) ]
  6. Parent(patch):父组件 patch 继续,直到结束。
  7. Parent(m): 父组件 patch 结束,Parent(m)推入 queuePostRenderEffect 队列。 (队列:[ Child(m), Parent(m) ]
  8. Flush: 同步 patch 全部完成。Vue 开始清空 post 队列,Child(m) 先执行,Parent(m) 后执行。

卸载阶段的 bum / um 顺序(Parent(bum) -> Child(bum) -> Child(um) -> Parent(um))与此同理,都是 unmount 函数递归调用的自然结果。


特殊生命周期

  • onActivated / onDeactivated: 这两个钩子专为 <KeepAlive> 组件设计。injectHook 在注册它们时会使用一个特殊的 registerKeepAliveHook 包装器。当 <KeepAlive> 切换组件时,它会跳过 unmount 流程,转而手动调用 deactivateactivate 方法,这两个方法会分别触发 daa 数组中的钩子。

  • onErrorCaptured: 此钩子被注册到 ec 数组。当子孙组件中发生未捕获的错误时,错误会沿着组件链向上传播,handleError 函数会查找父组件的 ec 钩子并执行它们,直到错误被“捕获”(钩子返回 true)。


总结

Vue 3 的生命周期系统是一个清晰的“注册/执行”机制:

  1. 注册 (setup 阶段)onMounted(fn) 等 API 通过 injectHook,将 fn 添加当前组件实例currentInstance)的特定回调数组中(如 instance.m)。
  2. 执行 (render 阶段)渲染器renderer)在执行 patch(挂载/更新)或 unmount(卸载)的特定时机,遍历并执行这些数组。
  3. 时机(同步 vs. 异步)
    • "Before" 钩子bm, bu, bum)总是在 DOM 操作之前同步执行。
    • "After" 钩子m, u, um)总是在 DOM 操作之后,通过 queuePostRenderEffect 异步执行,以确保 DOM 已完全稳定。
  4. 顺序:父子组件的执行顺序是 patch 递归调用和异步队列执行顺序的自然结果。

微信公众号二维码

Last updated: