从作用域链到 React 陈旧闭包:为什么函数式更新能拿到最新 state
准备前端面试时,我发现"闭包是什么"很容易背,"闭包为什么会让 React 计时器一直拿到旧 state"却经常解释不清。只说"内部函数访问外部变量"没有错,但无法指导实际排错。
我把两部分放到了一起理解:JavaScript 的词法作用域、作用域链与闭包,以及 React useState 的函数式更新和惰性初始化。这篇文章沿着运行机制组织,而不是按面试题逐条罗列。
1. 变量从哪里找,由声明位置决定
JavaScript 使用词法作用域(Lexical Scope):函数能访问哪些外部变量,由函数在源代码中的声明位置决定,而不是调用位置。
先看一个例子:
js
function bar() {
console.log(myName);
}
function foo() {
const myName = "foo 内部";
bar();
}
const myName = "全局";
foo();
bar 声明在全局作用域,它查找不到 foo 的局部变量,最终输出"全局"。把 bar() 写在 foo 里面调用,并不会改变 bar 的词法作用域。
可以把作用域链理解成变量查找路径:当前作用域找不到,就沿外层词法环境继续查,直到全局。调用栈回答"现在执行到哪个函数",作用域链回答"这个标识符去哪里找",两者不能混为一谈。
2. 闭包保存的是需要的外部绑定
当内部函数离开声明它的外部函数后仍然可调用,它还需要访问的外部变量不能被直接丢弃:
js
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
createCounter 已经执行结束,但 increment 仍引用 count。这个函数与它所需词法环境的组合,就是闭包(Closure)。
这里不是"把整个调用栈永远保存下来"。引擎保留的是仍然可达的绑定。只要 counter 还可达,count 就不能回收;当没有任何引用能再访问这个闭包时,它才有机会被垃圾回收。
3. 闭包不等于内存泄漏
闭包会延长外部变量的生命周期,但"活得更久"不等于"泄漏"。
真正的问题是:一个长期存在的闭包无意中引用了大对象、DOM 节点或已经不需要的数据,让垃圾回收器无法释放它们。
js
function bindHandler(element, hugeData) {
function handleClick() {
console.log(hugeData.title);
}
element.addEventListener("click", handleClick);
return () => {
element.removeEventListener("click", handleClick);
};
}
这段代码返回清理函数,让事件监听和闭包引用都能在组件卸载或业务结束时移除。
方案 A 是把所有闭包都视为风险并避免使用;方案 B 是明确它的生命周期并清理长期订阅。React Hooks 本身大量依赖闭包,正确方向显然是 B。
4. React 每次渲染都会产生新的状态快照
函数组件不是只执行一次。每次渲染都会重新调用组件函数,产生当前渲染对应的变量和事件处理函数。
下面这个计时器最能说明陈旧闭包(Stale Closure):
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>Count: {count}</div>;
}
useEffect(..., []) 只在挂载后建立一次计时器。计时器回调是在首次渲染时创建的,因此它闭包中的 count 一直是首次渲染的 0。
每秒执行的实际效果都是:
text
setCount(0 + 1)
setCount(0 + 1)
setCount(0 + 1)
...
第一次变成 1 后,后续仍然尝试设置成 1,所以界面看起来"卡住了"。
5. 函数式更新为什么有效
修复方式是把"下一个值"写成接收当前队列值的函数:
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount((previousCount) => previousCount + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>Count: {count}</div>;
}
这个回调不读取闭包里的 count,React 在处理更新队列时把最新状态传入 previousCount。因此它既解决陈旧闭包,也适用于同一批次中的连续更新:
jsx
function addThree() {
setCount((value) => value + 1);
setCount((value) => value + 1);
setCount((value) => value + 1);
}
如果写三次 setCount(count + 1),三次读取的是同一渲染快照,不能表达"基于上一次更新继续加一"。
方案 A 是把 count 加入 effect 依赖,让每次变化都清除并重建计时器;方案 B 是函数式更新,让计时器保持不变。只需要基于旧值计算新值时,B 更直接;如果 effect 的业务确实依赖最新 count 做其他事情,就应该诚实地声明依赖,而不是一律用空数组。
6. 惰性初始化解决的是另一种重复计算
useState 的惰性初始化(Lazy Initialization)解决的是另一类问题。两种写法看起来接近,执行次数不同:
jsx
function heavyCalculation() {
let sum = 0;
for (let i = 0; i < 1_000_000; i += 1) {
sum += i;
}
return sum;
}
function Demo() {
// 每次组件渲染都会先调用 heavyCalculation,
// 即使 React 只在首次渲染采用这个初始值。
// const [value, setValue] = useState(heavyCalculation());
// 传函数:只在初始化 state 时调用。
const [value, setValue] = useState(
() => heavyCalculation(),
);
return (
<button onClick={() => setValue((v) => v + 1)}>
{value}
</button>
);
}
这里传入的是"初始化函数",不是把计算结果提前求出来。它和函数式更新都使用函数参数,但时机不同:
useState(() => initialValue):首次初始化时计算;setState(previous => next):处理更新队列时计算。
不是所有初始值都要写函数。字符串、数字和便宜对象直接传值更清楚;只有初始化确实昂贵或不希望每次渲染求值时,才需要惰性初始化。
7. 我排查陈旧闭包时会问什么
遇到定时器、事件监听、Promise 回调拿到旧值时,我会按这个顺序检查:
- 这个回调在哪一次渲染中创建?
- 它读取了哪些当前渲染的变量?
- effect 的依赖数组是否与读取内容一致?
- 新状态是否只是旧状态的函数?
- 订阅是否在卸载或依赖变化时清理?
常见选择包括:
- 基于旧值更新:函数式
setState; - effect 确实依赖某值:加入依赖并接受重新订阅;
- 需要一个不触发渲染的可变最新值:谨慎使用
useRef; - 回调跨越很长生命周期:重新审视状态归属和订阅设计。
不要为了消除 ESLint 提示随意删除依赖。依赖警告往往正是在提示闭包读取了外部变量。
结尾
把闭包和 React 放在一起后,我对这个问题的理解变得具体:
- 词法作用域由函数声明位置决定;
- 闭包让函数在外层调用结束后仍能访问需要的绑定;
- React 每次渲染都会产生新的状态快照和函数;
- 长期回调可能保留旧渲染的快照,形成陈旧闭包;
- 基于旧值计算新值时,用函数式更新;
- effect 读取外部值时,依赖数组要反映真实依赖;
- 闭包不是天然泄漏,缺少生命周期清理才是问题。
下一步可以继续把这套思路扩展到 useCallback、事件订阅和异步请求:重点不是背 API,而是判断某个函数捕获的是哪一次渲染、它会存活多久、什么时候应该重建或清理。