模块二 · Hooks 与数据
副作用与数据:从 useEffect 到请求缓存
useEffect 的定位是"同步外部系统",不是"生命周期钩子"。这一节讲清依赖与清理、请求竞态、缓存去重,以及什么时候该用数据请求库。
读完这一节,你能做到
- 用"同步外部系统"解释 useEffect 的职责。
- 写出带清理与取消的请求 effect,避免竞态。
- 判断什么时候该引入请求缓存库,而不是手写。
- 识别 effect 依赖导致的无限循环。
一、useEffect 的定位
官方定义是"同步组件与外部系统",不是"组件挂载/更新时执行"。理解这一点,很多写法就清楚了:
| 外部系统 | 同步什么 | 清理什么 |
|---|---|---|
| 网络 | 请求数据 | 取消请求 |
| 订阅 | 订阅事件 | 取消订阅 |
| 定时器 | 创建定时器 | 清除定时器 |
| DOM | 绑定第三方实例 | 销毁实例 |
它不是用来"监听状态变化做业务"的。纯计算的派生值用 useMemo,事件触发的逻辑放事件回调。
二、依赖与清理
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then((r) => r.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [userId]);
三条规则:
- 依赖写全:effect 里用到的外部变量都要进依赖,否则读到旧值。
- 清理要对称:创建了什么,就清理什么。
- 依赖别放对象:对象字面量每次都是新引用,会让 effect 每次都执行。
三、请求的竞态问题
快速切换用户时,先发的请求可能后返回,把新数据覆盖掉,这叫竞态。三种处理:
- 取消:
AbortController,卸载或依赖变化时中止旧请求。 - 标记:用一个
ignore变量,返回时判断是否已过期。 - 请求库:用带 key 的缓存库,自动处理去重与取消。
useEffect(() => {
let ignore = false;
fetchData(userId).then((data) => {
if (!ignore) setData(data);
});
return () => {
ignore = true;
};
}, [userId]);
四、什么时候该用请求库
手写 useEffect + fetch 在简单页面够用,但它不解决:
- 缓存与失效(同一数据多处使用)。
- 去重与重试。
- 分页与无限滚动。
- 乐观更新与错误回滚。
当页面出现"同一接口在三个组件里各请求一次""返回页面又白屏"时,就该引入请求库(如 TanStack Query、SWR)。它们把"服务端状态"当成一类特殊状态管理,而不是塞进全局 store。
五、effect 的常见反模式
| 反模式 | 问题 | 替代 |
|---|---|---|
| 用 effect 同步派生状态 | 多一次渲染,容易循环 | useMemo |
| 用 effect 监听 props 调回调 | 数据流绕圈 | 在事件里调用 |
| 依赖里放整个对象 | 每次都执行 | 放稳定字段 |
| 忘记清理订阅 | 内存泄漏 | 返回清理函数 |
判断标准:如果这段逻辑不是"与外部系统同步",就不该放在 effect 里。
六、常见坑
- 空依赖数组里读了会变的 props,永远拿到初始值。
- 请求没有取消,切换页面后 setState 报警告。
- 在 effect 里 setState 且依赖包含该 state,造成无限循环。
- 把请求库的缓存当成全局状态,手动同步两套数据。
- 用
useEffect做路由跳转,导致渲染后才跳,出现闪烁。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个用户详情请求,支持切换用户、取消旧请求、显示加载与错误态。
- 把同一接口在两个组件里的请求合并成一个缓存 Hook,观察请求次数。
- 找一个用 effect 同步派生状态的组件,改成
useMemo。
② 让 AI 写同一题
把第 1 题交给 AI,补一句 切换用户时旧请求必须取消,卸载后不许再 setState。
③ 对比要盯的点
- 它有
AbortController,还是在then里比对当前 id?两者都没有就是竞态。 - 卸载时它还在
setData吗?这是控制台里最常见的那条警告。 - 错误态它是“展示给用户”,还是一句
console.error就咽回去了?
④ 一句话取舍
它省掉的那个取消逻辑,在“用户连续点三个用户”的页面里会表现成什么?
八、自测
- useEffect 的职责是什么?
- 依赖数组的比较规则是什么?
- 请求竞态的三种处理方式是什么?
- 什么时候该引入请求库?
- 哪些逻辑不该放在 effect 里?
参考答案要点:同步外部系统;Object.is 逐项;取消/标记/请求库;缓存去重与分页复杂时;纯派生与事件逻辑。
附:数据请求的四种方案
| 方案 | 做法 | 适用 |
|---|---|---|
| useEffect + fetch | 手写加载/错误/取消 | 简单页面、一次性请求 |
| 自定义 Hook | 把请求逻辑封装成 useXxx | 同一接口多处复用 |
| 请求库 | 缓存、去重、失效、重试 | 中大型应用 |
| 框架 loader | 路由级预取 | SSR/元框架 |
选择标准不是“先进”,而是“重复代码有多少”。当同一接口在三个地方各写一遍加载态,就已经该封装了。
附:effect 与事件的区别
事件是“用户做了什么”,effect 是“组件需要与外部同步什么”。
- 用户点击后发请求:放事件。
- 组件出现时订阅、消失时取消:放 effect。
- 根据 props 变化重新订阅:放 effect,依赖写 props。
把“点击后请求”放进 effect,会导致首次渲染也触发一次,还会因为依赖变化重复请求。
附:清理函数的三个作用
- 取消未完成的请求,避免 setState 到已卸载组件。
- 取消订阅/定时器,避免内存泄漏。
- 重置外部状态,避免下一次 effect 叠加。
写 effect 时先想“我要清理什么”,再写主体,能避免大部分泄漏。effect 是同步,不是生命周期;清理与创建同样重要。请求要么取消,要么标记;依赖写稳定值,不写对象。先想清楚再写,少一个清理,多一个泄漏;effect 写得好,组件就稳。清理是承诺,依赖是契约,副作用要有边界。边界即清晰。
附:一个请求 Hook 的完整实现
function useUser(id) {
const [state, setState] = useState({
loading: true,
data: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({ loading: true, data: null, error: null });
fetch(`/api/user/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => setState({ loading: false, data, error: null }))
.catch((error) => {
if (error.name !== "AbortError")
setState({ loading: false, data: null, error });
});
return () => controller.abort();
}, [id]);
return state;
}
三个细节:切换 id 时先重置状态、取消旧请求、区分 AbortError。缺一个,就会出现“闪旧数据”或“卸载后 setState”。
附:什么时候不该用 effect
- 纯派生:用 useMemo。
- 事件响应:放事件回调。
- 初始化只执行一次且与渲染无关:放模块顶层或初始化函数。
附录:高频追问
问:effect 的 cleanup 什么时候执行? 下一次 effect 前与组件卸载时。
问:为什么会出现“卸载后 setState”? 请求未取消,回调在卸载后执行;用 AbortController 或 ignore 标记。
问:请求库解决了什么? 缓存、去重、失效、重试、分页;把服务端状态从全局 store 里拆出来。
问:effect 能写 async 吗? 不能直接写,要包一层 async 函数再调用。
问:依赖里放对象为什么会循环? 对象每次新建,effect 每次执行,若 effect 又 setState 就会循环。
问:useEffect 和 watch 的区别? 一个同步外部系统,一个监听值变化;React 没有自动依赖收集。
问:什么时候用 useLayoutEffect? 需要绘制前读取/修改布局时。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解