Archive Note
React Hook 学习 2:useEffect 的副作用边界
useEffect 是 React 里最容易被滥用的 Hook。
很多人把它当成“组件里什么逻辑都能塞的地方”,但这正是问题开始的地方。useEffect 的真正职责不是“写逻辑”,而是:
把 React 渲染结果和外部世界同步起来。
这里的“外部世界”包括:
- 浏览器 API
- 订阅系统
- 定时器
- 网络请求
- 第三方库实例
- 手动 DOM 操作
如果你把这个边界想清楚,useEffect 就会从“玄学 Hook”变成一个非常明确的工具。
一、原理:effect 不是为了计算,而是为了同步
组件函数本身应该尽量保持纯:
- 给定同样的 props 和 state
- 返回同样的 UI
但真实应用不可能只有纯计算。你总要请求数据、注册事件、创建订阅、写 localStorage。useEffect 的存在,就是为了在渲染完成后处理这些副作用。
这也是为什么很多逻辑不应该放进 effect。
错误思路
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
这里 fullName 只是派生值,不是副作用。你不需要“同步到外部世界”,而只是多绕了一层状态同步。
正确思路
const fullName = `${firstName} ${lastName}`.trim();
二、开发场景:什么才该放进 useEffect
1. 注册和清理事件监听
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
2. 发请求并把结果放进 state
useEffect(() => {
let cancelled = false;
async function loadUsers() {
const response = await fetch("/api/users");
const data = await response.json();
if (!cancelled) {
setUsers(data);
}
}
loadUsers();
return () => {
cancelled = true;
};
}, []);
3. 和第三方实例同步
比如地图、图表、播放器、编辑器。
useEffect(() => {
chart.setData(data);
}, [chart, data]);
三、依赖数组到底怎么理解
依赖数组不是“我想让它什么时候执行”,而是:
这个 effect 使用了哪些会变化的值?
也就是说,依赖数组应该来自 effect 的真实输入,而不是来自你的愿望。
常见错误
useEffect(() => {
fetchUser(userId);
}, []);
这里 effect 明明用了 userId,却把依赖写成 [],就会造成数据不同步。
正确写法
useEffect(() => {
fetchUser(userId);
}, [userId]);
四、最常见误区:把 effect 当成“生命周期替代品”
很多人脑子里还停留在 class component 时代,会把 effect 理解成:
componentDidMountcomponentDidUpdatecomponentWillUnmount
这种理解不是完全错,但很容易把你带偏。更稳的方式是问:
- 我要同步什么外部资源?
- 这个同步依赖哪些值?
- 当这些值变化时,我是否需要重做同步?
- 之前的同步是否需要清理?
当你这样想,effect 的结构就自然了。
五、Strict Mode 为什么会看起来“执行两次”
开发环境里,React Strict Mode 会故意多执行一次某些 effect 流程,以帮助你暴露副作用问题。
所以你可能看到:
- 请求发两次
- 订阅注册两次
- console log 多一次
这不是 React 坏了,而是在提醒你:
- effect 是否可重复执行
- cleanup 是否写对了
- 有没有把不该放进 effect 的逻辑放进去了
如果一个 effect 无法承受重复执行,它的设计通常就有问题。
六、竞态问题:异步请求最容易踩的坑
当依赖变化很快时,旧请求可能比新请求更晚返回,最终把旧数据覆盖到新 UI 上。
例如搜索框:
useEffect(() => {
let active = true;
async function search() {
const response = await fetch(`/api/search?q=${keyword}`);
const result = await response.json();
if (active) {
setResults(result);
}
}
search();
return () => {
active = false;
};
}, [keyword]);
这个模式的本质不是“防内存泄漏”,而是防止过期请求污染当前界面。
七、什么时候不该用 useEffect
这是比“怎么用”更重要的问题。
不要用 effect 做这些事
- 派生数据计算
- 事件响应后的即时逻辑
- 只是因为“组件加载后要做点什么”就塞进去
- 纯粹为了把一个 state 同步到另一个 state
一个判断方法
如果这段逻辑可以:
- 在 render 里直接算出来
- 在事件处理函数里直接做掉
- 在数据结构设计阶段消灭掉
那就不要用 effect。
八、真实开发建议
1. 一个 effect 只做一件同步任务
不要把订阅、请求、localStorage、埋点全塞进一个 effect。
拆开后:
- 依赖更清楚
- cleanup 更清楚
- 出 bug 更容易定位
2. 先想依赖,再写 effect
不是写完逻辑再补依赖数组,而是先想:
- 这段同步依赖什么值?
- 变化后要不要重做?
3. 把 cleanup 当成 effect 的一部分
不是“可选收尾”,而是 effect 设计的一半。
如果你注册了:
- 事件监听
- 订阅
- 定时器
- 第三方实例
那你就必须考虑对应清理。
九、这一篇最该记住的结论
useEffect的职责是把 React 状态和外部世界同步- 依赖数组描述的是 effect 的真实输入,不是你希望它怎么执行
- 能在 render 中推导的值,不要放进 effect
- 异步 effect 要防竞态,不只是防报错
- cleanup 不是附属品,而是 effect 设计本身的一部分
如果 useState 解决的是“什么值会驱动渲染”,那 useEffect 解决的就是“渲染之后怎么和外部系统对接”。