REACT · Vol.II · LESSON 10 · Hooks 精讲
依赖数组与闭包陷阱:stale closure
Bug 现场:一个永远停在 1 的计数器
上一篇我们把副作用妥妥地安顿进了 useEffect,还学会了拿依赖数组当「阀门」。看起来岁月静好——直到你写出这个「每秒自增」计数器:
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 加进依赖数组总行了吧?
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
// ❌ 依赖一变 → 清掉旧定时器 → 再建一个新定时器,每秒循环一次
这一版数字确实动了,但代价是每一秒都销毁并重建一个定时器——功能「碰巧」对,本质上像后端里「每次调用都 new 一个线程池」:能跑,纯属事故预备役。
count 为什么永远是 0?state 明明每秒都在变。——想不清楚很正常,这正是后端转前端的第一困惑点:闭包陷阱。往下看。三十秒补课:闭包到底「捕获」了什么
闭包的学术定义很长,本篇只需要一条:函数会记住它「定义时」所在作用域的变量绑定——哪怕那个作用域早已执行完毕。
function makeCounter() {
let n = 0;
return () => {
n = n + 1; // 这个箭头函数记住了 n——makeCounter 的局部变量
return n;
};
}
const next = makeCounter();
next(); // 1
next(); // 2 —— makeCounter 早已返回,n 却一直活着
到这里都符合直觉。真正的转折在下一句:闭包记住的是「定义那一刻」的绑定。如果那个变量后来被替换成了「全新的一份」,旧闭包毫不知情,它还抱着原来那份。
Java 里你天天写 lambda,却没踩过这个坑——因为 Java 把坑封死了:lambda 只能捕获 effectively final 的局部变量,捕获后谁也不许重新赋值。这本质是「捕获一份不可变的值」,从根上杜绝了「读到旧值还是新值」的争议。JS 则允许外面的变量后来换新值——「值过没过期」就归你自己操心。而 React 恰好每次渲染都把 count 换成全新一份,坑就这么踩上了。
套回组件里:组件每次渲染都重新执行函数体,每次执行时的 count 都是「当次渲染」的全新常量。effect 在哪次渲染里执行,它的闭包就只认识那次的 count——后面再来多少个新的 count,都与它无关。
正确的心智模型:每次渲染都是一张快照
把第 4 篇的 state、第 5 篇的渲染模型、上一站的闭包规则串起来,就得到本篇最重要的一句话:
「每次渲染都有自己的 props、自己的 state、自己的事件处理器、自己的 effect。」一次渲染 = 一张不可变的快照。state 变化不是「改了快照里的值」,而是「拍了一张全新的快照」。
// 第 1 次渲染:
const count = 0; // 全新常量,只属于这一次渲染
useEffect(() => {
// 定时器 A 在这里创建,闭包捕获的 count 就是上面那个 0
}, []);
// 第 2 次渲染:count 是另一个全新常量,值为 1
// 但 deps=[] → effect 不再执行 → 定时器 A 还是第 1 次那个,它眼里的 count 永远是 0
拿这个模型复盘错误写法一,每一步都清清楚楚:
- 第 1 次渲染:
count = 0,effect 执行,定时器 A 创建,闭包捕获 0; - 1 秒后回调触发:
setCount(0 + 1)→ state 变 1 → 第 2 次渲染发生; - deps 是
[],第 2 次渲染不会重跑 effect——定时器 A 还是第 1 张快照创建的那个; - 于是每秒都在执行
setCount(0 + 1)——界面永远停在 1。
闭包本身没有 bug,它忠实地记住了「出生那次」的作用域。真正的问题是——我们让一张快照里的函数活得太久,还指望它知道后面快照的事。
快照模型不只属于 effect:事件处理器同款发病
别以为这是定时器的专利。下面这段代码没有任何 effect,照样翻车——点击按钮,3 秒后弹窗显示什么?
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 是本次渲染创建的函数,闭包捕获本次渲染的 count;setCount 引发下一轮渲染没错,但 setTimeout 的回调出生于上一张快照,它眼里的 count 永远定格在出生那一刻。
Java 里你把一个不可变 DTO 提交给线程池异步处理,主线程随后改了数据库——异步任务看到的仍是提交那一刻的快照,不会「实时刷新」。React 的每次渲染就是那次「提交」,事件处理器、定时器、Promise 回调都是「异步任务」:谁出生在哪张快照里,谁就只认得那张快照。
这个案例值得亲手跑一遍:它是「每次渲染都是一张快照」最直观的证据。想通了它,本篇所有修复手段(下一站)都只是同一个问题的不同侧面。
修复三板斧:函数式更新、补全依赖、useRef
板斧一:函数式更新(首选)
既然闭包里的 count 过期了,那就干脆别用它——setState 支持传入更新函数,React 会在真正应用更新那一刻,把最新值喂给你:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // ✅ 不读捕获的 count,由 React 提供「最新值」
}, 1000);
return () => clearInterval(id);
}, []); // deps 保持 []:定时器真的只建一次
第 4 篇里它是「连续 set 的正确姿势」,这一篇它升级成「跨时间读取最新 state」的手段:更新函数由 React 在应用更新那一刻调用,参数永远新鲜,天然绕开闭包陷阱。能用它,永远优先用它。
板斧二:诚实补全依赖数组
当 effect 确实需要用到某个会变的值(比如用它拼日志文案),别撒谎——把它写进 deps,让 React 在值变化时重建 effect 和它的闭包,每一轮闭包都拿到当次快照的新鲜值:
useEffect(() => {
const id = setInterval(() => {
console.log(`当前次数:${count}`); // 每一轮闭包里的 count 都是新鲜的
}, 1000);
return () => clearInterval(id);
}, [count]); // ✅ 诚实声明:count 变了,就重建 effect 和它的闭包
它和错误写法二的区别很微妙:写法二是 effect 内部改自己依赖的 count,制造「改依赖 → 重建 effect → 再改依赖」的自我循环;这里则是外部状态变化 → 合理重建。同一个语法,两种意图:一种是 bug,一种是设计。
板斧三:useRef 存最新值(预告)
还有一类场景:定时器必须只建一次(deps 为 []),回调里却要读「最新」的某个值。标准解法是用 useRef 开一个「跨渲染的可变盒子」,每次渲染把最新值放进去,回调里读盒子:
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-hooks 的 react-hooks/exhaustive-deps 规则会静态分析每个 effect:凡是组件作用域里用到、又会随渲染变化的值(state、props、组件里定义的函数……)没进依赖数组,它就报警。主流脚手架默认带了它,你只需别关。
{
"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 篇,useMemo 与 useCallback 登场。见。
Comments · 评论