Vue 基础体系 · 第 25/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 组件生命周期:挂载、更新、卸载、副作用和父子顺序
在 Vue 3 中,组件生命周期描述的是一个组件实例从创建、进入 DOM、响应状态变化,到最终从组件树中移除的过程。Composition API 通过一组 on... 函数暴露这些阶段:
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
生命周期钩子不是普通的“初始化回调集合”。它们分别对应组件渲染、响应式更新、DOM 操作和资源释放中的特定时刻。只有明确每个时刻发生了什么,才能判断某段逻辑应该放在哪个钩子中。
一、先建立组件生命周期模型
一个组件实例可以抽象为以下状态:
未创建
│ setup()
▼
已创建,尚未挂载
│ 首次渲染
▼
已挂载
│ 响应式状态或 props 变化
▼
更新中 ⇄ 已更新
│ 从父组件树中移除
▼
卸载中
│ 清理完成
▼
已卸载
这里的“挂载”指组件的虚拟节点经过渲染后,组件对应的 DOM 已经插入到页面;“更新”指响应式数据变化导致组件重新渲染,并可能产生 DOM patch;“卸载”指 Vue 将组件从当前组件树中移除,并停止其组件作用域中的响应式副作用。
一个组件的生命周期通常可以写成:
其中:
setup是组件实例建立响应式逻辑的阶段;mount是首次创建并插入 DOM 的阶段;update可以发生零次、一次或多次;unmount是组件从当前渲染树移除的阶段。
注意,这个模型描述的是普通组件。<KeepAlive>、异步组件、<Suspense>、SSR 和 Teleport 会改变某些时序或 DOM 位置,后文会分别说明。
二、setup() 与生命周期钩子的注册时机
Composition API 中,生命周期钩子必须在组件 setup() 执行期间同步注册:
import { onMounted } from 'vue'
onMounted(() => {
console.log('组件已挂载')
})
Vue 会把这个回调绑定到当前正在执行的组件实例。如果在一个异步回调中注册,通常已经没有正确的组件实例上下文:
setTimeout(() => {
// 不应在这里注册生命周期钩子
onMounted(() => {
console.log('不可靠的注册方式')
})
}, 0)
如果初始化逻辑本身是异步的,应当在同步阶段注册生命周期钩子,然后在钩子内部执行异步任务:
onMounted(async () => {
const response = await fetch('/api/profile')
// ...
})
这两种写法的区别是:
- 钩子注册是同步的,Vue 能够知道它属于哪个组件;
- 钩子内部的任务可以异步执行;
- 异步任务是否会在组件卸载后继续运行,需要单独处理。
三、挂载阶段:onBeforeMount 和 onMounted
3.1 onBeforeMount
onBeforeMount 在组件即将进行首次 DOM 渲染时调用。
此时:
- 组件的响应式状态已经建立;
- 模板已经完成编译或渲染函数已经准备好;
- 组件的 DOM 通常还没有插入页面;
- 模板引用,例如
ref="input"对应的元素引用,还不能依赖。
import { onBeforeMount } from 'vue'
onBeforeMount(() => {
console.log('即将首次挂载')
})
它适合观察或记录首次渲染前的状态,但不适合读取实际 DOM 尺寸:
onBeforeMount(() => {
// 不应依赖 elementRef.value 已经存在
})
3.2 onMounted
onMounted 在组件完成首次挂载后调用。对于普通组件,它意味着:
- 组件自己的 DOM 已经创建;
- 组件自己的 DOM 已经插入;
- 所有同步子组件也已经完成挂载;
- 可以安全访问模板引用和大多数 DOM 属性。
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputRef" placeholder="挂载后自动聚焦" />
</template>
这里 inputRef 初始值为 null,因为在首次挂载前元素尚不存在。onMounted 之后,Vue 才会把真实 DOM 元素赋给它。
3.3 挂载钩子不是“页面加载完成”
onMounted 只表示当前组件的同步挂载完成,不表示:
- 图片已经加载完成;
- 网络请求已经完成;
- 浏览器已经完成布局和绘制;
- 整个应用的所有异步组件都已经完成;
- 服务端渲染的 HTML 经过客户端 hydration 后所有业务逻辑都已完成。
如果需要等待 Vue 完成本轮 DOM 更新,可以使用 nextTick:
import { nextTick, ref } from 'vue'
const visible = ref(false)
const panelRef = ref<HTMLElement | null>(null)
async function openPanel() {
visible.value = true
// 等待 visible 导致的 DOM 更新完成
await nextTick()
console.log(panelRef.value?.getBoundingClientRect())
}
状态赋值不会立即同步修改 DOM。Vue 通常会将多次响应式变更批量调度到同一个更新周期中,nextTick() 用来等待该周期的 DOM patch 完成。
四、更新阶段:状态变化、批处理和 DOM 时机
4.1 什么会触发更新
组件更新通常由以下变化引起:
- 组件自身读取的响应式状态发生变化;
- 父组件传入的 prop 发生变化;
- 插槽内容变化;
- 某些依赖的计算值发生变化;
- 父组件重新渲染并导致该子组件需要重新执行更新。
例如:
const count = ref(0)
function increment() {
count.value++
count.value++
}
这段代码同步执行两次状态修改,但 Vue 通常会在同一个调度周期中合并它们,组件不一定更新两次。最终渲染结果是 count === 2,而不是先渲染一次 1 再渲染一次 2。
可以把一次调度近似表示为:
多个同步变更如果发生在同一轮 JavaScript 执行中,通常会共享一个更新周期。
4.2 onBeforeUpdate
onBeforeUpdate 在组件因为响应式变化即将更新 DOM 时调用。
此时:
- 组件的响应式状态已经是新值;
- DOM 通常仍然是旧状态;
- 可以比较“新状态”和“当前 DOM”;
- 不应在这里再次无条件修改触发当前更新的响应式状态。
import { onBeforeUpdate } from 'vue'
onBeforeUpdate(() => {
console.log('状态已变化,但 DOM 仍可能是旧内容')
})
例如,计数器从 0 变成 1 时:
响应式状态:1
DOM 文本:0
onBeforeUpdate:此时执行
DOM patch
DOM 文本:1
因此,如果目标是读取更新后的 DOM,应使用 onUpdated 或 nextTick,而不是 onBeforeUpdate。
4.3 onUpdated
onUpdated 在组件完成一次 DOM 更新后调用:
import { onUpdated } from 'vue'
onUpdated(() => {
console.log('组件 DOM 已更新')
})
它适合读取由本次更新产生的 DOM 结果,例如重新测量元素高度:
onUpdated(() => {
const height = panelRef.value?.offsetHeight
console.log('更新后的高度:', height)
})
但 onUpdated 不适合直接修改会触发同一组件再次更新的状态:
onUpdated(() => {
// 可能形成更新循环
// count.value++
})
如果确实要根据 DOM 测量结果更新状态,必须建立终止条件,或者把逻辑放到明确的事件、观察器或一次性流程中。
4.4 更新钩子与“状态变化次数”不是一一对应
以下代码不应假设 onUpdated 必然执行两次:
count.value++
count.value++
钩子对应的是组件更新周期,而不是每次赋值。更准确的判断方式是:
- 是否有依赖这个状态的组件渲染;
- 变更是否在同一调度批次内;
- 组件是否因为其他原因被跳过或重新渲染;
- 是否使用了不同的 watcher 调度策略。
如果逻辑关心的是某个具体状态变化,而不是整个组件 DOM 更新,应使用 watch:
import { watch } from 'vue'
watch(
() => count.value,
(newValue, oldValue) => {
console.log(`count: ${oldValue} -> ${newValue}`)
}
)
五、卸载阶段:onBeforeUnmount 和 onUnmounted
5.1 什么是卸载
卸载是 Vue 将组件从当前渲染树中移除的过程。例如:
<ChildPanel v-if="visible" />
当 visible 从 true 变成 false 时,ChildPanel 会被卸载。
卸载不等于隐藏:
<ChildPanel v-show="visible" />
v-show 只修改元素的 CSS 显示状态,组件仍然存在,也不会触发 onBeforeUnmount 或 onUnmounted。
5.2 onBeforeUnmount
onBeforeUnmount 在组件即将卸载时执行。此时:
- 组件仍然是活跃实例;
- DOM 通常仍然存在;
- 组件的响应式逻辑尚未完全停止;
- 适合执行卸载前的通知或读取最后状态。
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
console.log('组件即将卸载')
})
5.3 onUnmounted
onUnmounted 在组件卸载完成后调用。适合清理组件创建的外部资源:
import { onUnmounted } from 'vue'
onUnmounted(() => {
console.log('组件已经卸载')
})
需要清理的资源包括:
setInterval和setTimeout;window、document或第三方对象上的事件监听器;- WebSocket;
- 自定义事件总线订阅;
- 第三方 DOM 插件实例;
MutationObserver、ResizeObserver;- 未完成的网络请求;
- 手动创建且不属于当前组件作用域的 watcher 或 effect。
六、一个可运行的父子组件示例
下面的示例基于 Vue 3、<script setup lang="ts"> 和 Vite。它同时演示:
- 父子组件挂载顺序;
- 父子组件更新顺序;
- 子组件单独更新;
onMounted访问 DOM;watch的清理;- 定时器和事件监听器的卸载;
v-if触发卸载。
6.1 src/components/ChildPanel.vue
<script setup lang="ts">
import {
nextTick,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
onUnmounted,
onUpdated,
ref,
watch
} from 'vue'
const props = defineProps<{
value: number
}>()
const emit = defineEmits<{
increment: []
}>()
const localCount = ref(0)
const rootRef = ref<HTMLElement | null>(null)
const now = ref(new Date().toLocaleTimeString())
let timerId: number | undefined
function log(message: string) {
console.log(`[ChildPanel] ${message}`)
}
onBeforeMount(() => {
log('onBeforeMount')
})
onMounted(() => {
log('onMounted')
log(`DOM 是否存在:${rootRef.value !== null}`)
timerId = window.setInterval(() => {
now.value = new Date().toLocaleTimeString()
}, 1000)
window.addEventListener('resize', handleResize)
})
onBeforeUpdate(() => {
log(`onBeforeUpdate,props.value=${props.value}`)
})
onUpdated(() => {
log(`onUpdated,DOM 文本已反映 props.value=${props.value}`)
})
onBeforeUnmount(() => {
log('onBeforeUnmount')
})
onUnmounted(() => {
log('onUnmounted')
if (timerId !== undefined) {
window.clearInterval(timerId)
}
window.removeEventListener('resize', handleResize)
})
function handleResize() {
// 这里可以执行与窗口尺寸相关的逻辑
}
watch(
() => props.value,
async (newValue, _oldValue, onCleanup) => {
const controller = new AbortController()
// 组件卸载,或 props.value 再次变化时,取消上一轮异步任务
onCleanup(() => {
controller.abort()
log(`取消 value=${newValue} 对应的异步任务`)
})
try {
await new Promise<void>((resolve, reject) => {
const timeoutId = window.setTimeout(resolve, 500)
controller.signal.addEventListener('abort', () => {
window.clearTimeout(timeoutId)
reject(new DOMException('请求已取消', 'AbortError'))
})
})
if (!controller.signal.aborted) {
log(`异步任务完成,value=${newValue}`)
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
console.error('异步任务失败', error)
}
await nextTick()
}
)
</script>
<template>
<section ref="rootRef">
<h2>子组件</h2>
<p>父组件传入的 value:{{ props.value }}</p>
<p>子组件自己的 localCount:{{ localCount }}</p>
<p>当前时间:{{ now }}</p>
<button @click="localCount++">
只更新子组件自己的状态
</button>
<button @click="emit('increment')">
请求父组件增加 value
</button>
</section>
</template>
这个组件中有三类不同资源:
localCount、now是组件作用域内的响应式状态;setInterval和resize监听器是外部资源,需要显式释放;watch中模拟了一个可取消的异步任务,需要在下一轮任务开始或组件卸载时清理。
watch 回调的第三个参数 onCleanup 是通用的清理方式。它不应与组件卸载钩子混为一谈:
onUnmounted负责组件整体卸载时的清理;onCleanup负责某一次 watcher 回调对应的异步任务,在 watcher 重新触发或停止时清理。
6.2 src/App.vue
<script setup lang="ts">
import {
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
onUnmounted,
onUpdated,
ref
} from 'vue'
import ChildPanel from './components/ChildPanel.vue'
const value = ref(0)
const showChild = ref(true)
function log(message: string) {
console.log(`[App] ${message}`)
}
onBeforeMount(() => {
log('onBeforeMount')
})
onMounted(() => {
log('onMounted')
})
onBeforeUpdate(() => {
log('onBeforeUpdate')
})
onUpdated(() => {
log('onUpdated')
})
onBeforeUnmount(() => {
log('onBeforeUnmount')
})
onUnmounted(() => {
log('onUnmounted')
})
</script>
<template>
<main>
<h1>Vue 生命周期示例</h1>
<p>父组件 value:{{ value }}</p>
<button @click="value++">
更新父组件 value
</button>
<button @click="showChild = !showChild">
{{ showChild ? '卸载' : '重新挂载' }}子组件
</button>
<ChildPanel
v-if="showChild"
:value="value"
@increment="value++"
/>
</main>
</template>
在一个新的 Vite Vue TypeScript 项目中,可以将这两个文件放入对应位置,然后运行:
npm install
npm run dev
点击“更新父组件 value”时,预期会观察到:
[App] onBeforeUpdate
[ChildPanel] onBeforeUpdate
[ChildPanel] onUpdated
[App] onUpdated
这表示:
- 父组件先进入更新前阶段;
- 父组件更新其渲染结果,并将新的 prop 传给子组件;
- 子组件处理新的 prop 并更新自己的 DOM;
- 子组件更新完成后,父组件才进入
onUpdated。
这是一种普通父组件驱动子组件更新时的典型顺序。它不是“任何情况下所有组件都严格按同一日志序列执行”的保证,因为如果只有子组件自己的状态变化,父组件可能根本不会更新。
例如点击“只更新子组件自己的状态”:
[ChildPanel] onBeforeUpdate
[ChildPanel] onUpdated
父组件没有读取这个局部状态,因此没有必要重新执行自己的更新钩子。
七、父子组件生命周期顺序
7.1 首次挂载顺序
对于普通的同步父子组件,典型顺序如下:
sequenceDiagram
participant P as 父组件
participant C as 子组件
participant D as DOM
P->>P: setup()
P->>C: 创建子组件并执行 setup()
P->>P: onBeforeMount
P->>C: onBeforeMount
C->>D: 创建并插入子组件 DOM
C->>C: onMounted
P->>D: 完成父组件 DOM 插入
P->>P: onMounted
更常见的日志顺序可以概括为:
父 setup
子 setup
父 onBeforeMount
子 onBeforeMount
子 onMounted
父 onMounted
onMounted 是“后序”性质的钩子:父组件的 onMounted 会等待同步子组件挂载完成。因此父组件在 onMounted 中通常可以访问自己的 DOM,但不能据此推断异步子组件、延迟组件或所有后代都已经完成。
7.2 更新顺序
一次父组件驱动的更新通常表现为:
父 onBeforeUpdate
子 onBeforeUpdate
子 onUpdated
父 onUpdated
其直觉是:
- 更新前钩子在 DOM patch 前执行;
- 父组件需要先处理新的渲染结果;
- 子组件的 DOM 更新完成后,父组件的更新才算完成。
但必须区分“组件树更新”和“组件自身状态更新”:
| 触发原因 | 可能更新的组件 |
|---|---|
| 父组件自己的状态变化 | 父组件,以及受影响的子组件 |
子组件自己的 ref 变化 |
子组件本身 |
| 父组件传入的 prop 变化 | 子组件 |
| 子组件未实际产生新的渲染结果 | 可能没有子组件更新钩子 |
因此,不能把 onUpdated 当成“应用中任何后代都更新完成”的全局通知。
7.3 卸载顺序
普通同步父子组件卸载时,典型顺序为:
父 onBeforeUnmount
子 onBeforeUnmount
子 onUnmounted
父 onUnmounted
可以理解为:
- 父组件先收到“整个子树即将移除”的通知;
- 子组件依次执行自己的卸载前逻辑;
- 子组件清理完成;
- 父组件最后收到整个子树卸载完成的通知。
这对资源依赖很重要。例如父组件创建了一个服务,而子组件订阅这个服务:
- 父组件的
onBeforeUnmount仍可能先于子组件清理; - 如果父组件在这里立即销毁服务,子组件的卸载逻辑可能还会访问该服务;
- 更稳妥的做法是明确资源所有权,或者让子组件独立管理自己创建的资源。
7.4 顺序的边界
上面的顺序适用于普通同步组件,但以下情况不能简单套用:
异步组件和 Suspense
异步组件的挂载完成时间取决于异步依赖。父组件的普通挂载流程不会等同于“所有异步后代都完成”。如果业务需要感知异步分支完成,应使用 Suspense 提供的 pending、fallback 和 resolve 机制,而不是仅依赖父组件 onMounted。
<KeepAlive>
被 <KeepAlive> 缓存的组件切换出去时,通常是停用而不是卸载:
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
这种情况下:
- 不会执行普通的
onUnmounted; - 组件实例和状态会保留;
- 可以使用
onActivated和onDeactivated管理激活与停用。
例如轮询在停用时应暂停,而不是只在 onUnmounted 中清理:
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
startPolling()
})
onDeactivated(() => {
stopPolling()
})
Teleport
Teleport 会改变 DOM 的插入位置,但不会改变组件在 Vue 组件树中的父子关系。生命周期仍由组件树关系决定,不能根据 DOM 物理位置推断组件父子顺序。
八、副作用:为什么必须和生命周期一起理解
8.1 什么是副作用
副作用是指函数除了计算返回值,还对外部系统产生影响。例如:
window.addEventListener('resize', handler)
setInterval(refresh, 1000)
document.title = '编辑中'
socket.on('message', onMessage)
fetch('/api/data')
这些操作都依赖组件之外的资源。如果组件卸载后仍继续运行,就可能造成:
- 内存和定时器泄漏;
- 重复事件处理;
- 已卸载组件仍被异步回调引用;
- 页面切换后请求继续修改旧状态;
- WebSocket 或订阅长期占用连接;
- 开发环境中热更新后出现多个监听器。
组件副作用的基本约束可以写成:
更严格地说,如果资源句柄为 ,创建操作为 ,清理操作为 ,理想状态是:
清理操作应当幂等,也就是调用一次或多次都不会破坏程序状态。
8.2 响应式副作用与外部副作用
Vue 会自动管理一部分响应式副作用。例如在 setup() 中同步创建的 watch、watchEffect,通常会被绑定到当前组件作用域,并在组件卸载时自动停止:
watchEffect(() => {
console.log(count.value)
})
但是“自动停止 watcher”不等于“自动撤销外部资源”。下面的事件监听器不会因为 Vue 停止响应式 effect 而自动移除:
watchEffect(() => {
window.addEventListener('resize', handler)
})
这个写法还可能在 effect 每次重新执行时重复注册监听器。外部资源必须显式清理:
onMounted(() => {
window.addEventListener('resize', handler)
})
onUnmounted(() => {
window.removeEventListener('resize', handler)
})
8.3 异步请求的竞态和取消
仅在 onUnmounted 中设置一个布尔值,并不能真正取消网络请求:
let disposed = false
onUnmounted(() => {
disposed = true
})
它最多能防止回调继续写入状态,但请求本身仍可能占用网络资源。
更完整的方式是使用 AbortController:
import { onMounted, onUnmounted } from 'vue'
let controller: AbortController | undefined
onMounted(async () => {
controller = new AbortController()
try {
const response = await fetch('/api/items', {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
console.log(data)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
console.error('加载失败', error)
}
})
onUnmounted(() => {
controller?.abort()
})
这里有两条错误路径:
- 组件卸载:调用
abort(),请求以AbortError结束,这属于预期取消; - HTTP 错误、解析错误或其他网络错误:进入错误处理逻辑。
fetch 在收到 HTTP 404 或 500 时通常不会自动 reject,因此必须检查 response.ok。
如果请求会因为多个状态变化连续触发,应使用 watcher 的清理函数取消上一轮请求,避免旧请求晚于新请求返回并覆盖正确结果。
九、watcher 的执行时机与生命周期不是同一概念
watch 关注的是特定响应式源,生命周期钩子关注的是整个组件阶段。两者经常配合,但不能相互替代。
9.1 默认 flush
默认情况下,watcher 通常会在组件更新前执行,但具体回调不是“DOM 已经更新”的通知。如果需要读取更新后的 DOM,可以使用:
watch(
() => count.value,
() => {
// DOM 可能仍处于旧状态
},
{ flush: 'post' }
)
flush: 'post' 会把回调安排到 DOM 更新之后。
如果使用:
watch(source, callback, { flush: 'sync' })
回调会更接近同步执行,不经过通常的批处理。它适合极少数需要立即响应的场景,但高频状态变化可能导致大量重复执行,甚至破坏批处理带来的稳定性。
9.2 不要把 onUpdated 当 watcher
错误的替代方式:
onUpdated(() => {
saveToServer(form.value)
})
这会在组件的各种更新中触发保存,包括与 form 无关的更新。更明确的写法是:
watch(
form,
() => {
saveToServer(form.value)
},
{ deep: true }
)
但深度监听大型对象也有遍历和频繁触发成本,应根据实际字段拆分监听源。
十、模板 ref、DOM 更新和测量时机
模板引用的有效时间可以表示为:
setup 阶段:null
首次挂载后:HTMLElement
卸载前:通常仍可访问
卸载完成后:不应继续依赖
示例:
<script setup lang="ts">
import { nextTick, ref } from 'vue'
const expanded = ref(false)
const contentRef = ref<HTMLElement | null>(null)
async function expand() {
expanded.value = true
await nextTick()
const element = contentRef.value
if (element) {
console.log(element.scrollHeight)
}
}
</script>
<template>
<button @click="expand">展开</button>
<div v-if="expanded" ref="contentRef">
内容
</div>
</template>
如果不等待 nextTick(),expanded.value = true 之后,Vue 可能还没有创建 <div>,因此 contentRef.value 仍然是 null。
不过,nextTick 只等待 Vue 的更新队列,不保证浏览器已经完成最终绘制。如果依赖布局、字体加载或动画后的尺寸,还可能需要 requestAnimationFrame、ResizeObserver 或等待具体资源完成。
十一、错误处理与生命周期
生命周期回调、渲染函数、事件处理器、watcher 和异步任务都可能产生错误。Vue 提供两层常用处理方式。
11.1 onErrorCaptured
父组件可以捕获后代组件的错误:
import { onErrorCaptured, ref } from 'vue'
const errorMessage = ref('')
onErrorCaptured((error, instance, info) => {
errorMessage.value = `${info}: ${String(error)}`
console.error('捕获到子树错误', {
error,
instance,
info
})
// 返回 false 会阻止错误继续向更高层传播
return false
})
onErrorCaptured 适合为局部组件树提供降级 UI 或记录上下文。它不是所有异常的万能捕获器:
- 它主要捕获 Vue 管理范围内的错误;
fetch的失败仍应在try/catch中处理;setTimeout中抛出的错误不能简单假设会被组件错误边界处理;- 捕获函数本身如果再次抛错,可能产生新的错误路径。
11.2 全局错误处理
应用级错误可以配置:
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.config.errorHandler = (error, instance, info) => {
console.error('全局 Vue 错误', {
error,
instance,
info
})
}
app.mount('#app')
全局处理器适合统一日志、错误上报和诊断上下文,但不应取代组件内部的恢复逻辑。比如网络请求失败后显示重试按钮,应该由发起请求的组件或 composable 管理。
十二、服务端渲染与客户端生命周期边界
如果使用 SSR,setup() 可能在服务端执行,但服务端没有浏览器 DOM。因此以下代码不能无条件放在 setup() 顶层:
// SSR 环境中可能报错
const width = window.innerWidth
应将浏览器专属逻辑放入 onMounted:
import { onMounted, ref } from 'vue'
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
onMounted、onUpdated、onUnmounted 这类依赖客户端 DOM 或组件销毁过程的钩子不会在服务端渲染阶段执行。服务端只负责生成 HTML,客户端 hydration 后才建立浏览器中的组件运行时行为。
因此:
- 纯数据准备可以放在适合 SSR 的数据获取流程中;
- DOM 操作、窗口事件和浏览器 API 应放在客户端生命周期中;
- 不要假设
onMounted会在服务端执行。
十三、常见误解与失败表现
13.1 把 onMounted 当成所有子孙组件都完成
父组件 onMounted 通常等待同步子组件,但不等于等待:
- 异步组件;
- 网络请求;
- 图片加载;
- 延迟渲染分支;
- 第三方插件初始化;
- 所有后代的业务数据准备。
如果代码依赖某个明确资源,应等待那个资源本身,而不是依赖一个过于宽泛的生命周期钩子。
13.2 在 onUpdated 中无条件修改状态
onUpdated(() => {
visible.value = !visible.value
})
这可能导致:
状态变化
→ 组件更新
→ onUpdated 修改状态
→ 再次更新
→ onUpdated 再次修改状态
→ ...
浏览器表现可能是卡顿、警告或无限更新。更新后钩子应以观察和同步外部系统为主;若必须写回状态,需要有严格的变化条件。
13.3 用 v-show 测试卸载清理
如果使用 v-show,组件没有被卸载,定时器和 watcher 仍会运行。测试卸载逻辑应使用 v-if,或者对 <KeepAlive> 使用 onDeactivated。
13.4 只清理定时器,不清理监听器
下面的代码只清理了一半资源:
onMounted(() => {
const id = window.setInterval(refresh, 1000)
window.addEventListener('resize', resize)
})
onUnmounted(() => {
// 忘记 clearInterval,也忘记 removeEventListener
})
更安全的做法是让创建和销毁成对出现,并保存可复用的函数引用:
let id: number | undefined
onMounted(() => {
id = window.setInterval(refresh, 1000)
window.addEventListener('resize', resize)
})
onUnmounted(() => {
if (id !== undefined) {
window.clearInterval(id)
}
window.removeEventListener('resize', resize)
})
removeEventListener 要求事件类型、函数引用以及相关选项匹配。不能在移除时重新创建一个看似相同但实际不同的匿名函数。
13.5 在异步回调完成后直接写组件状态
onMounted(async () => {
const data = await loadData()
result.value = data
})
如果组件在 await 期间卸载,任务仍可能完成。Vue 的响应式系统通常不会因此立即报错,但这段逻辑已经失去业务意义,并可能保留不必要的引用。对可取消请求使用 AbortController,对不可取消任务使用清理标记或在回调中检查当前有效性。
十四、如何选择生命周期钩子
可以根据“要观察的对象”选择工具:
| 目标 | 更合适的工具 |
|---|---|
| 组件首次 DOM 创建前 | onBeforeMount |
| 首次访问模板 ref 或注册浏览器资源 | onMounted |
| 读取更新前的 DOM | onBeforeUpdate |
| 读取更新后的 DOM | onUpdated 或 nextTick |
| 组件即将移除 | onBeforeUnmount |
| 释放组件创建的外部资源 | onUnmounted |
| 监听具体响应式源 | watch |
| 对响应式依赖自动执行副作用 | watchEffect |
| 监听 watcher 对应的异步任务 | watcher 的 onCleanup |
| KeepAlive 切出或切入 | onDeactivated、onActivated |
| 捕获后代 Vue 错误 | onErrorCaptured |
| 统一处理未捕获的 Vue 错误 | app.config.errorHandler |
生命周期钩子解决的是“组件处于哪个阶段”,watcher 解决的是“哪个响应式依赖发生了变化”。两者都能执行副作用,但触发条件不同。
十五、最终的因果关系
理解 Vue 生命周期时,最重要的不是记住钩子名称,而是掌握以下因果链:
setup 建立响应式关系
↓
首次渲染前执行 onBeforeMount
↓
DOM 和同步子组件完成挂载
↓
执行 onMounted
↓
响应式依赖变化进入调度队列
↓
onBeforeUpdate 此时 DOM 可能仍是旧状态
↓
Vue 执行渲染并 patch DOM
↓
onUpdated 此时 DOM 已反映本轮更新
↓
组件被 v-if、路由或父树移除
↓
onBeforeUnmount
↓
卸载子树并停止组件作用域中的响应式副作用
↓
onUnmounted 中完成外部资源清理
生产代码中,副作用应与其所有权绑定:谁创建资源,谁负责停止资源;组件何时离开页面,资源就应在对应的卸载或停用阶段结束。这样处理后,生命周期不再只是调试日志,而是组件状态、DOM 时序、并发任务和资源释放之间的明确契约。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue SFC 与编译器:template、script setup、style 和编译产物
- 下一篇:Vue 模板引用:元素、组件实例、defineExpose 和生命周期边界
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论