
一句话:
useEffect的语义是「把组件与某个外部系统同步」——能不用就不用;派生数据直接算、事件逻辑写在事件处理里、重置状态用key,只有「真的在与外部系统同步」时才用 Effect。
一、正确的心智模型
❌ 错误模型:
「useEffect = componentDidMount / componentDidUpdate」
→ 于是把「数据派生」「事件响应」「初始化」都往里塞
✅ 正确模型:
「Effect 是「同步」:把 React 的状态与某个【外部系统】对齐」
· 外部系统:浏览器 API、第三方库、网络、订阅、定时器、DOM 命令式操作
· 它描述的是「要不要同步、同步什么」,而不是「在什么时机执行」
判断标准:
「这段代码是因为【渲染】而运行,还是因为【某个交互】而运行?」
· 因渲染而运行(需要与外部对齐)→ 可能需要 Effect
· 因交互而运行(点了按钮)→ 写在事件处理里
二、四个「不该用 Effect」的场景
1) 派生数据
// ❌ 用 Effect 把 props 派生到 state
function Bad({ firstName, lastName }) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`); // ⚠️ 多一次渲染、可能不一致
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
// ✅ 渲染时直接算
function Good({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}`; // ⭐ 没有额外渲染、永远一致
return <div>{fullName}</div>;
}
// ✅ 计算昂贵时用 useMemo(而不是 Effect + state)
function Good2({ items, keyword }) {
const filtered = useMemo(
() => items.filter((it) => it.name.includes(keyword)),
[items, keyword]
);
return <List items={filtered} />;
}
2) 事件响应
// ❌ 用 Effect 监听「某个值变化」来触发事件
function Bad({ productId, onView }) {
useEffect(() => {
if (productId) onView(productId); // ⚠️ 「用户看了商品」是事件
}, [productId, onView]);
return <Product id={productId} />;
}
// ✅ 写在「触发这件事」的地方
function Good({ product, onView }) {
const handleSelect = () => {
onView(product.id); // ⭐ 明确「因为点击而触发」
};
return <button onClick={handleSelect}>查看</button>;
}
为什么事件不该用 Effect?
① Effect 无法区分「用户主动触发」与「其它原因导致值变化」
② 容易「重复触发」(依赖变化就执行)
③ 触发链难以追踪(「谁让它跑起来的?」)
3) 重置状态
// ❌ 用 Effect 清空状态
function Bad({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment(''); // ⚠️ 要列出所有要重置的状态(容易漏)
setTab('info');
setDraft('');
}, [userId]);
return <Profile userId={userId} />;
}
// ✅ 用 key 让组件重建(更符合「不同用户 = 不同实例」的语义)
function Good({ userId }) {
return <Profile key={userId} userId={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState(''); // ⭐ userId 变化 → 组件重建 → 自动重置
// ...
}
4) 通知父组件
// ❌ 用 Effect 把内部状态「同步」给父组件
function Bad({ onChange }) {
const [value, setValue] = useState('');
useEffect(() => {
onChange(value); // ⚠️ 容易造成循环(onChange 又改 props)
}, [value, onChange]);
}
// ✅ 方案一:在「导致 value 变化的地方」调用 onChange
function Good1({ onChange }) {
const [value, setValue] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setValue(next);
onChange(next); // ⭐ 明确因果
};
return <input value={value} onChange={handleChange} />;
}
// ✅ 方案二:把状态提升(受控组件)
function Good2({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
三、该用 Effect 的场景
✅ 真正的「与外部系统同步」:
① 订阅(数据源、事件总线、store)
useEffect(() => { const un = store.subscribe(fn); return un; }, [store]);
② 浏览器事件监听(需要与 window/document 同步)
useEffect(() => { window.addEventListener('resize', fn); return () => window.removeEventListener('resize', fn); }, []);
③ 定时器
useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); }, [delay]);
④ Observer(IntersectionObserver / ResizeObserver / MutationObserver)
useEffect(() => { const io = new IntersectionObserver(fn); io.observe(el); return () => io.disconnect(); }, []);
⑤ 第三方库实例(图表、地图、编辑器)
useEffect(() => { const inst = new Lib(el); return () => inst.destroy(); }, []);
⑥ 网络请求(配合取消)
useEffect(() => { const ac = new AbortController(); fetch(url, { signal: ac.signal }).then(...); return () => ac.abort(); }, [url]);
⑦ 与浏览器状态同步(title、localStorage、body 样式)
useEffect(() => { document.title = title; }, [title]);
⑧ 在 DOM 更新后做「不需要阻塞绘制」的命令式操作
useEffect(() => { videoRef.current?.play(); }, [isPlaying]);
⭐ 共同特征:
「有一个【外部系统】,React 的状态需要与它保持一致」
—— 并且这个一致性不是「渲染结果」能表达的(DOM 属性、订阅、网络、定时器)
四、依赖数组的规范
// ✅ 依赖「Effect 里用到的每一个响应式值」
useEffect(() => {
doSomething(a, b);
}, [a, b]);
// ✅ 函数依赖:把函数移到 Effect 内部(推荐)
useEffect(() => {
function handle() { doSomething(a); } // ⭐ 依赖变成 a
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, [a]);
// ✅ 需要「读最新值但不重新执行」→ 用 ref
const latestRef = useRef(a);
useEffect(() => { latestRef.current = a; }); // 每次渲染同步(不触发执行)
useEffect(() => {
const id = setInterval(() => console.log(latestRef.current), 1000);
return () => clearInterval(id);
}, []); // ⭐ 依赖为空但不缺值
三条规范:
① 【写全依赖】——漏了会产生闭包陷阱(读到旧值)
② 【不要靠删依赖消警告】——那是在掩盖 bug(`exhaustive-deps` 几乎总是对的)
③ 【依赖不稳定就修它】——把常量提到模块外、用 useMemo/useCallback 稳定、
或依赖原始值(`user.id` 而非 `user`)
五、清理函数的规范
// ✅ 每个 Effect 都要问自己:「如果它再执行一次 / 组件卸载了,需要撤销什么?」
useEffect(() => {
// ① 事件监听
const onResize = () => setSize(getSize());
window.addEventListener('resize', onResize);
const ac = new AbortController(); // ⭐ 或用 signal 统一管理多个监听
window.addEventListener('scroll', onScroll, { signal: ac.signal });
// ② 定时器
const timer = setInterval(tick, 1000);
// ③ Observer
const io = new IntersectionObserver(onIntersect);
io.observe(sentinelRef.current!);
// ④ 第三方实例
const chart = echarts.init(chartRef.current!);
return () => {
window.removeEventListener('resize', onResize);
ac.abort();
clearInterval(timer);
io.disconnect();
chart.dispose();
};
}, []);
⚠️ 清理函数的三个要点:
① 它的作用是「撤销本次同步」——所以「建立了什么就要撤销什么」
② 执行时机:依赖变化后(下次执行前)与卸载时
③ 不要「只在卸载时清理」的思维——依赖变化时会「先清理再重建」,
这正是 Effect 比 componentDidUpdate 更安全的地方
六、一个完整的重构示例
// ❌ 一个「Effect 用得很糟」的组件
function BadSearch({ keyword, onResults }) {
const [results, setResults] = useState([]);
const [filtered, setFiltered] = useState([]);
const [count, setCount] = useState(0);
// ① 派生数据用 Effect
useEffect(() => {
setFiltered(results.filter((r) => r.visible));
}, [results]);
// ② 派生计数用 Effect
useEffect(() => {
setCount(filtered.length);
}, [filtered]);
// ③ 请求没有取消
useEffect(() => {
fetch(`/search?q=${keyword}`).then((r) => r.json()).then(setResults);
}, [keyword]);
// ④ 通知父组件用 Effect
useEffect(() => {
onResults(filtered);
}, [filtered, onResults]);
return <List items={filtered} count={count} />;
}
// ✅ 重构后:只剩「真正需要 Effect」的那一个(请求 + 取消)
function GoodSearch({ keyword, onResults }: Props) {
// ① 请求:需要与外部系统(网络)同步 → 用 Effect,并处理竞态
const { data: results = [] } = useQuery({
queryKey: ['search', keyword],
queryFn: ({ signal }) => api.search(keyword, { signal }),
});
// ⭐ 如果用 TanStack Query,连 Effect 都不需要写
// ② 派生数据:渲染时直接算
const filtered = useMemo(() => results.filter((r) => r.visible), [results]);
const count = filtered.length;
// ③ 通知父组件:在「导致变化的事件」里做(这里没有事件 → 用 Effect 也可以,
// 但更推荐「状态提升」或直接由父组件从 results 派生)
useEffect(() => {
onResults(filtered);
}, [filtered, onResults]);
return <List items={filtered} count={count} />;
}
重构的四个动作:
① 派生数据(filter、count)→ 从 Effect 改为直接计算/useMemo
② 请求 → 交给查询库(连取消与竞态都不用自己写)
③ 通知父组件 → 优先考虑「状态提升」或「父组件自己派生」
④ 只剩「确实需要与外部同步」的部分才用 Effect
七、常见坑
// ① 用 Effect 做派生数据(多一次渲染 + 可能不同步)
useEffect(() => { setFullName(`${f} ${l}`); }, [f, l]);
// ② 用 Effect 触发事件(应该写在事件处理里)
// ③ 用 Effect 重置状态(应该用 key)
// ④ 无依赖数组 + setState → 无限循环
useEffect(() => { setCount(count + 1); });
// ⑤ 漏依赖 → 闭包陷阱(读到旧值)
useEffect(() => { console.log(value); }, []); // ⚠️
// ⑥ 依赖里有新对象/新函数 → Effect 反复执行
useEffect(() => {}, [{ a: 1 }]); // ❌
// ⑦ 忘记清理(订阅、定时器、监听、Observer、第三方实例)
// ⑧ 在清理函数里访问已卸载的 DOM
// ⑨ 用 Effect 做「初始化」(应该用 useState 惰性初始化或 useRef)
useEffect(() => { setInitial(compute()); }, []); // ⚠️ 应该 useState(() => compute())
// ⑩ async 函数直接作为 effect(返回 Promise)→ 报错
useEffect(async () => {}, []); // ❌
// ✅ 内部定义 async 函数并调用
// ⑪ 认为 Effect「在渲染前执行」(其实在绘制后)
// ⑫ 用 Effect 同步「内部状态到内部状态」(应该直接算)
// ⑬ StrictMode 下「请求两次」→ 用 ref 绕过(应该补清理)
// ⑭ 用 Effect 做「表单字段联动」(大部分应该用受控 props 直接算)
// ⑮ 一个 Effect 做太多事(应该按「外部系统」拆分)
// ⑯ 把「事件处理函数」放进依赖导致反复重建(用 ref 或移到内部)
// ⑩ 的正确写法
useEffect(() => {
let cancelled = false;
(async () => {
const data = await fetchData();
if (!cancelled) setData(data);
})();
return () => { cancelled = true; };
}, []);
八、面试延伸
- 「
useEffect是生命周期钩子吗?」
不是。它的语义是「把组件与外部系统同步」——描述的是「需要与什么保持一致」,而不是「在哪个时机执行」。useEffect(() => {...}, [])看起来像componentDidMount,但这个类比会误导你「把什么都塞进 Effect」。正确的心智模型是「Effect 是同步机制」:订阅、监听、定时器、网络、第三方实例、浏览器状态,这些「外部系统」才需要它。 -
「什么时候不该用
useEffect?」
四种情况(React 官方文档明确列出):① 派生数据——渲染时直接计算(必要时useMemo),不要用 Effect 同步到 state;② 响应某个事件——写在事件处理函数里(Effect 是「因渲染而运行」,不是「因交互」);③ 重置状态——用key让组件重建(比「列出所有要重置的 state」更可靠);④ 通知父组件/部分 props 变化时的调整——优先「状态提升」或在「导致变化的地方」调用回调。 -
「Effect 和『生命周期』的对应关系是什么?」
粗略对应:componentDidMount→useEffect(fn, []);componentWillUnmount→useEffect(() => () => {...}, [])的清理函数;componentDidUpdate→useEffect(fn)或useEffect(fn, [deps])。但关键差异是:类组件的「挂载/更新」是两个独立时机(你要在componentDidUpdate里手动比较前后值),而useEffect是一个同步过程——依赖变化时会自动「先清理、再建立」,不需要手动比较。 -
「为什么清理函数很重要?」
因为 Effect 可能在依赖变化时多次执行(不只是「挂载一次」)。如果不清洗上一次的「同步」(如订阅、监听、定时器、Observer、第三方实例),就会产生:① 重复的订阅/监听(一个事件触发多次回调);② 内存泄漏(定时器、实例一直存在);③ 状态错乱(旧的定时器还在改已过期的 state);④ 在 StrictMode 下会立刻暴露这些问题(挂载 → 卸载 → 再挂载)。所以规则是「建立了什么就撤销什么」。 -
「
useEffect的依赖数组怎么理解?」
它是「什么时候需要重新同步」的声明。三种写法:不写 → 每次渲染后都同步(几乎总是错的);[]→ 只在挂载时同步一次(清理在卸载时);[a, b]→ 挂载时 +a/b变化时同步(变化时先清理旧同步)。它不是性能优化开关——写[]不会让代码更快,只会改变语义(并可能引入闭包陷阱)。规范是「写全 Effect 里用到的每一个响应式值」。 -
「依赖里有对象/函数导致 Effect 反复执行怎么办?」
三种解法:① 把常量提到模块作用域(不依赖任何值的东西不该在组件里创建);② 用useMemo/useCallback稳定引用(或让上游稳定它);③ 依赖「原始值」而不是对象(如[user.id]而不是[user])。此外如果「函数是在 Effect 内部用」的,直接把它移到 Effect 内部(依赖就变成函数用到的值)。最后要反思:如果加了依赖就无限循环,也许这段逻辑本就不该放在 Effect 里。 -
「怎么判断一个
useEffect该不该存在?」
问三个问题:① 「有没有一个外部系统需要同步?」——如果没有(只是「算一个值」「通知一个回调」),就不该用 Effect;② 「这段逻辑是因为渲染而跑,还是因为用户交互而跑?」——后者应该写在事件处理里;③ 「如果我把这段 Effect 删掉,功能还正常吗?」——如果正常,说明它可能是「派生数据」或「多余的同步」。经验:一个组件里的useEffect越少越好(很多优秀的 React 组件一个都没有)。 -
「
useEffect用得少是不是说明水平高?」
在某种程度上是的——因为 Effect 主要是「与外部系统打交道」的胶水代码,而好的代码应该让「业务逻辑」与「副作用」尽量分离。一个「几乎没有 Effect」的组件通常意味着:① 数据获取交给了查询库(而不是手写useEffect+useState);② 派生数据是计算出来的(而不是「同步」出来的);③ 事件逻辑写在事件处理里;④ 状态重置用key。这些都指向「更少的状态同步、更清晰的因果」。
一句话速记
useEffect= 「与外部系统同步」(不是生命周期钩子);四个不该用的场景是「派生数据(直接算)、事件响应(写在处理函数里)、重置状态(用key)、通知父组件(状态提升或在事件里调)」;该用的场景是「订阅、事件监听、定时器、Observer、第三方实例、网络请求、浏览器状态」;依赖要写全(不要靠删依赖消警告,不稳定就修依赖或用ref存最新值);清理函数必须写(建立了什么就撤销什么,它在「依赖变化后」与「卸载时」执行);async函数不能直接作为 effect;一个组件的useEffect越少越好。



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