Skip to content

04|生命周期:组件时间线不是 API 清单 ​

第四章概览 / 04。目标:根据资源需要选择钩子,并证明挂载、更新、缓存和卸载时没有重复启动或残留。

先问“资源何时有效” ​

生命周期钩子登记的是当前组件实例时间线上的回调。所有 Composition API 生命周期函数都必须在 setup 阶段同步调用;它们可以藏在 useXxx() 里,只要调用栈仍同步地从当前 setup() 出发。

钩子可靠事实常见用途
onBeforeMount响应式状态已建立,首次 DOM 尚未创建极少需要;不能读取模板 DOM
onMounted本组件 DOM 与同步子组件已挂载模板 ref、浏览器监听、观察器、客户端连接
onBeforeUpdateDOM 即将因响应式变化更新读取更新前 DOM;可在此修改状态但应谨慎
onUpdated本轮 DOM 更新完成,多个状态写入可能已批处理观察最终 DOM;不要无条件写回触发源
onBeforeUnmount实例仍完整可用,即将卸载需要实例仍活动的最后清理
onUnmounted子树与 setup 中绑定的响应式 effects 已停止清计时器、监听器、连接与第三方订阅
onActivated / onDeactivated<KeepAlive> 缓存实例重新插入 / 移出 DOM恢复、暂停轮询或观察;不是新实例

这些挂载、更新、卸载钩子都不会在 SSR 中执行。服务端 setup 里启动的永久资源不能指望 onUnmounted 收尾。

最小可运行例子:可暂停且幂等的探针 ​

vue
<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,与前面的探针放在同一目录。两个开关分别控制两份实例;先各等待两次计数,再隐藏、等待、重新显示。

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() 更精确:

ts
count.value += 1
await nextTick()
// 此处读取由这次状态变化提交后的 DOM。

在 onUpdated() 中无条件修改模板依赖状态,很容易形成“更新 → 钩子写状态 → 再更新”的循环。需要派生值时先考虑 computed,需要响应某个源时用 watch。

错误与渲染调试钩子 ​

onErrorCaptured() 捕获的是从后代组件传播上来的渲染、事件、setup、watcher 等错误。返回 false 会停止继续向祖先钩子和 app.config.errorHandler 传播;只有在局部确实完成恢复时才这样做,不能顺手吞错。

ts
onErrorCaptured((error, instance, info) => {
  fallback.value = '子区域暂时不可用'
  report(error, info)
  return false
})

错误 UI 不应再次渲染触发原错误的子树。onRenderTracked() 与 onRenderTriggered() 用于观察渲染依赖,只有开发模式调用,也不在 SSR 执行;它们是定位工具,不是业务事件系统。

失败路径:异步登记与匿名监听器 ​

ts
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 题。

用心记录每一步成长