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 将组件从当前组件树中移除,并停止其组件作用域中的响应式副作用。

一个组件的生命周期通常可以写成:

setupmount(update)nunmount\text{setup} \rightarrow \text{mount} \rightarrow (\text{update})^n \rightarrow \text{unmount}

其中:

  • 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 能够知道它属于哪个组件;
  • 钩子内部的任务可以异步执行;
  • 异步任务是否会在组件卸载后继续运行,需要单独处理。

三、挂载阶段:onBeforeMountonMounted

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 什么会触发更新

组件更新通常由以下变化引起:

  1. 组件自身读取的响应式状态发生变化;
  2. 父组件传入的 prop 发生变化;
  3. 插槽内容变化;
  4. 某些依赖的计算值发生变化;
  5. 父组件重新渲染并导致该子组件需要重新执行更新。

例如:

const count = ref(0)

function increment() {
  count.value++
  count.value++
}

这段代码同步执行两次状态修改,但 Vue 通常会在同一个调度周期中合并它们,组件不一定更新两次。最终渲染结果是 count === 2,而不是先渲染一次 1 再渲染一次 2

可以把一次调度近似表示为:

响应式变更加入更新队列执行渲染DOM patch\text{响应式变更} \rightarrow \text{加入更新队列} \rightarrow \text{执行渲染} \rightarrow \text{DOM patch}

多个同步变更如果发生在同一轮 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,应使用 onUpdatednextTick,而不是 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}`)
  }
)

五、卸载阶段:onBeforeUnmountonUnmounted

5.1 什么是卸载

卸载是 Vue 将组件从当前渲染树中移除的过程。例如:

<ChildPanel v-if="visible" />

visibletrue 变成 false 时,ChildPanel 会被卸载。

卸载不等于隐藏:

<ChildPanel v-show="visible" />

v-show 只修改元素的 CSS 显示状态,组件仍然存在,也不会触发 onBeforeUnmountonUnmounted

5.2 onBeforeUnmount

onBeforeUnmount 在组件即将卸载时执行。此时:

  • 组件仍然是活跃实例;
  • DOM 通常仍然存在;
  • 组件的响应式逻辑尚未完全停止;
  • 适合执行卸载前的通知或读取最后状态。
import { onBeforeUnmount } from 'vue'

onBeforeUnmount(() => {
  console.log('组件即将卸载')
})

5.3 onUnmounted

onUnmounted 在组件卸载完成后调用。适合清理组件创建的外部资源:

import { onUnmounted } from 'vue'

onUnmounted(() => {
  console.log('组件已经卸载')
})

需要清理的资源包括:

  • setIntervalsetTimeout
  • windowdocument 或第三方对象上的事件监听器;
  • WebSocket;
  • 自定义事件总线订阅;
  • 第三方 DOM 插件实例;
  • MutationObserverResizeObserver
  • 未完成的网络请求;
  • 手动创建且不属于当前组件作用域的 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>

这个组件中有三类不同资源:

  1. localCountnow 是组件作用域内的响应式状态;
  2. setIntervalresize 监听器是外部资源,需要显式释放;
  3. 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

这表示:

  1. 父组件先进入更新前阶段;
  2. 父组件更新其渲染结果,并将新的 prop 传给子组件;
  3. 子组件处理新的 prop 并更新自己的 DOM;
  4. 子组件更新完成后,父组件才进入 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
  • 组件实例和状态会保留;
  • 可以使用 onActivatedonDeactivated 管理激活与停用。

例如轮询在停用时应暂停,而不是只在 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 或订阅长期占用连接;
  • 开发环境中热更新后出现多个监听器。

组件副作用的基本约束可以写成:

组件创建资源组件销毁时释放资源\text{组件创建资源} \Rightarrow \text{组件销毁时释放资源}

更严格地说,如果资源句柄为 RR,创建操作为 C(R)C(R),清理操作为 D(R)D(R),理想状态是:

C(R) 至多对应一次有效的 D(R)C(R) \text{ 至多对应一次有效的 } D(R)

清理操作应当幂等,也就是调用一次或多次都不会破坏程序状态。

8.2 响应式副作用与外部副作用

Vue 会自动管理一部分响应式副作用。例如在 setup() 中同步创建的 watchwatchEffect,通常会被绑定到当前组件作用域,并在组件卸载时自动停止:

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()
})

这里有两条错误路径:

  1. 组件卸载:调用 abort(),请求以 AbortError 结束,这属于预期取消;
  2. 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 的更新队列,不保证浏览器已经完成最终绘制。如果依赖布局、字体加载或动画后的尺寸,还可能需要 requestAnimationFrameResizeObserver 或等待具体资源完成。


十一、错误处理与生命周期

生命周期回调、渲染函数、事件处理器、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
})

onMountedonUpdatedonUnmounted 这类依赖客户端 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 onUpdatednextTick
组件即将移除 onBeforeUnmount
释放组件创建的外部资源 onUnmounted
监听具体响应式源 watch
对响应式依赖自动执行副作用 watchEffect
监听 watcher 对应的异步任务 watcher 的 onCleanup
KeepAlive 切出或切入 onDeactivatedonActivated
捕获后代 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。