04|生命周期:组件时间线不是 API 清单
第四章概览 / 04。目标:根据资源需要选择钩子,并证明挂载、更新、缓存和卸载时没有重复启动或残留。
先问“资源何时有效”
生命周期钩子登记的是当前组件实例时间线上的回调。所有 Composition API 生命周期函数都必须在 setup 阶段同步调用;它们可以藏在 useXxx() 里,只要调用栈仍同步地从当前 setup() 出发。
| 钩子 | 可靠事实 | 常见用途 |
|---|---|---|
onBeforeMount | 响应式状态已建立,首次 DOM 尚未创建 | 极少需要;不能读取模板 DOM |
onMounted | 本组件 DOM 与同步子组件已挂载 | 模板 ref、浏览器监听、观察器、客户端连接 |
onBeforeUpdate | DOM 即将因响应式变化更新 | 读取更新前 DOM;可在此修改状态但应谨慎 |
onUpdated | 本轮 DOM 更新完成,多个状态写入可能已批处理 | 观察最终 DOM;不要无条件写回触发源 |
onBeforeUnmount | 实例仍完整可用,即将卸载 | 需要实例仍活动的最后清理 |
onUnmounted | 子树与 setup 中绑定的响应式 effects 已停止 | 清计时器、监听器、连接与第三方订阅 |
onActivated / onDeactivated | <KeepAlive> 缓存实例重新插入 / 移出 DOM | 恢复、暂停轮询或观察;不是新实例 |
这些挂载、更新、卸载钩子都不会在 SSR 中执行。服务端 setup 里启动的永久资源不能指望 onUnmounted 收尾。
最小可运行例子:可暂停且幂等的探针
<script setup lang="ts">
import {
onActivated,
onDeactivated,
onMounted,
onUnmounted,
onUpdated,
ref
} from 'vue'
const ticks = ref(0)
const log = ref<string[]>([])
let timer: ReturnType<typeof setInterval> | undefined
function record(message: string) {
log.value.push(message)
}
function onOnline() {
record('online')
}
function start() {
if (timer !== undefined) return
window.addEventListener('online', onOnline)
timer = setInterval(() => { ticks.value += 1 }, 1000)
record('start')
}
function stop() {
if (timer === undefined) return
clearInterval(timer)
timer = undefined
window.removeEventListener('online', onOnline)
record('stop')
}
onMounted(start)
onActivated(start)
onDeactivated(stop)
onUnmounted(stop)
onUpdated(() => {
console.log('DOM 已反映 ticks =', ticks.value)
})
</script>
<template>
<p>ticks: {{ ticks }}</p>
<pre>{{ log.join('\n') }}</pre>
</template>start() / stop() 必须幂等,因为 KeepAlive 下初次挂载与激活、最终停用与卸载可能让多个生命周期落在同一个实例上。用 timer === undefined 作为资源句柄状态,比额外维护一个可能失真的布尔值更可靠。
用同一个探针比较销毁与缓存
把下面父组件作为 App.vue,与前面的探针放在同一目录。两个开关分别控制两份实例;先各等待两次计数,再隐藏、等待、重新显示。
<script setup lang="ts">
import { ref } from 'vue'
import LifecycleProbe from './LifecycleProbe.vue'
const ordinary = ref(true)
const cached = ref(true)
</script>
<template>
<button @click="ordinary = !ordinary">切换普通实例</button>
<LifecycleProbe v-if="ordinary" />
<button @click="cached = !cached">切换缓存实例</button>
<KeepAlive :max="1">
<LifecycleProbe v-if="cached" />
</KeepAlive>
</template>普通实例隐藏时被卸载,重新显示会重新执行 setup,ticks 回到 0。缓存实例隐藏时被移出活动 DOM,ticks 的 ref 和组件身份仍在;重新显示时只恢复资源,从原值继续。这解释了为什么“看不见”“计时停止”“状态销毁”是三个不同事实。
再暂时去掉 onDeactivated(stop) 做对照:缓存实例隐藏期间仍然增长,重新显示时出现计数跳跃。KeepAlive 保存实例,不会替应用暂停任意计时器。恢复清理后,隐藏期间不增长;最终销毁整个父组件时资源也停止。若将缓存容量设为 1 后切换到另一种被缓存组件,旧实例还可能因容量淘汰而真正卸载,此时不能承诺草稿永远保留。
钩子调用也有前后关系。onMounted、onUpdated 观察的是 DOM 操作之后;它们不是在调用 ref setter 的那一刻执行。探针里的 onUpdated 只打印日志,不向模板依赖的 log 数组追加内容,否则记录“更新完成”本身又会触发一次更新。
更新钩子与 nextTick
onUpdated() 会在本组件任意 DOM 更新后执行,无法表示“某一个具体状态更新完成”。若一次动作只关心自己造成的 DOM 提交,在写状态后 await nextTick() 更精确:
count.value += 1
await nextTick()
// 此处读取由这次状态变化提交后的 DOM。在 onUpdated() 中无条件修改模板依赖状态,很容易形成“更新 → 钩子写状态 → 再更新”的循环。需要派生值时先考虑 computed,需要响应某个源时用 watch。
错误与渲染调试钩子
onErrorCaptured() 捕获的是从后代组件传播上来的渲染、事件、setup、watcher 等错误。返回 false 会停止继续向祖先钩子和 app.config.errorHandler 传播;只有在局部确实完成恢复时才这样做,不能顺手吞错。
onErrorCaptured((error, instance, info) => {
fallback.value = '子区域暂时不可用'
report(error, info)
return false
})错误 UI 不应再次渲染触发原错误的子树。onRenderTracked() 与 onRenderTriggered() 用于观察渲染依赖,只有开发模式调用,也不在 SSR 执行;它们是定位工具,不是业务事件系统。
失败路径:异步登记与匿名监听器
setTimeout(() => onUnmounted(stop), 0) // 失去当前实例归属
window.addEventListener('resize', () => refresh())
window.removeEventListener('resize', () => refresh()) // 不是同一个函数,移除失败同步登记生命周期;保存同一个监听器引用和一致的 capture 选项。计时器保存句柄,请求保存 AbortController,订阅保存取消函数。资源的身份就是清理所需的最小证据。
练习:验证四条时间线
实现 LifecycleProbe.vue 并由父组件用普通 v-if 与 <KeepAlive> 两种方式切换:
- 挂载后启动一个 20ms 计时器和一个
online监听器;重复start()不得重复注册。 - 普通
v-if卸载后两者都停止;KeepAlive 停用时暂停,再激活时从原ticks继续。 - 记录
mounted / activated / deactivated / unmounted与start / stop,解释观察到的顺序,不硬编码浏览器调度时间。 - 加一个后代组件抛错,局部显示 fallback 并记录
info;再移除return false,观察全局处理器是否仍收到错误。 - 说明 SSR 输出为什么不会包含计时器增长,也为什么不能在 setup 根部启动它。
参考答案:查看第 04 题。