首页 / React 学习笔记 / 10

REACT · Vol.II · LESSON 10 · Hooks 精讲

依赖数组与闭包陷阱:stale closure

进阶核心#Hooks#闭包

Bug 现场:一个永远停在 1 的计数器

上一篇我们把副作用妥妥地安顿进了 useEffect,还学会了拿依赖数组当「阀门」。看起来岁月静好——直到你写出这个「每秒自增」计数器:

Counter.tsx · ❌ 错误写法一:定时器里直接用 count
import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);  // ❌ 这里的 count 永远是 0
    }, 1000);
    return () => clearInterval(id);
  }, []);  // 空依赖:effect 只在首次渲染时执行这一轮

  return <p>{count}</p>;  // 界面永远显示 1
}

界面永远显示 1。定时器每秒都在跑、setCount 每秒都在调,数字却纹丝不动。你可能会想:那把 count 加进依赖数组总行了吧?

Counter.tsx · ❌ 错误写法二:把 count 塞进依赖
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);
// ❌ 依赖一变 → 清掉旧定时器 → 再建一个新定时器,每秒循环一次

这一版数字确实动了,但代价是每一秒都销毁并重建一个定时器——功能「碰巧」对,本质上像后端里「每次调用都 new 一个线程池」:能跑,纯属事故预备役。

先凭直觉回答:错误写法一里,定时器回调里的 count 为什么永远是 0?state 明明每秒都在变。——想不清楚很正常,这正是后端转前端的第一困惑点:闭包陷阱。往下看。

三十秒补课:闭包到底「捕获」了什么

闭包的学术定义很长,本篇只需要一条:函数会记住它「定义时」所在作用域的变量绑定——哪怕那个作用域早已执行完毕。

closure.js · 最小闭包
function makeCounter() {
  let n = 0;
  return () => {
    n = n + 1;  // 这个箭头函数记住了 n——makeCounter 的局部变量
    return n;
  };
}

const next = makeCounter();
next();  // 1
next();  // 2 —— makeCounter 早已返回,n 却一直活着

到这里都符合直觉。真正的转折在下一句:闭包记住的是「定义那一刻」的绑定。如果那个变量后来被替换成了「全新的一份」,旧闭包毫不知情,它还抱着原来那份。

后端类比:Java lambda 只捕获 effectively final

Java 里你天天写 lambda,却没踩过这个坑——因为 Java 把坑封死了:lambda 只能捕获 effectively final 的局部变量,捕获后谁也不许重新赋值。这本质是「捕获一份不可变的值」,从根上杜绝了「读到旧值还是新值」的争议。JS 则允许外面的变量后来换新值——「值过没过期」就归你自己操心。而 React 恰好每次渲染都把 count 换成全新一份,坑就这么踩上了。

套回组件里:组件每次渲染都重新执行函数体,每次执行时的 count 都是「当次渲染」的全新常量。effect 在哪次渲染里执行,它的闭包就只认识那次的 count——后面再来多少个新的 count,都与它无关。

正确的心智模型:每次渲染都是一张快照

把第 4 篇的 state、第 5 篇的渲染模型、上一站的闭包规则串起来,就得到本篇最重要的一句话:

一句话点破(请背下来)

「每次渲染都有自己的 props、自己的 state、自己的事件处理器、自己的 effect。」一次渲染 = 一张不可变的快照。state 变化不是「改了快照里的值」,而是「拍了一张全新的快照」。

snapshots.tsx · 把每次渲染想象成重新调用一次组件函数(伪代码)
// 第 1 次渲染:
const count = 0;                    // 全新常量,只属于这一次渲染
useEffect(() => {
  // 定时器 A 在这里创建,闭包捕获的 count 就是上面那个 0
}, []);

// 第 2 次渲染:count 是另一个全新常量,值为 1
// 但 deps=[] → effect 不再执行 → 定时器 A 还是第 1 次那个,它眼里的 count 永远是 0
setCount(0+1) setCount(0+1) 第 1 次渲染 count = 0 JSX:购物车(0) 定时器 A 捕获 0 闭包作用域:第 1 次的 第 2 次渲染 count = 1 JSX:购物车(1) deps=[] → effect 跳过 定时器 A 仍活着 第 3 次渲染 count = 1 JSX:购物车(1) deps=[] → effect 跳过 定时器 A 还在跑 每次渲染 = 一张新快照:有自己的 state、自己的 JSX、自己的 effect 旧快照里的函数只能认识旧快照的值 —— 这就是 stale closure
图 1空依赖的 effect:定时器 A 出生于第 1 次渲染,之后只拍新快照,它捕获的 count 永远停在 0

拿这个模型复盘错误写法一,每一步都清清楚楚:

  1. 第 1 次渲染count = 0,effect 执行,定时器 A 创建,闭包捕获 0;
  2. 1 秒后回调触发:setCount(0 + 1) → state 变 1 → 第 2 次渲染发生;
  3. deps 是 [],第 2 次渲染不会重跑 effect——定时器 A 还是第 1 张快照创建的那个;
  4. 于是每秒都在执行 setCount(0 + 1)——界面永远停在 1。

闭包本身没有 bug,它忠实地记住了「出生那次」的作用域。真正的问题是——我们让一张快照里的函数活得太久,还指望它知道后面快照的事

快照模型不只属于 effect:事件处理器同款发病

别以为这是定时器的专利。下面这段代码没有任何 effect,照样翻车——点击按钮,3 秒后弹窗显示什么?

Mystery.tsx · ❌ 弹窗里的 count 永远慢一拍
import { useState } from "react";

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

  function handleClick() {
    setCount(count + 1);
    setTimeout(() => {
      alert(count); // 3 秒后显示的是「点击那次渲染」的 count
    }, 3000);
  }

  return <button onClick={handleClick}>{count}</button>;
}

答案是点击前的旧值:点一下,界面变成 1,弹窗却弹出 0。复盘走一遍快照模型——handleClick 是本次渲染创建的函数,闭包捕获本次渲染的 countsetCount 引发下一轮渲染没错,但 setTimeout 的回调出生于上一张快照,它眼里的 count 永远定格在出生那一刻。

后端类比:异步线程拿到的永远是提交时的 DTO 快照

Java 里你把一个不可变 DTO 提交给线程池异步处理,主线程随后改了数据库——异步任务看到的仍是提交那一刻的快照,不会「实时刷新」。React 的每次渲染就是那次「提交」,事件处理器、定时器、Promise 回调都是「异步任务」:谁出生在哪张快照里,谁就只认得那张快照。

这个案例值得亲手跑一遍:它是「每次渲染都是一张快照」最直观的证据。想通了它,本篇所有修复手段(下一站)都只是同一个问题的不同侧面。

修复三板斧:函数式更新、补全依赖、useRef

板斧一:函数式更新(首选)

既然闭包里的 count 过期了,那就干脆别用它——setState 支持传入更新函数,React 会在真正应用更新那一刻,把最新值喂给你:

Counter.tsx · ✅ setCount(c => c + 1)
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);  // ✅ 不读捕获的 count,由 React 提供「最新值」
  }, 1000);
  return () => clearInterval(id);
}, []);  // deps 保持 []:定时器真的只建一次

第 4 篇里它是「连续 set 的正确姿势」,这一篇它升级成「跨时间读取最新 state」的手段:更新函数由 React 在应用更新那一刻调用,参数永远新鲜,天然绕开闭包陷阱。能用它,永远优先用它。

板斧二:诚实补全依赖数组

当 effect 确实需要用到某个会变的值(比如用它拼日志文案),别撒谎——把它写进 deps,让 React 在值变化时重建 effect 和它的闭包,每一轮闭包都拿到当次快照的新鲜值:

Greeting.tsx · ✅ 值变了,就重建 effect
useEffect(() => {
  const id = setInterval(() => {
    console.log(`当前次数:${count}`);  // 每一轮闭包里的 count 都是新鲜的
  }, 1000);
  return () => clearInterval(id);
}, [count]);  // ✅ 诚实声明:count 变了,就重建 effect 和它的闭包

它和错误写法二的区别很微妙:写法二是 effect 内部改自己依赖的 count,制造「改依赖 → 重建 effect → 再改依赖」的自我循环;这里则是外部状态变化 → 合理重建。同一个语法,两种意图:一种是 bug,一种是设计。

板斧三:useRef 存最新值(预告)

还有一类场景:定时器必须只建一次(deps 为 []),回调里却要读「最新」的某个值。标准解法是用 useRef 开一个「跨渲染的可变盒子」,每次渲染把最新值放进去,回调里读盒子:

Counter.tsx · 🧰 终极方案预告
const countRef = useRef(count);  // 一个跨渲染的「可变盒子」

useEffect(() => {
  countRef.current = count;      // 每次渲染,把最新值放进盒子
}, [count]);

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
    console.log(countRef.current);  // 读到的永远是最新值
  }, 1000);
  return () => clearInterval(id);
}, []);  // 定时器仍然只建一次

记得 import { useEffect, useRef, useState } from "react"。先混个脸熟即可,useRef 完整机制是第 12 篇主角。三板斧的选用顺序:能函数式更新就函数式更新要用会变的值就诚实补全依赖资源只能建一次又要读最新值,才用 useRef

让 ESLint 替你盯着:exhaustive-deps 的意义与「撒谎」的代价

好消息是:「哪些值会过期」这套推理不必人肉做。eslint-plugin-react-hooksreact-hooks/exhaustive-deps 规则会静态分析每个 effect:凡是组件作用域里用到、又会随渲染变化的值(state、props、组件里定义的函数……)没进依赖数组,它就报警。主流脚手架默认带了它,你只需别关

eslint.config.js · 确认两条 Hook 规则都开着
{
  "rules": {
    "react-hooks/rules-of-hooks": "error",  // Hook 只在顶层调用等硬性规则
    "react-hooks/exhaustive-deps": "warn"   // 依赖补全检查:别关!
  }
}

报警多了难免心烦,于是有人加 // eslint-disable-next-line react-hooks/exhaustive-deps 压掉它。可以压,但要想清楚——那是在对 React「撒谎」

你的操作表面效果真实代价
诚实补全依赖effect 在依赖变化时重建几乎为零——这正是 React 期望的协作方式
disable 规则,deps 写 []报警消失,effect 只跑一次闭包冻结在首次渲染,之后读到的全是过期值,且再无提醒
disable 规则,deps 随手写暂时不报错该重建时不重建、不该重建时狂重建,bug 随机闪现,极难排查
后端类比:依赖数组就是方法签名

把 effect 想象成一个方法,依赖数组就是参数列表useEffect(fn, [a, b]) 约等于声明「本方法依赖 a、b,任一变化请重新注入并重跑」。参数列表漏写一个、方法体却偷偷使用——编译期无察觉,运行期读旧值。这和「在 Spring 里自己 new 依赖而不交给容器注入」是同一种坏味道:绕过契约,框架就无法兜底。

核心要点
  • 心智模型一句话:每次渲染都有自己的 props、state、effect——effect 是被某次渲染创建、活到被清理为止的「快照函数」。
  • 闭包陷阱的根源:effect 活了很久,却只认识「出生那次渲染」的变量绑定。
  • 快照是渲染的根本性质,不是 effect 专利——事件处理器、setTimeout 里的闭包同样只认出生那帧的值。
  • 修复三板斧:setX(c => …) 函数式更新 → 诚实补全依赖 → useRef 存最新值(第 12 篇)。
  • react-hooks/exhaustive-deps 是 React 生态最重要的 lint 规则:报警先修代码,disable 前想清楚代价。

本章回顾

上一篇回答了「effect 何时执行」,这一篇回答了「effect 里读到的值是哪一版的」。你建立了「每次渲染都是一张快照」的心智模型和修复三板斧。但补全依赖只是第一步——把对象、数组、函数写进 deps,会发现它们每次渲染都是新引用,effect 又开始疯狂重建。怎么让引用稳定下来?下一篇第 11 篇,useMemouseCallback 登场。见。

Comments · 评论