第 05 课 · useEffect 与副作用
Vue 里「组件挂载后做事」「监听数据变化」「卸载前收尾」分属 onMounted / watch / onUnmounted 三个 API;React 把它们合成一个 useEffect。这是两个框架心智差异最大的地方,也是 Vue 开发者最常写出 bug 的地方------本课一半篇幅都在纠偏。
🎯 本课目标
- 建立「useEffect = onMounted + watch + onUnmounted 三合一」的心智模型,分清它和
watch的关键差异 - 熟练使用依赖数组三形态(不写 /
[]/[a, b]),能预判每种写法在「任何状态变化」下的行为 - 给定时器、事件监听、订阅写清理函数,说清 cleanup 的执行时机
- 写出企业标准的请求数据模式:loading/error/data 三态 + ignore 标志处理竞态
- 用官方「You Might Not Need an Effect」的判断标准,识别 code review 里该删的 useEffect
1. 副作用与「三合一」心智
Vue 里你这样写
ts
import { onMounted, onUnmounted, watch, ref } from 'vue'
const keyword = ref('')
onMounted(() => { document.title = '搜索页' }) // 挂载后做事
watch(keyword, kw => { console.log('搜索:', kw) }) // 数据变化后做事
onUnmounted(() => { window.removeEventListener('resize', onResize) }) // 卸载前收尾
React 里这样写
tsx
import { useEffect, useState } from 'react'
export default function SearchPage() {
const [keyword, setKeyword] = useState('')
// ① 挂载后做事(= onMounted)
useEffect(() => {
document.title = '搜索页'
}, [])
// ② 数据变化后做事(= watch(keyword, ...))
useEffect(() => {
console.log('搜索:', keyword)
}, [keyword])
// ③ 卸载前收尾(= onUnmounted,写在 return 里)
useEffect(() => {
const onResize = () => console.log('resize')
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}
关键差异与注意点
- 副作用 :渲染之外的事------请求数据、操作 DOM、定时器、订阅、打点。React 里「渲染必须是纯的」,这些事统一交给
useEffect。 - 执行时机 :每次渲染提交到屏幕之后 异步执行(Vue 的
watch默认是flush: 'pre',在组件更新前)。所以 effect 里再改 state 不会卡住首屏绘制。 - 一个组件可以有任意多个
useEffect,按「这件事」拆分,而不是像 Vue 那样按「生命周期」聚堆。 - effect 的两个参数:
(执行函数, 依赖数组)。依赖数组决定「什么时候再执行」,return 的函数决定「执行前怎么收尾」。
2. 依赖数组三形态
Vue 里你这样写
ts
watchEffect(() => {}) // 自动收集依赖
watch([a, b], () => {}) // 显式指定依赖
onMounted(() => {}) // 只跑一次
React 里这样写
依赖数组没有自动收集,必须手写、写全:
tsx
// 形态一:不写第二个参数 ------ 每次渲染后都执行(几乎不用,用了多半是 bug)
useEffect(() => {
console.log('任何 state 变化、任何父组件重渲染导致的重渲染,我都会跑')
})
// 形态二:空数组 ------ 仅首次挂载后执行(= onMounted)
useEffect(() => {
fetchConfig()
}, [])
// 形态三:[a, b] ------ 首次执行 + a 或 b 变化后执行(= onMounted + watch(a/b))
useEffect(() => {
console.log('a 或 b 变了', a, b)
}, [a, b])
关键差异与注意点
- 与 watch 的关键差异(最容易理解偏差的地方) :
watch默认不立即执行 ,只有依赖变化才跑;useEffect挂载后必执行一次 ,然后才按依赖数组决定是否重跑。所以useEffect(fn, [a])≈watch(a, fn, { immediate: true }),而不是普通watch。 - 依赖数组比较用的是
Object.is(浅比较)。对象/数组依赖是重灾区 :useEffect(..., [form])里form是每次渲染新建的对象时,effect 每次渲染都会跑。要么放具体字段[form.keyword],要么记忆化(第 7 课useMemo)。 - 诚实填写:effect 里用到的响应式值(state、props、以及组件内定义的函数)都要进依赖。漏写不会报错,但会读到「上一次渲染的旧值」------这类 bug 比报错难查得多。关掉 exhaustive-deps 的 lint 规则来逃避填依赖,是企业 code review 一票否决项。
- 想要「仅依赖变化时执行、跳过首次」?用 ref 记录是否首次,不要 hack 依赖数组。
3. 清理函数:return 出去的收尾逻辑
Vue 里你这样写
ts
onMounted(() => { window.addEventListener('resize', onResize) })
onUnmounted(() => { window.removeEventListener('resize', onResize) }) // 收尾写在另一个 API 里
React 里这样写
tsx
import { useEffect, useState } from 'react'
export default function Clock() {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
const onResize = () => console.log('resize')
window.addEventListener('resize', onResize)
// cleanup:下一次 effect 执行前 + 组件卸载时,各调用一次
return () => {
clearInterval(timer)
window.removeEventListener('resize', onResize)
}
}, [])
return <p>{now.toLocaleTimeString()}</p>
}
关键差异与注意点
- 执行时机 :不是只在卸载时!cleanup 在下一次 effect 执行之前 也会执行(依赖变化 → 先清理上一轮,再跑新一轮),以及组件卸载时 。这一点比
onUnmounted覆盖的场景多------竞态处理(第 4 节)全靠它。 - cleanup 必写清单 :
setInterval/setTimeout(clearInterval/clearTimeout)、addEventListener(removeEventListener)、WebSocket/EventSource、订阅(store、PubSub)、手动修改的 DOM/第三方实例(destroy/disconnect)。 - 不写 cleanup 的后果:组件卸载后定时器还在跑、监听还在触发------内存泄漏 + 日志错乱 + 对已卸载组件 setState。
- effect 里只能 return 函数 ,所以
async函数不能直接写在 effect 里(见第 4 节的正确姿势)。 useEffect的 cleanup 里 setState 是允许的(React 18+ 已移除卸载后 setState 的警告),但「清理」本身不该做业务动作(比如退出登录),那是别处的事。
4. 请求数据标准模式:三态 + 竞态
Vue 里你这样写
ts
const loading = ref(false)
const error = ref('')
const user = ref(null)
async function loadUser(id: number) {
loading.value = true
error.value = ''
try {
user.value = await fetchUser(id)
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
watch(() => props.id, id => loadUser(id), { immediate: true })
React 里这样写
请求随 id 变化自动重发,ignore 标志保证「慢的旧响应」不会覆盖新结果:
tsx
import { useEffect, useState } from 'react'
interface User { id: number; name: string; role: string }
export default function UserProfile({ id }: { id: number }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
let ignore = false // 本轮 effect 的「过期开关」
async function load() {
setLoading(true)
setError('')
try {
const data = await fetchUser(id)
if (!ignore) setUser(data) // 已过期就丢弃
} catch (e) {
if (!ignore) setError((e as Error).message)
} finally {
if (!ignore) setLoading(false)
}
}
load()
return () => { ignore = true } // 下一次 effect 前 / 卸载时执行
}, [id])
if (loading) return <p>加载中...</p>
if (error) return <p style={{ color: 'red' }}>{error}</p>
if (!user) return null
return <p>{user.name}({user.role})</p>
}
关键差异与注意点
- 三态拆开存 :
loading/error/data三个 state(或合成一个带类型的对象),UI 按优先级渲染:loading → error → data。 - 竞态 :请求是异步的,切到用户 2 后用户 1 的慢响应可能后到,把新数据覆盖掉。
let ignore = false+ cleanup 里ignore = true是官方推荐的最小方案;也可以用AbortController真·取消请求(fetchUser内部把 signal 传给 fetch)。 async不能直接写在 effect 里 :useEffect(async () => {})会报错------effect 必须返回函数或什么都不返回。标准做法是 effect 里定义async function load()然后load()。- 初次挂载就能拿到数据(不需要
immediate: true),因为useEffect(fn, [id])挂载时必执行一次------这正好符合列表页「进页面就请求」的习惯。 - 企业现状 :手写这套模式只用于理解原理,实际项目用 TanStack Query(本课程第 11 课),缓存、重试、竞态、失效全帮你做好了。面试要能手写,上班要用库。
5. StrictMode:开发环境为什么执行两次
React 里这样写
tsx
// main.tsx ------ 企业项目都会包一层 StrictMode,不要删
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
关键差异与注意点
- 开发环境挂载时,React 会故意执行两遍 :组件函数渲染两次、effect 执行 → cleanup → 再执行一次。生产环境不会。
- 目的:模拟「组件随时可能被卸载重挂」,逼你把 cleanup 写对。
- 正确反应是补 cleanup,而不是删 StrictMode「修复」------删掉它等于关掉烟雾报警器,泄漏会留到生产。
- Vue 没有这一层,所以第一次看到「请求发了两次」会很惊讶:先检查是不是 StrictMode,再查依赖数组。
6. 什么时候不该用 useEffect(企业 code review 高频意见)
Vue 里你这样写
ts
const full = computed(() => `${first.value} ${last.value}`) // 派生数据用 computed
React 里这样写
tsx
// ❌ 多余的 effect:为「渲染时能算出来的东西」专门跑一次副作用
const [full, setFull] = useState('')
useEffect(() => {
setFull(`${first} ${last}`)
}, [first, last])
// ✅ 直接算:渲染就是一次「用最新 state 重算 UI」的过程
const full = `${first} ${last}`
关键差异与注意点
- 判断口诀 (官方 You Might Not Need an Effect 精华):
- 这份值能由现有 state/props 直接算出来 ?→ 渲染时直接写表达式,别进 effect(Vue 里你本来就会用
computed,同理)。 - 这件事是某个具体交互引起的?→ 写在那个事件回调里(提交表单时发请求,而不是「监听到表单变了就发」),别进 effect。
- 是多处状态变化都要触发、且来源不止用户操作?→ 这才是 effect(同步第三方、埋点、操作 DOM 标题)。
- 这份值能由现有 state/props 直接算出来 ?→ 渲染时直接写表达式,别进 effect(Vue 里你本来就会用
- effect 里 setState 去存「另一个 state 的派生值」会导致多一轮渲染,并且容易写出链式 effect(A 的 effect 改 B,B 的 effect 改 C)------review 里叫「effect 地狱」。
- 事件回调能做就别进 effect(Demo 4 左右对照):事件回调只响应真实交互,没有挂载那次多余执行,逻辑也更内聚。
- 真正适合 effect 的:请求/订阅/定时器、
document.title这类 DOM 副作用、和 React 外部系统同步。React 内部的数据加工,一律不用 effect。
📊 Vue ↔ React 对照速查表
| Vue 3 写法 | React 写法 | 一句话差异 |
|---|---|---|
onMounted(fn) |
useEffect(fn, []) |
语义一致,写法换个位置 |
onUnmounted(fn) |
useEffect(() => fn, []) 的 return |
收尾逻辑写在同一个 effect 里 |
watch(a, fn) |
useEffect(fn, [a]) |
useEffect 挂载后必执行一次,watch 默认不会 |
watch(a, fn, { immediate: true }) |
useEffect(fn, [a]) |
这才是完全等价物 |
watch([a, b], fn) |
useEffect(fn, [a, b]) |
依赖手写,不会自动收集 |
watchEffect(fn) |
useEffect(fn)(不写依赖) |
都容易过度执行,慎用 |
computed |
渲染中直接算 / useMemo |
派生数据不要进 effect |
| 事件回调里发请求 | 事件回调里发请求 | 一样!能回调就别进 effect |
watch 请求 + 手动防竞态 |
ignore 标志 / AbortController |
cleanup 让竞态处理有落点 |
onBeforeUnmount 清定时器 |
cleanup 里 clearInterval |
同一件事,换个位置写 |
🏢 企业实战要点
- 依赖数组诚实填写 ,配合
eslint-plugin-react-hooks的 exhaustive-deps 规则;「为了少跑几次而故意漏依赖」是 review 一票否决项,正确做法是useMemo/useCallback稳定依赖(第 7 课)。 - 对象/数组依赖的引用陷阱 :
[form]、[list]、[options]这类写法,只要上游每次渲染新建对象就会次次触发。放原始值字段([form.keyword])或记忆化,review 里见到直接打回。 - cleanup 必写清单贴在脑子里:定时器、事件监听、订阅、WebSocket、第三方实例 destroy。PR 描述里能一句话说清「这个 effect 的清理是什么」。
- 请求逻辑封装成自定义 Hook (第 8 课
useUser(id)/ 第 11 课 TanStack Query),组件里只留「用」,别让每个组件都手写三态 + ignore。 - 一个 effect 只做一件事 :需要多个副作用就写多个
useEffect,依赖数组各自精确;一个大 effect 揉五个职责是「effect 地狱」的起点。 onChange里能做的不进 effect(上一课的防抖、这一课的联动同理):review 里最常见的多余 effect 就是「监听 input 值去更新另一个 state」。- 别删 StrictMode,也别为了「请求只发一次」去改全局配置;用 cleanup + 正确依赖解决。
- effect 里不要做重计算 :它跑在渲染之后,卡的是用户可感知的交互;重活挪到事件回调或用
useMemo记忆化。
💼 面试高频
1. useEffect 的依赖数组是怎么工作的?漏写会怎样?
React 在每次渲染后用 Object.is 逐项比较新旧依赖:全相等就跳过 effect,否则先执行上一次的 cleanup 再跑新 effect。漏写依赖不会报错,但 effect 闭包会引用旧的 state/props(闭包陷阱),表现为「数据明明变了逻辑却用旧值」,是最难排查的一类 bug。所以依赖要诚实写全。
2. useEffect 和 Vue 的 watch 有什么区别?
最大差异是执行时机:watch 默认不立即执行,依赖变化才跑;useEffect 挂载后必执行一次,之后依赖变化才重跑,等价于 watch(..., { immediate: true })。另外 watch 靠响应式系统自动追踪依赖,useEffect 必须手写依赖数组且比较是浅比较(对象引用变了就算变了)。
3. cleanup 函数的意义和执行时机?
用于撤销本次副作用:清定时器、移除监听、取消订阅/请求。执行时机有两个:下一次 effect 执行之前(依赖变化时先清理上一轮)和组件卸载时。它不只是「卸载收尾」,更是处理竞态(ignore 标志)、避免重复订阅的关键机制。
4. 开发环境 useEffect 为什么执行两次?
因为 React 18+ 的 StrictMode 会在开发环境对每个组件模拟一次「挂载 → 卸载 → 重挂载」(渲染两次、effect 执行 → cleanup → 再执行),用来暴露缺失 cleanup 的副作用。生产环境只执行一次。正确做法是补全 cleanup,而不是移除 StrictMode。
5. 请求的竞态问题怎么处理?
典型场景:快速切换 id,先发出的慢响应后到,覆盖了新结果。最小方案是 effect 里 let ignore = false,响应回来先判断 if (ignore) return,cleanup 里 ignore = true 把上一轮作废;更彻底用 AbortController 真正中断请求。生产上直接用 TanStack Query,它内置 keyed 缓存与过期丢弃。
📌 小结 + 下节预告
这一课的核心是心智转换:useEffect 把 Vue 的 onMounted / watch / onUnmounted 合成一个函数,挂载必执行一次 是与 watch 最关键的区别;依赖数组要诚实手写、对象依赖小心引用陷阱;定时器/监听/订阅必须 cleanup;请求数据 = 三态 + ignore 竞态处理;最后用「能在渲染里算、能在事件回调里做的,都别进 effect」来给代码瘦身。
不过你可能已经注意到 Demo 2 里 appendLog 需要包一层 useCallback、依赖数组里不敢放对象------这些「函数身份」和「引用稳定性」问题,正是下一课的主题:组件通信 (props 回调、Context、useImperativeHandle),以及它背后引出的性能优化三件套。