R30第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

依赖陷阱与旧闭包

依赖陷阱与旧闭包 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

定时器跑着跑着用的是三秒前的旧值、订阅回调里读到的永远是 0——这些都是旧闭包。这一课你会亲手复现它,并掌握三种修法的适用场景:函数式更新、正确的依赖数组、ref 持有最新值。

问题场景

一个每秒自增的计数器:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 用的永远是首次渲染的 count = 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // 依赖数组撒了谎:回调明明用了 count

  return <p>{count}</p>;
}

期望每秒加一,实际停在 1。useEffect 的回调在首次渲染时创建,捕获的 count 是那次渲染快照里的 0(快照模型见 R08)。依赖数组写 [] 等于向 React 承诺「回调不依赖任何会变的值」——这个承诺被打破了,React 也无从帮你。

核心解释

修法有三条,按优先级选:

一、函数式更新:根本不需要旧值。 新值只由旧值算出时,别引用快照:

setCount((c) => c + 1); // c 由更新队列提供,永远是最新的

二、正确的依赖:值变了就重新同步。 回调确实需要 count 时如实声明,Effect 会在 count 变化后清掉旧定时器、建新的。功能正确,代价是 interval 每秒被销毁重建——对长期订阅不划算。

三、ref 持有最新值:既要长期订阅、又要读最新值。 用一个小 Effect 把快照同步进 ref,回调读 ref:

const countRef = useRef(0);
useEffect(() => { countRef.current = count; }, [count]);

useEffect(() => {
  const id = setInterval(() => {
    console.log('最新值:', countRef.current); // 不触发渲染,只是读
  }, 1000);
  return () => clearInterval(id);
}, []);

顺带一提:React 正在实验的 useEffectEvent 就是为「读最新值但不重跑 Effect」设计的,可以关注,但生产代码目前仍以上面三种为准。

示例拆解

实验区提供三种模式的定时器对照,以及「延迟 3 秒 +1」按钮——演示同类问题在事件回调里的变体:

function delayedAddBroken() {
  setTimeout(() => setCount(count + 1), 3000); // 捕获的是点击那一刻的快照
}
function delayedAddFixed() {
  setTimeout(() => setCount((c) => c + 1), 3000); // 执行时才取最新值
}
  • 3 秒内连点两次坏版按钮,两次加的都是同一个旧快照,结果只加 1。
  • 修复版两次各自基于到期那一刻的最新值,结果加 2。
  • 事件回调里的闭包问题不涉及依赖数组——快照捕获发生在函数创建时,与 Effect 无关,但病根相同。

动手任务

打开本课实验区:

  1. 依次切换「坏版 / 函数式 / 正确依赖」三种模式各观察 5 秒,说出坏版停在 1、其余正常递增的原因。
  2. 分别连点两次「延迟 +1(坏)」和「延迟 +1(修复)」,对比 3 秒后的结果差。
  3. 切到「正确依赖」模式看屏幕日志,观察 interval 随 count 变化被重建的节奏。

常见错误

  • 依赖数组撒谎:写 [] 却用了 state,编译不报错、运行必出鬼;要么函数式更新,要么如实声明依赖。
  • 依赖写对象或函数:[{ id }] 或 [onChange] 每次渲染都是新引用,Effect 每次渲染都重跑;传原始值,或用 useMemo/useCallback 稳定引用。
  • 补全依赖后过度重订阅:依赖对了,但 WebSocket 每 100ms 重建一次——该改用函数式更新或 ref 方案。
  • 以为闭包问题只在 useEffect 里:事件处理里的 setTimeout、Promise 回调一样捕获快照,修法同样是函数式更新。

检查与复盘

完成下方检查题确认理解。自查清单:

  • 能解释旧闭包成因:回调捕获的是创建那一刻的渲染快照
  • 三种修法能对号入座:不需要值用函数式;值变了要重同步用正确依赖;长期订阅又要最新值用 ref
  • 审查 Effect 时先看依赖数组有没有「撒谎」

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1故障定位较难

bug 现场:秒表每秒 +1 正常走,但「计满 10 秒自动停止」永远不生效,跑到十几秒都不停。原因和修法是?

function Stopwatch() {
  const [sec, setSec] = useState(0);
  const timerRef = useRef(null);

  function start() {
    timerRef.current = setInterval(() => {
      setSec(sec + 1);
      if (sec + 1 >= 10) {
        clearInterval(timerRef.current);
      }
    }, 1000);
  }

  return <p>{sec} 秒</p>;
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...