
一个问题:组件从生到死,中间发生了什么?
你住酒店,流程是这样的:
- 入住:前台核验身份 → 给你房卡 → 你进房间放行李
- 住着:叫客房服务、调空调温度、每天打扫换毛巾
- 退房:收拾行李 → 退还房卡 → 结账走人
组件的生命周期,就是这三步。只不过名字叫 挂载(Mount)、更新(Update)、卸载(Unmount)。
但 React 和 Vue3 对这三步的处理方式完全不同——不是语法不同,是思路不同。
React:把所有事塞进一个 useEffect
React 16.8 之后,类组件的 componentDidMount、componentDidUpdate、componentWillUnmount 三件套被一个 useEffect 统一收编了。
一个函数干三件事:
import { useEffect, useState } from 'react';
function Countdown({ duration }) {
const [remaining, setRemaining] = useState(duration);
useEffect(() => {
// ---- 入住(+ duration 变化时重新计时)----
const timer = setInterval(() => {
setRemaining((s) => (s > 0 ? s - 1 : 0));
}, 1000);
// ---- 退房(+ 重新计时前清掉旧定时器)----
return () => clearInterval(timer);
}, [duration]); // ← 依赖数组:duration 变了就重新计时
return <div>剩余 {remaining} 秒</div>;
}
这里发生了什么?
-
首次渲染(入住):
useEffect的回调执行,启动定时器,每秒减 1。 -
duration变化(换个更短的倒计时):先执行上一次的清理函数(清掉旧定时器),再执行新的回调(重新开始计时)。 - 组件卸载(退房):执行最后一次清理函数,清掉定时器。
一个函数,一个依赖数组,覆盖了挂载、更新、卸载三个阶段。React 的思路是:我不关心你在哪个阶段,我只关心你的"副作用"依赖了什么。
依赖数组是核心
useEffect(() => { /* ... */ }, []); // 只在挂载时执行一次
useEffect(() => { /* ... */ }, [duration]); // duration 变化时也执行
useEffect(() => { /* ... */ }); // 每次渲染都执行(慎用)
React 把"什么时候执行"这件事,完全交给了依赖数组。你告诉它依赖了什么,它替你决定什么时候跑、什么时候清理。
useLayoutEffect:DOM 测量的专用通道
import { useLayoutEffect, useRef, useState } from 'react';
function Tooltip({ children }) {
const triggerRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// 在浏览器绘制前同步执行,避免"先闪到错误位置再跳正"的闪烁
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
});
}, [children]);
return (
<div ref={triggerRef} className="tooltip-trigger">
{children}
<div className="tooltip" style={{ position: 'absolute', ...position }}>
{children}
</div>
</div>
);
}
useLayoutEffect 和 useEffect 用法一模一样,唯一区别是执行时机:它在 DOM 更新后、浏览器绘制前同步执行。适合做 DOM 测量然后立即调整样式,避免视觉闪烁。
Vue3:每个阶段一个函数,各管各的
Vue3 的 Composition API 走了完全相反的路——每个生命周期阶段都有自己专属的函数。
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue';
const props = defineProps({ duration: Number });
const remaining = ref(props.duration);
let timer = null;
// ---- 入住 ----
onMounted(() => {
timer = setInterval(() => {
if (remaining.value > 0) remaining.value--;
}, 1000);
});
// ---- 住着(数据变化导致 DOM 更新后)----
onUpdated(() => {
console.log('DOM 更新完成,倒计时画面已刷新');
});
// ---- 退房 ----
onUnmounted(() => {
clearInterval(timer);
});
</script>
<template>
<div>剩余 {{ remaining }} 秒</div>
</template>
Vue3 的思路是:你在哪个阶段做事,就去哪个函数里写。 语义明确,一看就知道这段代码什么时候跑。
Vue3 完整生命周期清单
| 钩子函数 | 触发时机 | 典型用途 |
|---|---|---|
onBeforeMount |
挂载前(模板编译完成,还没渲染到 DOM) | 最后一刻修改数据 |
onMounted |
挂载后(DOM 已就绪) | 发请求、启动定时器、操作 DOM |
onBeforeUpdate |
更新前(数据变了,DOM 还没重绘) | 获取更新前的 DOM 状态 |
onUpdated |
更新后(DOM 已重绘) | 依赖 DOM 更新的操作 |
onBeforeUnmount |
卸载前(组件还活着) | 做最后的清理、发统计打点 |
onUnmounted |
卸载后(组件已销毁) | 清除定时器、移除事件监听 |
对比 React 的 useEffect 一把梭,Vue3 给了六个明确的钩子。多了吗?不多。你想想看,酒店前台如果只有一个按钮,既管入住又管退房又管换房,你会觉得方便还是混乱?
watch 和 watchEffect:Vue 独有的响应式监听
Vue3 还有一套 React 没有直接对应的东西——显式监听响应式数据的变化:
<script setup>
import { ref, watch, watchEffect } from 'vue';
const duration = ref(60);
// watch:明确指定监听谁,变化时执行(首次不执行)
watch(duration, (newVal, oldVal) => {
console.log(`时长从 ${oldVal} 秒改成 ${newVal} 秒`);
});
// watchEffect:自动追踪内部用到的响应式数据
watchEffect(() => {
// duration.value 变了就会自动重新执行
console.log(`当前时长:${duration.value} 秒`);
});
</script>
watch 和 React 的 useEffect 有相似之处,但有个关键区别:watch 只在监听的值变化时执行,首次不执行(除非加 { immediate: true })。而 useEffect 在挂载时一定会执行一次。
watchEffect 更像 useEffect——首次会执行一次,然后自动追踪依赖。但它的依赖是自动收集的,不需要你手写依赖数组。
同一个功能,两种写法
现在用同一个简单场景——一个倒计时组件,挂载时开始计时,时长(duration)变化时重新计时,卸载时清除定时器——看看两种框架的完整写法。
React 版
import { useState, useEffect } from 'react';
function Countdown({ duration }) {
const [remaining, setRemaining] = useState(duration);
useEffect(() => {
// 时长变化时,重置倒计时并重新开始
setRemaining(duration);
const timer = setInterval(() => {
setRemaining((s) => (s > 0 ? s - 1 : 0));
}, 1000);
// 清理函数:duration 变化 / 卸载时都会执行
return () => clearInterval(timer);
}, [duration]);
return <div>倒计时:{remaining} 秒</div>;
}
Vue3 版
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
const props = defineProps({ duration: Number });
const remaining = ref(props.duration);
let timer = null;
function start() {
clearInterval(timer);
remaining.value = props.duration;
timer = setInterval(() => {
if (remaining.value > 0) remaining.value--;
}, 1000);
}
// 挂载时开始计时
onMounted(start);
// 卸载时清除定时器
onUnmounted(() => clearInterval(timer));
// duration 变了:重新开始计时
watch(() => props.duration, start);
</script>
<template>
<div>倒计时:{{ remaining }} 秒</div>
</template>
看出区别了吗?
-
React 用一个
useEffect+ 依赖数组[duration]就搞定了挂载、换时长、卸载三件事。创建逻辑和清理逻辑写在同一个函数里——它们是配对的。 -
Vue3 把挂载逻辑放在
onMounted,卸载放在onUnmounted,换时长放在watch。三个阶段分散在三个位置——每个阶段各司其职。
React 的写法更紧凑,但你要读懂"依赖数组 + 返回清理函数"的约定。Vue3 的写法更直白,函数名就是时间线,但你得在文件里跳来跳去看完整逻辑。
正面对比表
| 维度 | React(Hooks) | Vue3(Composition API) |
|---|---|---|
| 挂载阶段 | useEffect(() => {}, []) |
onMounted(() => {}) |
| 更新阶段 | useEffect(() => {}, [dep]) |
onUpdated / watch / watchEffect
|
| 卸载阶段 |
useEffect 返回的清理函数 |
onUnmounted / onBeforeUnmount
|
| DOM 更新前 | useLayoutEffect |
onBeforeUpdate |
| DOM 更新后 |
useEffect(依赖变化时触发) |
onUpdated |
| 响应式监听 |
useEffect 依赖数组 |
watch / watchEffect
|
| 依赖收集 | 手动写依赖数组 | 自动追踪(watchEffect)或显式声明(watch) |
| 首次执行 |
useEffect 挂载时必执行一次 |
watch 默认不执行,watchEffect 执行一次 |
| 清理方式 | 在 effect 内部 return 一个函数 | 独立的 onUnmounted 钩子 |
| 设计哲学 | 副作用是数据依赖的函数 | 生命周期是时间线上的事件点 |
| 学习成本 | 依赖数组的心智负担较重 | 函数名即语义,直觉清晰 |
| 灵活性 | 极高,一个 effect 可覆盖多阶段 | 各阶段解耦,但需要手动串联 |
两种思路背后的设计差异
React 的 useEffect 之所以看起来"什么都能干",是因为它的设计起点是一个判断:副作用是数据依赖的函数——你依赖了什么,effect 就在那些东西变化时重新执行。挂载是"依赖第一次发生变化",卸载是"最后一次清理"。三个阶段只是这个模型的自然产物,不需要单独的钩子。
Vue3 的做法更传统:生命周期就是时间线上的事件点。你在哪个点做事,就去注册哪个点。这更接近浏览器原生的事件模型——addEventListener('DOMContentLoaded', ...) 的思路。
哪种更好?
取决于你脑子里装的模型是什么。如果你习惯"数据驱动一切"的思路——数据变了,副作用自动跟着跑——React 的统一模型更顺。如果你习惯"在合适的时间做合适的事"——先挂载再操作 DOM,先卸载再清除定时器——Vue3 的分离模型更直觉。
不要因为一个写法看起来短就觉得它好。useEffect 一个函数干三件事,也意味着 bug 可能藏在一个函数的三个分支里。Vue3 三个函数各管各的,也意味着逻辑分散在文件不同位置,你需要跳来跳去看全貌。
理解了这两种设计,你才能在面试或实际项目中说出"为什么选这个"——而不是"因为大家都在用"。