主题
实现生命周期 Hooks
本节对标 Vue 3 源码
@vue/runtime-core中的apiLifecycle.ts+component.ts+renderer.ts
生命周期概览
Vue 3 提供了一组 Composition API 风格的生命周期 Hooks,必须在 setup() 中同步调用:
setup()
│
├── onBeforeMount ── 组件挂载前
├── onMounted ── 组件挂载后(DOM 已就绪)
│
├── onBeforeUpdate ── 响应式数据变更,DOM 更新前
├── onUpdated ── DOM 更新后
│
├── onBeforeUnmount ── 组件卸载前
└── onUnmounted ── 组件卸载后对标源码位置:packages/runtime-core/src/apiLifecycle.ts
currentInstance 机制
生命周期 Hooks 的核心依赖是 currentInstance——一个模块级变量,标识当前正在执行 setup() 的组件实例。
ts
// packages/runtime-core/src/component.ts
// 模块级变量,存储当前正在执行 setup() 的组件实例
// 在 setup() 执行期间被设置,执行完毕后被清除为 null
export let currentInstance: ComponentInternalInstance | null = null
// 设置当前活跃的组件实例,在 setup() 调用前后分别设置和清除
export function setCurrentInstance(instance: ComponentInternalInstance | null) {
currentInstance = instance
}
// 获取当前活跃的组件实例,供生命周期 Hooks、inject() 等 API 内部使用
export function getCurrentInstance(): ComponentInternalInstance | null {
return currentInstance
}在 setupComponent 中,执行 setup() 前后会设置和清除 currentInstance:
ts
function setupStatefulComponent(instance: ComponentInternalInstance) {
const Component = instance.type // 获取组件的选项对象(即用户定义的组件配置)
const { setup } = Component // 解构出 setup 函数
if (setup) {
// 在执行 setup() 之前,将当前组件实例设置为活跃实例
// 这样 setup() 内部调用的 onMounted 等 API 才能获取到正确的实例
setCurrentInstance(instance)
// 调用 setup(),传入只读的 props 和上下文对象(emit、slots、attrs、expose)
const setupResult = setup(shallowReadonly(instance.props), {
emit: instance.emit,
slots: instance.slots,
attrs: instance.attrs,
expose: instance.expose,
})
// setup() 执行完毕后立即清除 currentInstance
// 防止在异步回调中误用生命周期注册 API
setCurrentInstance(null)
// 处理 setup() 的返回值(可能是渲染函数或状态对象)
handleSetupResult(instance, setupResult)
}
}关键设计:setCurrentInstance(null) 在 setup() 执行完毕后立即调用。这意味着如果在异步回调(如 setTimeout、await 之后)中调用 onMounted,currentInstance 已经是 null,注册会失败。这是有意为之的设计约束。
组件实例上的生命周期数组
每个组件实例上预定义了生命周期 Hook 数组:
ts
// packages/runtime-core/src/component.ts
// 组件内部实例的接口定义
export interface ComponentInternalInstance {
// ...
isMounted: boolean // 标记组件是否已挂载
isUnmounted: boolean // 标记组件是否已卸载
// lifecycle hooks —— 使用缩写命名以减少内存占用
bc: Function[] | null // beforeCreate (Options API,不在此讨论)
c: Function[] | null // created
bm: Function[] | null // beforeMount —— 挂载前的回调数组
m: Function[] | null // mounted —— 挂载后的回调数组
bu: Function[] | null // beforeUpdate —— 更新前的回调数组
u: Function[] | null // updated —— 更新后的回调数组
bum: Function[] | null // beforeUnmount —— 卸载前的回调数组
um: Function[] | null // unmounted —— 卸载后的回调数组
}
// 工厂函数:创建组件实例对象
function createComponentInstance(vnode, parent): ComponentInternalInstance {
const instance: ComponentInternalInstance = {
vnode, // 组件对应的虚拟节点
type: vnode.type, // 组件的选项对象(包含 setup、render 等)
parent, // 父组件实例,用于 provide/inject 链式查找
isMounted: false, // 初始状态:未挂载
isUnmounted: false, // 初始状态:未卸载
// lifecycle hooks - 初始化为 null,注册时按需创建数组
// 使用 null 而非空数组可以减少内存开销,因为大部分组件不会注册所有生命周期
bc: null,
c: null,
bm: null,
m: null,
bu: null,
u: null,
bum: null,
um: null,
// ...
}
return instance
}使用 null 而不是空数组,是为了减少内存开销——大部分组件不会注册所有生命周期。
createHook 工厂函数
所有生命周期 Hooks 的注册逻辑几乎相同,Vue 3 使用 createHook 工厂函数来消除重复代码:
ts
// packages/runtime-core/src/apiLifecycle.ts
import { currentInstance, setCurrentInstance } from './component'
// 使用 const enum 定义生命周期类型常量
// const enum 在编译时会被内联替换为实际值,零运行时开销
export const enum LifecycleHooks {
BEFORE_MOUNT = 'bm', // 对应组件实例上的 bm 数组
MOUNTED = 'm', // 对应组件实例上的 m 数组
BEFORE_UPDATE = 'bu', // 对应组件实例上的 bu 数组
UPDATED = 'u', // 对应组件实例上的 u 数组
BEFORE_UNMOUNT = 'bum', // 对应组件实例上的 bum 数组
UNMOUNTED = 'um', // 对应组件实例上的 um 数组
}
// 核心注入函数:将生命周期回调注册到目标组件实例上
function injectHook(
type: LifecycleHooks, // 生命周期类型(决定注入到哪个数组)
hook: Function, // 用户传入的回调函数
target: ComponentInternalInstance | null = currentInstance, // 默认注入到当前活跃实例
) {
if (target) {
// 延迟创建数组:如果实例上该类型的数组不存在,则创建一个空数组
// 这是按需创建的策略,避免为未使用的生命周期分配内存
const hooks = target[type] || (target[type] = [])
// 对用户传入的 hook 进行包装,在执行时恢复 currentInstance 上下文
const wrappedHook = (...args: unknown[]) => {
// 在调用 hook 时恢复 currentInstance
// 这样 hook 内部调用的 inject() 等 API 也能正确获取实例
setCurrentInstance(target)
const res = hook(...args) // 执行用户的回调函数
setCurrentInstance(null) // 执行完毕后清除,避免污染全局状态
return res
}
hooks.push(wrappedHook) // 将包装后的 hook 推入对应的生命周期数组
return wrappedHook // 返回包装后的 hook,便于后续移除
} else if (__DEV__) {
// 开发环境下,如果没有活跃的组件实例,输出警告信息
// 这意味着用户在 setup() 之外调用了生命周期 API
const apiName = `on${type.charAt(0).toUpperCase()}${type.slice(1)}`
console.warn(
`${apiName} is called when there is no active component instance to be associated with. ` +
`Lifecycle injection APIs can only be used during execution of setup().`,
)
}
}
// 工厂函数:利用闭包捕获生命周期类型,返回对应的注册函数
// 这种模式避免了为每个生命周期 Hook 重复编写注册逻辑
const createHook = (lifecycle: LifecycleHooks) => {
return (hook: Function, target: ComponentInternalInstance | null = currentInstance) =>
injectHook(lifecycle, hook, target)
}
// 导出所有生命周期 Hooks,每个都是通过 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)设计分析
- 工厂模式:
createHook通过闭包捕获lifecycle类型,避免每个 Hook 重复编写注册逻辑 - 延迟创建数组:
target[type] || (target[type] = [])按需创建,节省内存 - wrappedHook 恢复 instance:确保生命周期回调内部可以正常使用
getCurrentInstance()、inject()等 API - 开发环境警告:在
setup()外部调用时给出明确提示
invokeArrayFns 工具函数
生命周期 Hook 数组的批量调用使用统一的工具函数:
ts
// packages/shared/src/index.ts
// 批量调用函数数组的工具函数
// 使用 for 循环而非 forEach,避免闭包和函数调用开销,性能更优
export function invokeArrayFns(fns: Function[], arg?: any) {
for (let i = 0; i < fns.length; i++) {
fns[i](arg) // 按注册顺序依次执行每个函数,支持传入一个可选参数
}
}这个函数简洁高效:
- 使用
for循环而非forEach,避免闭包和函数调用开销 - 支持传入一个可选参数
- 数组中的函数按注册顺序依次执行
调用时机:componentUpdateFn
生命周期 Hooks 的调用发生在渲染器的 componentUpdateFn 中,这是组件的核心渲染/更新函数:
ts
// packages/runtime-core/src/renderer.ts
// 设置组件的渲染副作用:创建响应式 effect 并绑定更新函数
function setupRenderEffect(
instance: ComponentInternalInstance,
initialVNode: VNode,
container: any,
anchor: any,
) {
// 组件的核心渲染/更新函数,在首次挂载和后续更新时都会被调用
const componentUpdateFn = () => {
if (!instance.isMounted) {
// ============ MOUNT 流程(首次渲染)============
const { bm, m } = instance // 解构出 beforeMount 和 mounted 的 hook 数组
// beforeMount hook —— 在 DOM 创建之前同步调用
if (bm) {
invokeArrayFns(bm)
}
// 执行组件的 render 函数,生成子树 VNode(虚拟 DOM 树)
const subTree = (instance.subTree = renderComponentRoot(instance))
// 递归 patch 子树,将虚拟 DOM 转化为真实 DOM 并插入容器
patch(null, subTree, container, anchor, instance)
// 将子树的根 DOM 元素赋给组件的 VNode,便于后续访问
initialVNode.el = subTree.el
// mounted hook —— 此时 DOM 已挂载到页面
if (m) {
// Vue 源码中 mounted 是通过 queuePostRenderEffect 异步调用的
// 确保所有子组件也已完成挂载后再执行
// 简化实现中可以同步调用
queuePostRenderEffect(m)
}
// 标记组件已挂载,后续更新将走 else 分支
instance.isMounted = true
} else {
// ============ UPDATE 流程(响应式数据变更触发)============
const { bu, u } = instance // 解构出 beforeUpdate 和 updated 的 hook 数组
// beforeUpdate hook —— 在 DOM 更新之前同步调用
if (bu) {
invokeArrayFns(bu)
}
// 重新执行 render 函数,生成新的子树 VNode
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree // 保存旧的子树用于 diff 对比
instance.subTree = nextTree // 更新实例上的子树引用
// 对比新旧子树,执行最小化的 DOM 更新(diff + patch)
patch(prevTree, nextTree, container, anchor, instance)
// updated hook —— 此时 DOM 已完成更新
if (u) {
queuePostRenderEffect(u) // 放入后置队列,确保子组件也更新完毕后再执行
}
}
}
// 创建响应式副作用:当组件依赖的响应式数据变化时,触发调度器
// 调度器不会直接执行 componentUpdateFn,而是通过 queueJob 将更新任务加入队列
// 这样可以在同一 tick 中合并多次数据变更,只执行一次更新
const effect = new ReactiveEffect(componentUpdateFn, () =>
queueJob(update),
)
// 将 effect.run 绑定为组件的 update 方法,便于手动触发更新
const update = (instance.update = () => effect.run())
update() // 首次调用,触发 MOUNT 流程
}调用顺序详解
挂载阶段:
1. onBeforeMount ← 同步调用,DOM 尚未创建
2. render() ← 生成 VNode 子树
3. patch() ← 递归挂载,创建真实 DOM
4. onMounted ← 异步调用(queuePostRenderEffect),DOM 已就绪更新阶段:
1. onBeforeUpdate ← 同步调用,DOM 尚未更新
2. render() ← 生成新 VNode 子树
3. patch() ← 对比更新 DOM
4. onUpdated ← 异步调用(queuePostRenderEffect),DOM 已更新注意:onMounted 和 onUpdated 在 Vue 3 源码中是通过 queuePostRenderEffect 放入 post flush 队列异步执行的,确保子组件也已完成挂载/更新。
调用时机:unmountComponent
卸载生命周期在 unmountComponent 中调用:
ts
// packages/runtime-core/src/renderer.ts
// 卸载组件:依次触发卸载生命周期并递归卸载子树
function unmountComponent(instance: ComponentInternalInstance) {
const { bum, um, subTree } = instance // 解构出 beforeUnmount、unmounted hook 数组和子树
// beforeUnmount hook —— 同步调用,此时组件仍然完整可用
// 可以在这里执行清理操作,如取消定时器、移除事件监听器等
if (bum) {
invokeArrayFns(bum)
}
// 递归卸载子树,移除所有真实 DOM 节点
unmount(subTree)
// unmounted hook —— 放入后置队列异步执行
// 此时组件的 DOM 已被移除,所有子组件也已卸载
if (um) {
queuePostRenderEffect(um)
}
// 标记组件已卸载,防止重复卸载
instance.isUnmounted = true
}卸载阶段:
1. onBeforeUnmount ← 同步调用,组件仍然完整可用
2. unmount() ← 递归卸载子树,移除 DOM
3. onUnmounted ← 异步调用,组件已完全卸载父子组件生命周期执行顺序
Parent setup()
│
├── Parent onBeforeMount
│ │
│ └── Child setup()
│ ├── Child onBeforeMount
│ ├── Child render + patch
│ └── Child onMounted
│
└── Parent onMounted
更新时:
Parent onBeforeUpdate
└── Child onBeforeUpdate
└── Child onUpdated
Parent onUpdated
卸载时:
Parent onBeforeUnmount
└── Child onBeforeUnmount
└── Child onUnmounted
Parent onUnmounted规律:mount/update/unmount 都是"父先开始,子先完成"——即父组件的 before* 先执行,但子组件的完成回调先执行。
对比 React
| 维度 | Vue 3 Lifecycle Hooks | React useEffect |
|---|---|---|
| 调用约束 | 必须在 setup() 中同步调用 | 必须在函数组件顶层调用 |
| 挂载时机 | onMounted —— DOM 挂载后同步/微任务 | useEffect —— 浏览器绘制后异步执行 |
| 更新时机 | onBeforeUpdate / onUpdated 精确区分更新前后 | useEffect 无法区分"更新前" |
| 卸载时机 | onBeforeUnmount / onUnmounted 两个阶段 | useEffect 返回的 cleanup 函数 |
| 粒度 | 每个生命周期独立注册 | useEffect 统一处理挂载、更新、卸载 |
| 依赖追踪 | 自动响应式追踪,无需手动指定依赖 | 必须手动声明 deps 数组 |
| 执行时序 | onMounted 在微任务中执行 | useEffect 在宏任务(paint 后)执行 |
| 同步 DOM 访问 | onMounted 可安全访问 DOM | 需要 useLayoutEffect 才能在 paint 前访问 |
关键差异:执行时序
Vue 3:
render → DOM 更新 → 微任务队列 → onMounted/onUpdated
(在同一帧内,浏览器绘制前执行)
React:
render → DOM 更新 → 浏览器绘制 → useEffect
(在下一帧,浏览器绘制后异步执行)
React useLayoutEffect:
render → DOM 更新 → useLayoutEffect → 浏览器绘制
(类似 Vue 3 的 onMounted 时序)简化实现
ts
// apiLifecycle.ts
import { currentInstance } from './component'
// 生命周期类型枚举,值对应组件实例上的属性名缩写
export const enum LifecycleHooks {
BEFORE_MOUNT = 'bm',
MOUNTED = 'm',
BEFORE_UPDATE = 'bu',
UPDATED = 'u',
BEFORE_UNMOUNT = 'bum',
UNMOUNTED = 'um',
}
// 简化版的 injectHook:省略了 wrappedHook 包装逻辑
// 直接将用户的回调推入目标实例对应的生命周期数组
function injectHook(type: LifecycleHooks, hook: Function, target = currentInstance) {
if (target) {
// 按需创建数组并推入 hook
const hooks = target[type] || (target[type] = [])
hooks.push(hook)
}
}
// 工厂函数:通过闭包绑定生命周期类型,返回注册函数
const createHook = (lifecycle: LifecycleHooks) => {
return (hook: Function) => injectHook(lifecycle, hook)
}
// 导出六个核心生命周期 Hooks
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)ts
// shared/index.ts
// 工具函数:遍历函数数组并逐个调用
// 用于批量执行生命周期 hook 回调
export function invokeArrayFns(fns: Function[], arg?: any) {
for (let i = 0; i < fns.length; i++) {
fns[i](arg) // 将可选参数传递给每个回调函数
}
}测试用例
ts
import { describe, it, expect, vi } from 'vitest'
import {
h,
render,
ref,
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
nextTick,
} from '../src'
describe('lifecycle hooks', () => {
// 测试:onBeforeMount 和 onMounted 在组件挂载时被正确调用
it('onBeforeMount and onMounted', () => {
const beforeMount = vi.fn() // 创建 mock 函数用于追踪调用
const mounted = vi.fn()
const Comp = {
setup() {
// 在 setup() 中注册生命周期 Hooks
onBeforeMount(beforeMount)
onMounted(mounted)
return () => h('div', null, 'hello') // 返回渲染函数
},
}
const root = document.createElement('div') // 创建挂载容器
render(h(Comp), root) // 渲染组件到容器
// 断言:两个 hook 各被调用了一次
expect(beforeMount).toHaveBeenCalledTimes(1)
expect(mounted).toHaveBeenCalledTimes(1)
// 断言:DOM 已正确渲染
expect(root.innerHTML).toBe('<div>hello</div>')
})
// 测试:onBeforeUpdate 和 onUpdated 在响应式数据变更后被调用
it('onBeforeUpdate and onUpdated', async () => {
const beforeUpdate = vi.fn()
const updated = vi.fn()
const count = ref(0) // 创建响应式数据
const Comp = {
setup() {
onBeforeUpdate(beforeUpdate)
onUpdated(updated)
// 渲染函数中依赖了 count.value,当 count 变化时会触发更新
return () => h('div', null, String(count.value))
},
}
const root = document.createElement('div')
render(h(Comp), root)
// 断言:首次渲染后 DOM 显示 0
expect(root.innerHTML).toBe('<div>0</div>')
// 断言:首次挂载时不会触发 update 相关的 hook
expect(beforeUpdate).not.toHaveBeenCalled()
expect(updated).not.toHaveBeenCalled()
// 修改响应式数据,触发组件更新
count.value++
// 等待异步调度完成(Vue 的更新是批量异步执行的)
await nextTick()
// 断言:DOM 已更新为新值
expect(root.innerHTML).toBe('<div>1</div>')
// 断言:更新相关的 hook 各被调用了一次
expect(beforeUpdate).toHaveBeenCalledTimes(1)
expect(updated).toHaveBeenCalledTimes(1)
})
// 测试:onBeforeUnmount 和 onUnmounted 在组件卸载时被调用
it('onBeforeUnmount and onUnmounted', () => {
const beforeUnmount = vi.fn()
const unmounted = vi.fn()
const Comp = {
setup() {
onBeforeUnmount(beforeUnmount)
onUnmounted(unmounted)
return () => h('div', null, 'hello')
},
}
const root = document.createElement('div')
render(h(Comp), root)
// 断言:挂载时卸载相关的 hook 不应被调用
expect(beforeUnmount).not.toHaveBeenCalled()
expect(unmounted).not.toHaveBeenCalled()
// 渲染 null 触发组件卸载
render(null, root)
// 断言:卸载后两个 hook 各被调用了一次
expect(beforeUnmount).toHaveBeenCalledTimes(1)
expect(unmounted).toHaveBeenCalledTimes(1)
})
// 测试:父子组件生命周期的执行顺序——"父先开始,子先完成"
it('lifecycle call order for parent and child', async () => {
const calls: string[] = [] // 用于记录生命周期的调用顺序
const Child = {
setup() {
// 子组件注册的生命周期 hook 会将标识字符串推入 calls 数组
onBeforeMount(() => calls.push('child beforeMount'))
onMounted(() => calls.push('child mounted'))
onBeforeUnmount(() => calls.push('child beforeUnmount'))
onUnmounted(() => calls.push('child unmounted'))
return () => h('span')
},
}
const Parent = {
setup() {
// 父组件同样注册生命周期 hook
onBeforeMount(() => calls.push('parent beforeMount'))
onMounted(() => calls.push('parent mounted'))
onBeforeUnmount(() => calls.push('parent beforeUnmount'))
onUnmounted(() => calls.push('parent unmounted'))
// 父组件渲染子组件
return () => h('div', null, [h(Child)])
},
}
const root = document.createElement('div')
render(h(Parent), root)
// 断言挂载顺序:父 beforeMount → 子 beforeMount → 子 mounted → 父 mounted
// 体现了"父先开始,子先完成"的嵌套执行模型
expect(calls).toEqual([
'parent beforeMount',
'child beforeMount',
'child mounted',
'parent mounted',
])
calls.length = 0 // 清空调用记录
render(null, root) // 卸载整个组件树
// 断言卸载顺序:父 beforeUnmount → 子 beforeUnmount → 子 unmounted → 父 unmounted
expect(calls).toEqual([
'parent beforeUnmount',
'child beforeUnmount',
'child unmounted',
'parent unmounted',
])
})
// 测试:同一生命周期可以注册多个 hook,按注册顺序执行
it('can register multiple hooks of the same type', () => {
const calls: string[] = []
const Comp = {
setup() {
// 连续注册三个 onMounted hook
onMounted(() => calls.push('mounted 1'))
onMounted(() => calls.push('mounted 2'))
onMounted(() => calls.push('mounted 3'))
return () => h('div')
},
}
const root = document.createElement('div')
render(h(Comp), root)
// 断言:三个 hook 按注册顺序依次执行
expect(calls).toEqual(['mounted 1', 'mounted 2', 'mounted 3'])
})
// 测试:在 setup() 外部调用生命周期 API 应触发警告
it('should warn if called outside setup()', () => {
// 拦截 console.warn 调用
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 在 setup() 外部直接调用 onMounted,此时 currentInstance 为 null
onMounted(() => {})
// 断言:应该输出警告信息
expect(spy).toHaveBeenCalled()
spy.mockRestore() // 恢复 console.warn 原始行为
})
})本节小结
- currentInstance —— 模块级变量,在
setup()执行期间指向当前组件实例,是生命周期注册的核心依赖 - createHook 工厂 —— 通过闭包捕获生命周期类型,统一注册逻辑,所有 Hook 调用
injectHook将回调推入实例对应数组 - invokeArrayFns —— 简洁的批量调用工具函数,
for循环遍历执行 - 调用时机 ——
beforeMount/beforeUpdate同步调用在render之前,mounted/updated通过 post flush 队列在 DOM 操作完成后调用 - 父子顺序 —— "父先开始,子先完成"的嵌套执行模型
下一节实现 nextTick 与异步调度机制。