从作用域链到 React 陈旧闭包:为什么函数式更新能拿到最新 state

从作用域链到 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 回调拿到旧值时,我会按这个顺序检查:

  1. 这个回调在哪一次渲染中创建?
  2. 它读取了哪些当前渲染的变量?
  3. effect 的依赖数组是否与读取内容一致?
  4. 新状态是否只是旧状态的函数?
  5. 订阅是否在卸载或依赖变化时清理?

常见选择包括:

  • 基于旧值更新:函数式 setState;
  • effect 确实依赖某值:加入依赖并接受重新订阅;
  • 需要一个不触发渲染的可变最新值:谨慎使用 useRef;
  • 回调跨越很长生命周期:重新审视状态归属和订阅设计。

不要为了消除 ESLint 提示随意删除依赖。依赖警告往往正是在提示闭包读取了外部变量。

结尾

把闭包和 React 放在一起后,我对这个问题的理解变得具体:

  • 词法作用域由函数声明位置决定;
  • 闭包让函数在外层调用结束后仍能访问需要的绑定;
  • React 每次渲染都会产生新的状态快照和函数;
  • 长期回调可能保留旧渲染的快照,形成陈旧闭包;
  • 基于旧值计算新值时,用函数式更新;
  • effect 读取外部值时,依赖数组要反映真实依赖;
  • 闭包不是天然泄漏,缺少生命周期清理才是问题。

下一步可以继续把这套思路扩展到 useCallback、事件订阅和异步请求:重点不是背 API,而是判断某个函数捕获的是哪一次渲染、它会存活多久、什么时候应该重建或清理。

相关推荐
怕浪猫3 小时前
前端转全栈最低成本的技术栈选择
前端·javascript·面试
WebInfra3 小时前
Rsdoctor 2.0 发布:Agent 驱动的全自动优化工具
前端·javascript·node.js
晨间晓3 小时前
图片到底偏什么颜色?用华为云码道开发 OpenCV 色彩偏移分析工具
javascript·opencv·华为云
鬼手点金12 小时前
nanoGPT/nanochat 能做出 DeepSeek/Qwen 这样的大模型吗?
服务器·前端·javascript·bug·openclaw
软件技术NINI12 小时前
美妆商城html+Css+js 4页
javascript·css·html
蛋蛋的就会顺顺的12 小时前
hot100 p0——栈,贪心算法,动态规划
java·前端·javascript·算法·leetcode·力扣
码艺-Alimjan13 小时前
Tauri 源码备份 · 打包 & 解压 总结,源码收集之路
前端·javascript·vue.js
DevUp16 小时前
标着 JavaScript 的 Wiki 与知识库:既不是 CMS 也不是 SSG(番外三)
javascript·node.js·cms
kyriewen16 小时前
我上手了 Vite+ 1.0:Node 不用自己装,lint 却翻车了
前端·javascript·程序员