知

模块二 · Hooks 与数据

第 7 / 18 节18 分钟阅读型

副作用与数据:从 useEffect 到请求缓存

useEffect 的定位是"同步外部系统",不是"生命周期钩子"。这一节讲清依赖与清理、请求竞态、缓存去重,以及什么时候该用数据请求库。

读完这一节,你能做到

  1. 用"同步外部系统"解释 useEffect 的职责。
  2. 写出带清理与取消的请求 effect,避免竞态。
  3. 判断什么时候该引入请求缓存库,而不是手写。
  4. 识别 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]);

三条规则:

  1. 依赖写全:effect 里用到的外部变量都要进依赖,否则读到旧值。
  2. 清理要对称:创建了什么,就清理什么。
  3. 依赖别放对象:对象字面量每次都是新引用,会让 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 里。

六、常见坑

  1. 空依赖数组里读了会变的 props,永远拿到初始值。
  2. 请求没有取消,切换页面后 setState 报警告。
  3. 在 effect 里 setState 且依赖包含该 state,造成无限循环。
  4. 把请求库的缓存当成全局状态,手动同步两套数据。
  5. 用 useEffect 做路由跳转,导致渲染后才跳,出现闪烁。

七、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个用户详情请求,支持切换用户、取消旧请求、显示加载与错误态。
  2. 把同一接口在两个组件里的请求合并成一个缓存 Hook,观察请求次数。
  3. 找一个用 effect 同步派生状态的组件,改成 useMemo。

② 让 AI 写同一题

把第 1 题交给 AI,补一句 切换用户时旧请求必须取消,卸载后不许再 setState。

③ 对比要盯的点

  • 它有 AbortController,还是在 then 里比对当前 id?两者都没有就是竞态。
  • 卸载时它还在 setData 吗?这是控制台里最常见的那条警告。
  • 错误态它是“展示给用户”,还是一句 console.error 就咽回去了?

④ 一句话取舍

它省掉的那个取消逻辑,在“用户连续点三个用户”的页面里会表现成什么?

八、自测

  1. useEffect 的职责是什么?
  2. 依赖数组的比较规则是什么?
  3. 请求竞态的三种处理方式是什么?
  4. 什么时候该引入请求库?
  5. 哪些逻辑不该放在 effect 里?

参考答案要点:同步外部系统;Object.is 逐项;取消/标记/请求库;缓存去重与分页复杂时;纯派生与事件逻辑。

附:数据请求的四种方案

方案做法适用
useEffect + fetch手写加载/错误/取消简单页面、一次性请求
自定义 Hook把请求逻辑封装成 useXxx同一接口多处复用
请求库缓存、去重、失效、重试中大型应用
框架 loader路由级预取SSR/元框架

选择标准不是“先进”,而是“重复代码有多少”。当同一接口在三个地方各写一遍加载态,就已经该封装了。

附:effect 与事件的区别

事件是“用户做了什么”,effect 是“组件需要与外部同步什么”。

  • 用户点击后发请求:放事件。
  • 组件出现时订阅、消失时取消:放 effect。
  • 根据 props 变化重新订阅:放 effect,依赖写 props。

把“点击后请求”放进 effect,会导致首次渲染也触发一次,还会因为依赖变化重复请求。

附:清理函数的三个作用

  1. 取消未完成的请求,避免 setState 到已卸载组件。
  2. 取消订阅/定时器,避免内存泄漏。
  3. 重置外部状态,避免下一次 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? 需要绘制前读取/修改布局时。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解