上午 10:02,资料预览对话框初次打开。组件给 window 加上 Escape 监听,发出详情请求,插槽里的封面图也开始加载。界面顺利出现,暂时看不出问题。
10:07,对话框已经开关过一轮。用户再次打开它并按下 Escape,关闭逻辑却连续响应了两次。再开关几轮,日志还会继续增加——每个新实例都添了一份监听,离场时却没有把自己的那一份带走。对话框虽然从页面消失了,它留下的行为仍在继续。
10:09,对话框关闭后,先前发出的详情请求才返回,并继续把结果写入预览状态。若这时用户已经切换到另一份资料,迟到的旧结果还可能盖住眼前的新内容。这里缺的不是“等待得再耐心一点”,而是要判断一项异步工作什么时候已经过期。
10:12,开发者把测量封面高度的代码放进 onMounted,重新打开对话框,读到的高度却依然是 0。组件 DOM 的确已经进入页面,但插槽里的图片尚未下载和解码;“组件挂好了”和“图片可以测量了”原来是两件事。
这正是上一章那个带插槽的对话框:标题、正文和操作区已经有了清楚的模板边界,父组件可以放心替换内容。可插槽解决的是“内容由谁提供、放在哪里”,并不会替组件安排监听何时开始、请求何时失效、资源何时可以读取。外壳越常被复用,这些藏在时间线里的问题反而越容易撞在一起。
这三个症状分别发生在打开、使用和关闭附近,根源却指向同一个问题:组件正在时间线上走到哪一步?它此刻已经拥有了什么,又应该在离开前收回什么? 只看最终那份模板,无法回答这些问题;我们得沿着组件实例从出现到消失的过程追踪它。
接下来就把这条时间线归纳成四段:准备时建立状态并登记后续工作,挂载时接触已经进入页面的 DOM,更新时处理数据与 DOM 的先后节奏,离场时终止监听、请求和其他外部联系。它们不是四块互不相干的知识,而是同一个组件从准备、登场、变化到收尾的连续过程。

一个普通客户端组件,可以先用四个阶段来理解:
这里有两个很重要的观察。
第一,生命周期属于组件实例,不属于 .vue 文件本身。同一个 NoticeCard.vue 在页面里出现三次,就有三个实例,也就有三套状态、三次挂载和各自的清理过程。某一个实例离场,不代表另外两个也离场。
第二,“状态变化”不等于“整个组件重新创建”。更新阶段通常只是重新运行组件的渲染工作,找出需要变动的 DOM 并做最小修补;<script setup> 的顶层代码不会因为按钮点了一次就从头执行。只有旧实例真正被卸载、随后又创建了新实例,准备阶段才会重新开始。
因此,下面这段代码中的两行日志含义完全不同:
<script setup>
import { ref, onUpdated } from 'vue'
const count = ref(0)
console.log('建立一个新的计数器实例')
onUpdated(() => {
console.log('这个实例的界面更新了一次')
})
</script>
<template>
<button @click="count++">计数:{{ count }}</button>
</template>第一次出现组件时,控制台会看到“建立一个新的计数器实例”。之后每次点击按钮,通常只会看到“这个实例的界面更新了一次”。如果父组件用 v-if 把它移除,再重新显示,才会建立一个全新的实例。
模板不是运行时反复拼接的字符串。构建时,模板会被编译成渲染代码;运行时,响应式变化触发渲染工作,Vue 再把新旧结果之间真正需要改变的部分修补到 DOM 上。
<script setup> 已经开始了组件的一生在组合式 API 里,没有 onBeforeCreate 和 onCreated 这两个注册函数。原因不是“创建阶段消失了”,而是 <script setup> 自己就承担了准备工作。每创建一个组件实例,这段顶层代码都会为该实例执行一次。
<!-- WelcomeCard.vue -->
<script setup>
import { computed, ref, onMounted } from 'vue'
const name = ref('小满')
const greeting = computed(() => `你好,${name.value}`)
function changeName() {
name.value = '知夏'
}
console.log('1. 准备状态和方法')
在顶层代码执行时,name、greeting 和 changeName 已经建立起来,但模板对应的真实 DOM 还不存在。此时适合做三类事:
onMounted、onUpdated、onUnmounted 等钩子。“同步注册”值得单独强调。Vue 需要在组件准备期间知道“这段回调属于哪个实例”。下面的写法把注册动作塞进了定时器,等它执行时,当前组件的准备过程早已结束,Vue 无法可靠地把钩子挂回这个实例:
// 不要这样注册生命周期钩子
setTimeout(() => {
onMounted(() => {
console.log('太晚了,已经错过注册时机')
})
}, 100)正确的做法是立即注册钩子,把“以后才执行”的工作写进回调内部:
onMounted(() => {
setTimeout(() => {
console.log('钩子同步注册,内部任务可以稍后执行')
}, 100)
})组合式函数也是同样的道理。一个 useWindowWidth() 可以在自己内部同步注册 onMounted 和 onUnmounted,调用它的组件离场时,清理逻辑会跟着正确的实例执行。生命周期不要求所有代码都挤在一个文件里,它要求的是注册时仍能确认“我属于谁”。
准备阶段不适合直接写 document.querySelector()、window.addEventListener() 或初始化依赖元素尺寸的图表。浏览器 API 虽然可能存在,但组件自己的 DOM 还没被放进页面;如果应用还会做服务端渲染,window 和 document 在服务端甚至根本不存在。把这类工作留给挂载阶段,会让代码的边界清楚很多。
挂载是组件第一次把界面交给浏览器的过程。onBeforeMount 发生在首次 DOM 创建之前,onMounted 发生在组件自己的 DOM 树已经创建并插入父容器之后。

先看一个真正使用 .vue 单文件组件的例子:
<!-- SearchBox.vue -->
<script setup>
import { ref, onBeforeMount, onMounted } from 'vue'
const inputEl = ref(null)
const keyword = ref('')
console.log('1. setup:建立状态,此时 inputEl 还是 null')
onBeforeMount(() => {
console.log('2. onBeforeMount:即将首次渲染')
console.log(inputEl.value)
页面出现后,输入框会自动获得焦点。控制台依次看到准备、挂载前、挂载后三条日志,而且模板引用 inputEl 会从 null 变成真实的输入元素。这正是 onMounted 最典型的用途:工作必须依赖已经创建的 DOM。
onMounted 到底保证了什么当 onMounted 执行时,可以确定:
但它不保证下面这些事情已经完成:
<img> 指向的图片已经下载并解码;fetch() 请求已经返回;所以,“onMounted 之后所有内容都准备好了”是一个过度承诺。比如你要读取图片的天然尺寸,应该等待图片自己的 load 事件,而不是只等组件挂载:
<script setup>
import { ref } from 'vue'
const sizeText = ref('图片仍在加载')
function readImageSize(event) {
const image = event.currentTarget
sizeText.value = `图片尺寸:${image.naturalWidth} × ${image.naturalHeight}`
}
</script>
<
再比如,你要初始化一个依赖容器宽度的图表,可以在 onMounted 里读取模板引用;但如果图表尺寸还会随窗口变化,就不能只读取一次,还需要添加窗口监听,并在离场时移除。生命周期不是“只管开始”,而是要求开始和结束成对出现。
模板引用在首次挂载前通常是 null。不要用一个随意的延时去“猜 DOM 大概好了”,应当把首次访问放在 onMounted,把某次状态变化后的访问放在 await nextTick() 之后。
假设页面上有一个计数器:
count.value++这行代码执行后,count.value 会立刻变成新值,但 DOM 不会在同一行代码的中间立刻重画。Vue 会把需要更新的工作放进队列,在本轮同步代码结束后集中处理。这样,同一个组件连续改动多次状态时,Vue 不必做多次重复渲染。
count.value++
count.value++
count.value++数据确实连续增加了三次,但界面通常只需要在这一批任务里更新一次。这叫批处理。它不是拖延,而是在保证结果正确的同时避免无意义的重复工作。

nextTick 等待的是这一批 DOM 更新完成,而不是随意等待固定毫秒数。nextTick 读取这一次操作后的新 DOM下面的组件把“数据值”和“DOM 文字”同时打印出来,可以直接观察更新时机:
<!-- TickDemo.vue -->
<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
const numberEl = ref(null)
const logs = ref([])
async function addOne() {
count.value++
logs.value.push(`修改后,数据是 ${count.
第一次点击后,页面上的日志会依次呈现:
修改后,数据是 1
立刻读取 DOM:0
nextTick 后读取 DOM:1这三行没有互相矛盾。响应式数据先完成赋值,DOM 修补随后发生;await nextTick() 把后续代码安排到本批 DOM 更新完成之后。
你可能会想:那我写 setTimeout(..., 0) 行不行?有时看起来也能读到新 DOM,但它表达的是“等一个计时任务”,不是“等 Vue 完成当前更新”。nextTick 更准确,也让读代码的人立刻知道你在等待什么。
onBeforeUpdate 与 onUpdated 分别看到什么onBeforeUpdate 在组件因为响应式变化而修补 DOM 之前执行。此时数据已经是新的,DOM 仍是旧的,因此它适合记录更新前的页面状态,例如保存列表容器原来的滚动高度。
onUpdated 在组件的 DOM 完成一次更新后执行。它适合做不修改组件状态的观察、埋点或与外部界面工具同步。
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
const message = ref('早上好')
const textEl = ref(null)
onBeforeUpdate(() => {
console.log('更新前 DOM:', textEl.value.textContent)
console.log('更新前数据:', message.value)
})
onUpdated(() =>
点击后,可以观察到大致这样的顺序:
更新前 DOM:早上好
更新前数据:下午好
更新后 DOM:下午好不过,如果你的需求是“点击这个按钮后,等这一次变化更新完,再滚动到底部”,优先把 await nextTick() 写在按钮处理函数里。onUpdated 会在组件的任意一次 DOM 更新后执行,它不知道你关心的是哪一次业务操作;随着组件变复杂,它可能因为别的状态变化也被触发。
onUpdated 里无条件修改状态下面的代码看似是在“更新后记一次次数”,实际上会制造循环:
const updateCount = ref(0)
onUpdated(() => {
updateCount.value++ // 这次修改又会触发下一次更新
})过程会变成:DOM 更新 → 执行 onUpdated → 状态改变 → DOM 再更新 → 再执行 onUpdated。就像你每次整理完桌面都故意往桌上扔一张纸,当然永远整理不完。
更稳妥的做法是把业务状态修改放进明确的事件处理函数、watch 或计算逻辑里;如果只是要统计渲染次数,用开发工具、性能标记或不会参与当前模板渲染的外部调试手段。onUpdated 应该是一扇观察窗,不应该成为无条件推动下一轮更新的按钮。
生命周期属于实例,因此组件树里会同时存在很多条生命周期。理解父子顺序时,不必背全部内部细节,抓住一个直觉就够了:父组件只有等同步子组件的界面也站稳,才能宣布自己的挂载完成;更新与卸载也需要照顾整棵子树。
下面用两个文件观察首次挂载:
<!-- ChildPanel.vue -->
<script setup>
import { onBeforeMount, onMounted } from 'vue'
console.log('子:setup')
onBeforeMount(() => console.log('子:onBeforeMount'))
onMounted(() => console.log('子:onMounted'))
</script>
<template>
<p>我是子组件</p>
<!-- ParentPanel.vue -->
<script setup>
import { onBeforeMount, onMounted } from 'vue'
import ChildPanel from './ChildPanel.vue'
console.log('父:setup')
onBeforeMount(() => console.log('父:onBeforeMount'))
onMounted(() => console.log('父:onMounted'))
</script>
<template>
在常规同步组件树中,控制台会呈现下面的关键关系:
父:setup
父:onBeforeMount
子:setup
子:onBeforeMount
子:onMounted
父:onMounted最值得记的是最后两行:子组件先完成挂载,父组件随后完成。因此在父组件 onMounted 中,通常可以访问同步子组件已经生成的 DOM。
但不要把这条规律扩大成“父组件会等世间一切异步工作”。异步组件、图片下载和网络请求仍有自己的完成时机。生命周期顺序描述的是组件树的同步建立与修补,不是整个网页所有资源的总进度条。
调试父子顺序时,建议给日志加上组件名和实例相关的业务标识,例如:
console.log(`[消息卡片 ${props.id}] mounted`)否则列表里十个相同组件同时打印 mounted,你会知道“有人挂载了”,却不知道究竟是谁。
组件在自己内部创建的响应式渲染工作、计算属性,以及在准备阶段同步创建并归属于该实例的监听器,会在卸载时由 Vue 停止。但浏览器并不知道哪些外部资源属于哪个 Vue 组件。你手动创建的定时器、窗口事件、观察器、网络连接和请求,需要由你明确清理。

onBeforeUnmount 执行时,组件实例仍然可用,DOM 也还在,适合需要在拆除前读取最后状态的场景。onUnmounted 执行时,子组件已经卸载,当前组件关联的响应式作用也已经停止,最常用来做最终清理。
<!-- SessionTimer.vue -->
<script setup>
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
const seconds = ref(0)
const online = ref(true)
let timerId
function updateNetworkState() {
online.value = navigator.onLine
}
onMounted(() => {
updateNetworkState()
这里有一个很容易忽略的细节:添加和移除监听器时必须使用同一个函数引用。下面的两支箭头函数长得一样,却是两个不同的函数对象,因此移除不会成功:
// 错误示例
window.addEventListener('resize', () => console.log(window.innerWidth))
window.removeEventListener('resize', () => console.log(window.innerWidth))把处理函数先命名,再分别传给添加与移除操作,资源才能真正成对。
请求会在两种情况下“过期”:
第一种可以在 onUnmounted 中取消。第二种更适合在 watch 的清理回调中取消,因为每次监听源改变时,上一轮工作就应立即失效,而不是一直等到整个组件卸载。
<!-- UserLookup.vue -->
<script setup>
import { ref, watch } from 'vue'
const userId = ref('1001')
const user = ref(null)
const loading = ref(false)
const error = ref('')
watch(
userId,
async
如果用户很快把 1001 改成 1002,第一次回调的清理函数会取消旧请求,避免较慢的旧结果最后返回,反而覆盖较新的结果。这个例子选择 watch 第三个参数提供的 onCleanup,写法直观,也能清楚表达“每一轮副作用都带着自己的收尾动作”。
在 Vue 3.5 及之后,也可以使用 onWatcherCleanup 注册相同意图的清理;它必须在监听回调的同步执行阶段注册,不能等到 await 之后再调用。无论选哪种写法,最重要的都不是 API 名字,而是意识到:异步结果也有保质期。
不要只靠“组件卸载后不再赋值”来掩盖旧请求。能取消的工作应尽早取消;不能取消的 Promise,也至少要用失效标记阻止过期结果写回当前界面。
v-if、v-show 与真正的卸载很多“为什么没有执行卸载钩子”的问题,根源不是钩子失灵,而是组件其实没有离场。
<ChildPanel v-if="visible" />当 visible 从 true 变成 false,v-if 会移除组件实例,触发离场流程。再次变成 true 时,会创建一个新实例,局部状态回到初始值。
<ChildPanel v-show="visible" />v-show 只是切换元素的 CSS 显示状态,实例仍然存在,因此不会因为隐藏而触发 onUnmounted,计时器也不会自动暂停。它像是关灯,不是让演员退场。
选择时可以问自己:
v-show 往往更合适;v-if 的语义更直接;<KeepAlive> 与激活/停用钩子。此外,改变组件的 key 也可能迫使 Vue 把旧实例卸载,再创建新实例:
<ProfileCard :key="userId" :user-id="userId" />这种做法有时用于“编号变化时彻底重置组件”,但不要把它当成修复所有更新问题的万能按钮。若只是 prop 改变后重新请求数据,用 watch 表达依赖关系通常更准确,也能保留没有必要重置的局部状态。
<KeepAlive>:暂时离开,不等于被卸载普通动态组件切走时,旧实例会被卸载,内部输入值、滚动位置等局部状态随之丢失。<KeepAlive> 会缓存暂时不显示的实例:它从 DOM 中移开,却没有结束生命,因此会进入“停用”状态,而不是立刻卸载。

<!-- Workspace.vue -->
<script setup>
import { shallowRef } from 'vue'
import EditorPanel from './EditorPanel.vue'
import PreviewPanel from './PreviewPanel.vue'
const currentPanel = shallowRef(EditorPanel)
</script>
<template>
<nav>
<button @click="currentPanel = EditorPanel">编辑</button>
被缓存的面板可以用 onActivated 和 onDeactivated 感知回来与暂停:
<!-- EditorPanel.vue -->
<script setup>
import { ref, onActivated, onDeactivated, onUnmounted } from 'vue'
const draft = ref('这段草稿切走后仍会保留')
let timerId
function startAutoSave() {
if (timerId) return
timerId = window.setInterval(() => {
console.log('自动保存草稿')
}, 5000)
onActivated 在组件首次挂载进入页面时也会执行,之后每次从缓存回来都会执行;onDeactivated 在组件进入缓存时执行,最终真正卸载时也会经历停用。上面的 startAutoSave 和 stopAutoSave 都写成可重复调用的安全函数,这样即使状态切换多次,也不会叠出多个定时器。
这也解释了一个常见误区:把暂停逻辑只放在 onUnmounted 里,缓存组件切走时它仍会继续工作。使用 <KeepAlive> 后,需要把资源分成两类:
onActivated / onDeactivated 这一对里;onUnmounted 中兜底。缓存不是“永远不卸载”。缓存数量可能受限制,组件也可能因为外层结构离场而最终卸载。因此,停用与卸载两条路径都要想清楚。
如果应用只在浏览器里运行,这一节可以先建立印象;等以后接触 SSR 时再回来复习。
服务端渲染的任务,是在服务器上把组件输出为 HTML 字符串。那里没有真实浏览器 DOM,也没有用户点击后的一轮轮界面更新。因此:
onMounted、onBeforeMount 不会在服务端执行;onUpdated、onBeforeUpdate 不会在服务端执行;onUnmounted、onBeforeUnmount 也不会在服务端执行;window、document、localStorage 等浏览器对象不能在通用顶层代码中直接使用。这带来一个反直觉的风险:如果你在 <script setup> 顶层创建了 setInterval,然后指望 onUnmounted 清理,在服务器上清理钩子不会到来,定时器就可能一直留着。浏览器专属且需要清理的副作用,应在 onMounted 中启动。
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const width = ref(0)
function readWidth() {
width.value = window.innerWidth
}
onMounted(() => {
readWidth()
window.addEventListener('resize', readWidth)
})
onUnmounted(() => {
服务端需要等待组件数据时,还有专门的 onServerPrefetch。它注册一个异步任务,服务端在渲染该组件前等待任务完成;客户端若没有拿到预取数据,可以再在 onMounted 中补取。
<script setup>
import { ref, onServerPrefetch, onMounted } from 'vue'
const lesson = ref(null)
async function loadLesson() {
const response = await fetch('/api/lesson/current')
lesson.value = await response.json()
}
onServerPrefetch(loadLesson)
onMounted(async
实际项目通常会由上层框架统一处理服务端数据获取和数据注入,但底层判断仍然一样:准备阶段可能在不同运行环境中执行;依赖真实 DOM 的代码只属于客户端挂载阶段。
本章以 Vue 3 项目里更常见的 .vue + <script setup> 为主。遇到旧项目时,你仍会看到 Options API。它们不是两套生命周期,而是同一批时机的两种登记方式。
一个简短对照如下:
<script>
export default {
data() {
return {
seconds: 0,
timerId: undefined
}
},
mounted() {
this.timerId = window.setInterval(() => {
this.seconds++
}, 1000)
},
unmounted() {
window.clearInterval(
Options API 的钩子要写成普通方法,若需要访问 this,不要使用箭头函数:
export default {
mounted: () => {
console.log(this) // 这里的 this 不是组件实例
}
}阅读旧代码时,先把名字翻译成“挂载前、挂载后、更新前、更新后、离场前、离场后”,再看逻辑是否放对位置。不要急着把整个文件改写成另一套语法;判断时机和资源边界,比语法外形更重要。
下面用父组件控制实验卡片的出现、更新和离场。将两个文件放进一个普通 Vue 3 项目即可运行。
<!-- LifecycleLab.vue -->
<script setup>
import {
ref,
nextTick,
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
const props = defineProps({
title: {
type: String,
required: true
}
})
const panelEl = ref(null
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import LifecycleLab from './components/LifecycleLab.vue'
const visible = ref(true)
const title = ref('早班组件')
</script>
<template>
<main>
<div class="toolbar"
按下面的顺序操作,生命周期会非常清楚:
setup → onBeforeMount → onMounted,计时开始;nextTick 前后的 DOM 值不同;setup;onBeforeUnmount,随后 onUnmounted 清除定时器;这个实验里,seconds 每秒变化,也会触发更新钩子,所以控制台中的 onUpdated 会比较频繁。这恰好说明:它是整个组件的更新观察点,并不专属于“加一”按钮或标题变化。真正与某个动作对应的 DOM 读取,放在那个动作内部配合 nextTick 更容易维护。

遇到生命周期问题时,可以按下面的顺序排查。
给组件日志带上 props.id,检查 v-if、动态组件和 key 是否导致旧实例离场、新实例创建。若 setup 又打印了一次,就不是普通更新,而是新实例开始了新的一天。
刚赋值后数据已更新,DOM 可能仍是旧的。需要当前动作对应的新 DOM 时使用 await nextTick();需要模板元素时优先使用模板引用,不要到处用全局选择器。
不要在 setTimeout、请求回调或普通事件里临时调用 onMounted。钩子应在 <script setup> 的准备阶段同步登记,异步工作写到已登记的回调内部。
v-show 不卸载组件,<KeepAlive> 会让组件进入停用状态。期待 onUnmounted 却没有看到日志时,先确认实际用的是 v-if、v-show 还是缓存组件。
看到 setInterval,就搜索对应的 clearInterval;看到 addEventListener,就找相同函数引用的 removeEventListener;看到长请求或订阅,就找取消、断开或失效处理。若开始与清理相隔几百行,最好抽成一个组合式函数,让两者重新靠近。
某些开发工具或上层框架可能为了暴露不安全的副作用,在开发阶段执行额外检查。不要用“加一个全局布尔值让它只跑一次”草率掩盖问题;先确认初始化函数是否可重复调用、清理是否完整、生产构建是否有相同行为。
一个好用的日志格式是:
function trace(stage, detail = '') {
const time = performance.now().toFixed(1)
console.log(`[${time}ms][资料面板 ${props.id}][${stage}] ${detail}`)
}时间、组件名、实例标识和阶段齐全后,十个组件同时更新也不会变成一团无法辨认的日志。
onMounted请求是否放在 onMounted,取决于它是否只应在客户端挂载后开始,以及项目如何处理服务端数据。普通客户端组件放在 onMounted 很直观;但如果请求由路由、状态层或服务端预取负责,就不必机械地搬进挂载钩子。真正要问的是:谁拥有请求、何时需要结果、过期后怎样取消。
onMounted 等于页面全部加载完成挂载只描述组件 DOM 与同步子树的状态。图片、字体、请求和异步组件仍有自己的完成信号。需要图片尺寸就监听 load,需要请求结果就等待对应 Promise,不要让一个钩子承担它没有承诺的事情。
onUpdatedonUpdated 的覆盖范围很广。只与一次点击相关的工作放回点击处理函数,并在修改状态后 await nextTick();只与某个数据源相关的副作用放进 watch。范围越精确,触发次数越容易推理。
Vue 会停止属于实例的响应式作用,但它不会猜测某个全局事件、定时器或 WebSocket 是你为这个组件创建的。手动建立的外部联系,需要手动解除。
onUnmounted进入 <KeepAlive> 缓存通常是停用,不是卸载。需要切走即暂停的任务,应使用 onDeactivated;回到页面再用 onActivated 恢复,并保留最终卸载的兜底清理。
在准备阶段同步创建的 watch 通常能归属当前实例并在卸载时停止;若你在很久以后才从异步回调里新建监听,它可能不再自动绑定,需要保留停止函数并自行调用。更好的方式往往是同步创建监听,在回调内部用条件判断数据是否准备好。
做一个 NoteEditor.vue:首次挂载后让标题输入框自动获得焦点。要求使用模板引用,不使用 document.querySelector,并在注释中解释为什么准备阶段拿不到元素。
维护一个消息数组,点击“添加消息”后把容器滚动到底部。先记录添加后立刻读取的 scrollHeight,再用 await nextTick() 读取新高度,观察两次结果的差异。
父组件用 v-if 控制时钟出现与离场。时钟挂载时每秒更新一次,离场后清除定时器。连续开关五次,确认控制台没有出现越来越快的重复日志。
监听搜索词并请求结果。用户快速输入时,用监听清理回调和 AbortController 取消上一轮请求;对取消错误保持安静,对真实网络错误显示中文提示。
用动态组件在“资料表单”和“预览面板”之间切换,再用 <KeepAlive> 保留表单内容。表单停用时暂停自动保存,激活时恢复,最终卸载时再次兜底清理。
为下面每项任务选择位置,并写一句理由:
const count = ref(0);window 的滚动监听;可以用这组答案自查:准备阶段、onMounted、nextTick、onUnmounted、onDeactivated、onServerPrefetch。如果理由能说清“此时已经具备什么”和“何时需要收尾”,就算真正理解了,而不只是记住名字。
沿着对话框的事故一路查到这里,我们已经能把每个现象放回正确的时间位置。生命周期的核心不是“在八个钩子里选一个”,而是管理组件与外部世界之间的时间关系:
<script setup> 顶层代码为每个实例准备状态,并同步登记钩子;onMounted 说明组件 DOM 与同步子树已经就位,不代表所有异步内容完成;nextTick;onUpdated 会响应组件的多种 DOM 更新,不适合无条件修改状态;v-if 会让实例离场,v-show 只是隐藏,<KeepAlive> 则让实例在激活与停用之间切换;以后再遇到“为什么这段代码没有按预期执行”,先别急着加延时。问三个问题:现在是准备、挂载、更新还是离场?我依赖的数据和 DOM 此刻真的可用吗?这项工作结束时由谁清理?
能回答这三个问题,生命周期就从一组名词,变成了你可以随时拿来定位问题的地图。回到开头的对话框,我们现在已经知道 Escape 监听要成对移除、离场或条件变化时要让旧请求失效、图片尺寸要等待图片自己的完成信号。单个组件的问题已经能解决,真正还悬着的是:这些总是成对出现的工作,是否每次都要重新写一遍?
可当资料预览、订单搜索和编辑面板都需要这些能力时,新的麻烦又出现了:多个组件各自保存一份状态,各自注册快捷键和窗口监听,各自取消请求,再各自补上一遍清理。我们虽然知道每项资源应该怎样收尾,却仍要在许多组件里重复拼装“状态 + 监听 + 清理”这条完整功能线。
这条功能线能不能从组件里完整取出,变成 useEscapeKey()、useRequest() 这样可以复用、又能跟随调用者正确收尾的能力?这两个函数草图能否成为真正的答案,要看它们能不能把状态、监听与清理整条功能线一起带走,并在调用它们的组件离场时一同收好尾。