
一句话:
useEffect的语义是「把这个组件与某个外部系统同步起来」——它在渲染提交、浏览器绘制之后异步执行,通过依赖数组控制何时重新同步,通过清理函数保证「切换前先撤销上一次的同步」。
一、效应(Effect)的正确理解
useEffect 不是「生命周期钩子」,而是「同步外部系统」的工具。
它的模型是:
① 组件渲染 → 需要与某个外部系统对齐
② 执行 effect → 建立同步(订阅、连接、写入 DOM…)
③ 依赖变化或卸载 → 先执行清理(撤销上一次同步),再执行新的 effect
外部系统包括:
网络请求、订阅、定时器、浏览器 API(title / localStorage / ResizeObserver)、
第三方库实例、WebSocket、DOM 操作…
useEffect(() => {
// ① 建立同步
const ws = new WebSocket(url);
ws.onmessage = handleMessage;
// ② 返回清理函数
return () => ws.close();
}, [url]); // ③ 依赖变化时重新同步
二、依赖数组的三种写法
// ① 不写依赖数组:每次渲染后都执行(几乎总是错的)
useEffect(() => {
console.log('每次渲染都执行');
});
// ② 空数组:只在挂载后执行一次(清理在卸载时)
useEffect(() => {
console.log('仅挂载时执行');
return () => console.log('仅卸载时执行');
}, []);
// ③ 有依赖:挂载时 + 依赖变化时执行
useEffect(() => {
console.log('id 变化时执行', id);
return () => console.log('id 变化前 / 卸载时执行', id);
}, [id]);
| 写法 | 执行时机 | 常见错误 |
|---|---|---|
| 无依赖数组 | 每次渲染后 | 忘记写依赖,导致无限循环 |
[] |
仅挂载 + 卸载清理 | 用了外部变量但不写依赖(闭包陷阱) |
[a, b] |
挂载 + a 或 b 变化时 |
漏依赖 / 多依赖(对象引用不稳定) |
// ⚠️ 无依赖数组 + setState = 无限循环
useEffect(() => {
setCount(count + 1); // ❌ 每次渲染都执行 → 又触发渲染 → 无限循环
});
// ⚠️ 依赖是「每次渲染都新建的对象」→ 每次都执行
useEffect(() => {
doSomething(options); // options 是对象字面量 → 引用总是新的
}, [options]);
// ✅ 三种解法
useEffect(() => {
doSomething({ a: 1 });
}, []); // ① 把常量移出依赖
const opts = useMemo(() => ({ a: 1 }), []);
useEffect(() => { doSomething(opts); }, [opts]); // ② useMemo 稳定引用
useEffect(() => {
doSomething({ a: id });
}, [id]); // ③ 依赖「原始值」而不是对象
三、执行时机与清理时机
完整流程:
初次挂载:
① 执行组件函数(render phase)→ 返回元素
② commit 阶段:把 DOM 更新到屏幕
③ 浏览器绘制(paint)
④ 执行 useEffect 的 effect 函数(异步,paint 之后)
← ⭐ 注意:这里的「异步」是指「不阻塞绘制」,但它仍在本次事件循环的微任务/任务中
依赖变化时:
① 渲染 + commit(DOM 已更新)
② 浏览器绘制
③ 执行【上一次的清理函数】
④ 执行【本次的 effect 函数】
卸载时:
① 执行【最后一次的清理函数】
⚠️ 顺序要点:
· 同一个组件内的多个 useEffect:按【声明顺序】执行
· 不同组件之间:子组件的 effect 先于父组件执行
· 清理函数的执行顺序:与 effect 相反(后声明的先清理)
// 用日志验证时机(值得自己跑一遍)
function Demo({ id }) {
console.log('① render', id);
useLayoutEffect(() => {
console.log('② useLayoutEffect', id);
return () => console.log('② cleanup layout', id);
}, [id]);
useEffect(() => {
console.log('③ useEffect', id);
return () => console.log('③ cleanup effect', id);
}, [id]);
return {id};
}
// 首次挂载输出:
// ① render
// ② useLayoutEffect
// ③ useEffect
// id 从 1 变 2 时输出:
// ① render 2
// ② cleanup layout 1
// ② useLayoutEffect 2
// ③ cleanup effect 1
// ③ useEffect 2
四、与类组件生命周期的对应
| 类组件 | 函数组件等价写法 |
|---|---|
componentDidMount |
useEffect(() => {...}, []) |
componentDidUpdate |
useEffect(() => {...})(无依赖)或 [deps] |
componentWillUnmount |
useEffect(() => () => {...}, []) 的清理函数 |
shouldComponentUpdate |
React.memo + 自定义比较函数 |
componentDidCatch |
❌ 只能用类组件(Error Boundary) |
getSnapshotBeforeUpdate |
useLayoutEffect(在 DOM 更新后、绘制前读取) |
// 「componentDidMount + componentWillUnmount」的等价写法
useEffect(() => {
// 挂载时执行(对应 componentDidMount)
const timer = setInterval(tick, 1000);
return () => {
// 卸载时执行(对应 componentWillUnmount)
clearInterval(timer);
};
}, []);
// ⚠️ 但注意:useEffect 的模型不是「事件」(挂载/卸载),而是「同步」
// 当依赖变化时,它会「先清理、再执行」——这比 componentDidUpdate 更安全
重要区别:类组件的
componentDidMount/componentDidUpdate是两个独立的时机,你需要在componentDidUpdate里手动比较前后值;而useEffect是「一个同步过程」,依赖变化时会自动「先撤销、再建立」,不需要手动比较。
五、什么时候「不该」用 Effect(重要)
// ❌ ① 派生数据:不需要 Effect,直接算
function Bad({ firstName, lastName }) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`); // ❌ 多余的一次渲染
}, [firstName, lastName]);
return {fullName};
}
// ✅
function Good({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}`; // ✅ 渲染时直接算
return {fullName};
}
// ❌ ② 响应「某个事件」:应该写在事件处理里
function Bad2({ productId }) {
useEffect(() => {
if (productId) analytics.track('view', productId); // ❌ 「用户点了」是事件
}, [productId]);
}
// ✅ 在触发的地方调用
function Good2({ productId, onSelect }) {
const handleClick = () => {
analytics.track('view', productId);
onSelect(productId);
};
return ;
}
// ❌ ③ 重置/调整状态:优先用 key 让组件重建
function Bad3({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment(''); // ❌ 用户切换时清空草稿
}, [userId]);
}
// ✅ 用 key 强制重建(更符合「不同用户 = 不同组件实例」的语义)
// ❌ ④ 通知父组件变化:让「数据流」更清晰
function Bad4({ onChange, value }) {
useEffect(() => {
onChange(value); // ❌ 容易造成循环
}, [value, onChange]);
}
// ✅ 在「导致 value 变化的那个事件处理」里调用 onChange
官方总结的「不一定需要 Effect」的四种情况:
| 场景 | 应该怎么做 |
|---|---|
| 派生数据 | 渲染时直接计算(必要时 useMemo) |
| 响应事件 | 写在事件处理函数里 |
| 重置状态 | 用 key 让组件重新挂载 |
| 部分 props 变化时的调整 | 渲染时计算,而不是用 Effect 同步 |
判断标准:「这段代码是因为『渲染』而运行,还是因为『某个交互』而运行?」 前者才可能需要 Effect;后者应该写在事件处理里。
六、Effect 的合法用法(与外部系统同步)
// ① 订阅(数据源 / 事件总线)
useEffect(() => {
const unsubscribe = store.subscribe(handleChange);
return unsubscribe;
}, [store]);
// ② 浏览器事件监听
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 或用 AbortController 统一管理
useEffect(() => {
const ac = new AbortController();
window.addEventListener('resize', handleResize, { signal: ac.signal });
window.addEventListener('scroll', handleScroll, { signal: ac.signal });
return () => ac.abort();
}, []);
// ③ 定时器
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [delay]); // ⚠️ delay 变化时重新建立定时器
// ④ 与第三方库同步(图表、地图、编辑器)
useEffect(() => {
const chart = echarts.init(ref.current!);
chart.setOption(option);
return () => chart.dispose();
}, []);
// ⑤ 修改浏览器标题 / 读取或写入 DOM 之外的存储
useEffect(() => {
document.title = title;
}, [title]);
// ⑥ 数据请求(配合取消)
useEffect(() => {
const ac = new AbortController();
setState({ status: 'loading' });
fetchData(id, { signal: ac.signal })
.then((data) => setState({ status: 'success', data }))
.catch((e) => {
if (e.name !== 'AbortError') setState({ status: 'error', error: e.message });
});
return () => ac.abort();
}, [id]);
// ⑦ Observer
useEffect(() => {
const io = new IntersectionObserver(onIntersect);
io.observe(ref.current!);
return () => io.disconnect();
}, []);
七、依赖数组的规范
// ✅ 依赖「用到的每一个响应式值」
useEffect(() => {
console.log(a, b); // 用到了 a 和 b
}, [a, b]); // 依赖必须写全
// ⚠️ 把整个对象作为依赖(引用不稳定 → 每次都执行)
useEffect(() => {
doSomething(user);
}, [user]); // user 每次渲染都是新对象?
// ✅ 依赖具体的字段
useEffect(() => {
doSomething(user.id);
}, [user.id]);
// ✅ 需要「最新值但不想重新执行」时用 useRef
const latestUser = useRef(user);
useEffect(() => {
latestUser.current = user; // 每次渲染更新 ref(不触发执行)
});
useEffect(() => {
const timer = setInterval(() => {
console.log(latestUser.current); // 用 ref 拿最新值
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖写 [] 但拿得到最新值
// ✅ 函数依赖:用 useCallback 稳定,或移到 Effect 内部
// 方案一:把函数移到内部(推荐,除非它需要在别处复用)
useEffect(() => {
function handle() { /* 用到 a, b */ }
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, [a, b]); // 依赖变成函数用到的值
// 方案二:用 useCallback 稳定函数引用
const handle = useCallback(() => { /* ... */ }, [a, b]);
useEffect(() => {
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, [handle]);
关于 exhaustive-deps 的建议:
① 它的警告【几乎总是对的】——不要习惯性 disable
② 如果加上依赖会导致「无限循环」,通常说明:
· 依赖里有「每次渲染都新建的对象/函数」→ 用 useMemo/useCallback 稳定
· 或者这段逻辑本来就不该放在 Effect 里(见第五节)
③ 需要「跳过某次执行」时,用 ref 标志位,而不是删依赖
八、常见坑
// ① 无依赖数组 + setState → 无限循环
useEffect(() => { setX(x + 1); });
// ② 漏依赖导致闭包陷阱
useEffect(() => {
const id = setInterval(() => console.log(count), 1000); // ⚠️ count 永远是初始值
return () => clearInterval(id);
}, []); // ✅ 应该写 [count],或用 useRef
// ③ 依赖是「新对象」
useEffect(() => { ... }, [{ a: 1 }]); // ⚠️ 字面量对象每次都是新引用
// ④ 忘记清理
useEffect(() => {
const ws = new WebSocket(url); // ⚠️ 没关闭 → 泄漏 + StrictMode 下开两个
}, [url]);
// ⑤ 在 Effect 里「同步状态」而不是「同步外部系统」
useEffect(() => {
setFiltered(list.filter(...)); // ⚠️ 应直接计算或用 useMemo
}, [list]);
// ⑥ 用 Effect 初始化「只做一次」的数据
useEffect(() => {
setInitialValue(compute()); // ⚠️ 应该用 useState 的惰性初始化
}, []);
// ⑦ 在清理函数里访问已卸载的 DOM
return () => { ref.current?.focus(); }; // ⚠️ 此时 DOM 可能已卸载
// ⑧ 依赖数组里放「不稳定的函数」
useEffect(() => { fetchData(); }, [fetchData]); // ⚠️ fetchData 每次渲染都是新函数
// ⑨ 异步函数直接作为 effect(会返回 Promise)
useEffect(async () => { await fetchData(); }, []); // ❌ 不能返回 Promise
// ✅
useEffect(() => {
let cancelled = false;
(async () => {
const data = await fetchData();
if (!cancelled) setData(data);
})();
return () => { cancelled = true; };
}, []);
// ⑩ 认为 Effect「在渲染前执行」
// → 实际是在【绘制之后】异步执行(需要绘制前执行用 useLayoutEffect)
面试延伸
- 「
useEffect的执行时机是什么?」
它在「渲染提交 + 浏览器绘制之后」异步执行(不阻塞绘制)。依赖变化时的顺序是「渲染 → commit → 绘制 → 执行上一次的清理函数 → 执行本次 effect」;卸载时执行最后一次清理。同一组件内多个useEffect按声明顺序执行,子组件的 effect 先于父组件;清理函数的顺序与声明相反。 -
「依赖数组不写、写
[]、写[a]有什么区别?」
不写 → 每次渲染后都执行(几乎总是错的,配合setState会无限循环);[]→ 只在挂载后执行一次,清理在卸载时执行;[a]→ 挂载时执行,且a变化时“先清理再执行”。第三种是最常用的形式,它把「同步」与「重新同步」统一在了一个模型里。 -
「什么时候不该用
useEffect?」
四种情况:① 派生数据——渲染时直接算(需要时用useMemo),不要用 Effect 同步到 state;② 响应某个事件——写在事件处理函数里(Effect 是「因为渲染而运行」的,不是「因为交互」);③ 重置状态——用key让组件重新挂载更符合语义;④ 部分 props 变化时的调整——在渲染时计算即可。判断标准是「这段逻辑因渲染而跑,还是因交互而跑」。 -
「清理函数什么时候执行?」
两个时机:① 依赖变化后、下一次 effect 执行前(用于「撤销上一次的同步」);② 组件卸载时(用于释放资源)。这正是useEffect比componentDidUpdate更安全的地方——它自动处理了「先撤销、再建立」的逻辑,不需要你手动比较新旧值。注意:清理函数里不要访问已卸载的 DOM 引用(可能已经是null)。 -
「为什么
useEffect里不能直接写async函数?」
因为useEffect的返回值必须是「清理函数或无返回值」,而async函数总是返回 Promise,React 会把它当作清理函数来调用,导致报错(An effect function must not return anything besides a function)。正确写法是「在 effect 内部定义一个 async 函数并立即调用」,同时用「取消标记」或AbortController处理竞态。 -
「依赖数组漏了值会怎样?」
会产生闭包陷阱(stale closure):effect 里捕获的是当次渲染的变量快照,后续变化不会反映进去。典型表现是「setInterval里拿到的永远是初始值」「事件处理器读到旧 state」。解法:① 补齐依赖(首选);② 用useRef保存最新值;③ 用函数式更新(setX(p => ...))避免读旧值。不要靠删依赖来「消除警告」,那是在掩盖 bug。 -
「为什么加了依赖会导致无限循环?」
因为依赖里有「每次渲染都新建的对象/函数」(如对象字面量、内联箭头函数、useSelector返回的新对象)。effect 执行 → 修改 state → 重新渲染 → 对象又是新引用 → 依赖「变了」→ 再执行 → 死循环。解法:① 把常量提到模块作用域;② 用useMemo/useCallback稳定引用;③ 依赖「原始值」而不是对象(user.id而不是user);④ 反思这段逻辑是否本就不该用 Effect。 -
「怎么在 Effect 里拿到『最新值』但又不重新执行?」
用useRef:每次渲染把最新值写入ref.current(在没有依赖数组的 effect 里更新 ref 不会触发任何执行),然后在需要的地方读ref.current。这是「事件订阅 / 定时器里读最新 props」的标准解法。另一种方式是「用useEffectEvent(实验中)」——它的语义就是「读最新值但不作为依赖」。
一句话速记
useEffect的语义是「与外部系统同步」(不是生命周期钩子);依赖数组三种写法:不写(每次渲染)、[](仅挂载)、[deps](变化时「先清理再执行」);清理函数在「依赖变化后」与「卸载时」执行;能不用就不用——派生数据直接算、事件逻辑写在处理函数里、重置状态用key;漏依赖 = 闭包陷阱(用useRef或补齐依赖解决,不要靠删依赖消警告);依赖里有新对象/新函数会导致无限循环。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!