ReactJs状态迷思:从"常量"到"闭包陷阱"的底层逻辑

很多初学 React 的开发者,在经历了从"能跑就行"到"写出优雅代码"的进阶时,都会经历一个认知重塑的阶段。在这个过程中,我们常常会被一些看似违背直觉的现象所困扰:为什么 const 声明的变量值还能变?为什么 setTimeout 里拿到的永远是旧值?

其实,这些问题的答案都隐藏在 React 的核心设计哲学中。今天,我们就结合具体的代码场景,彻底讲透 React 状态更新的底层逻辑。

一、 重新认识 const:是"绑定"不可变,而非"值"不可变

先来看一段完整的表单代码:

javascript 复制代码
import { useState } from 'react';

export default function Form() {
  const [form, setForm] = useState({
    firstName: '',
    lastName: ''
  });

  const fullName = form.firstName + ' ' + form.lastName;

  function handleChange(e) {
    const { name, value } = e.target;
    setForm(prev => ({
      ...prev,
      [name]: value
    }));
  }

  return (
    <>
      <h2>Let's check you in</h2>
      <label>
        First name:{' '}
        <input name="firstName" value={form.firstName} onChange={handleChange} />
      </label>
      <label>
        Last name:{' '}
        <input name="lastName" value={form.lastName} onChange={handleChange} />
      </label>
      <p>Your ticket will be issued to: <b>{fullName}</b></p>
    </>
  );
}

在 React 函数组件中,我们经常会写出这样的派生状态:

javascript 复制代码
const fullName = form.firstName + ' ' + form.lastName;

很多开发者会疑惑:fullName 明明是用 const 声明的常量,为什么当 firstNamelastName 改变时,fullName 的值也会跟着变?常量不是不能被修改吗?

这里的核心误区在于混淆了"变量绑定"与"变量值"的概念。在 JavaScript 中,const 保证的是变量绑定(Binding)不可变 ,即你不能用 =fullName 重新赋值。但它绝不意味着它所指向的值是永恒不变的。

React 的渲染机制是:每当状态(State)发生改变,整个组件函数都会被重新执行一遍。

你可以把组件函数想象成一台"UI 制造机"。当你在输入框打字触发 setForm 时,React 会重新按下启动键,Form 函数从头到尾再跑一遍。在第二次执行时,const fullName = ... 这行代码会被重新计算,并在全新的函数作用域 中生成一个全新的 fullName 常量。

因此,不仅 State 是快照,整个渲染周期内计算出来的所有普通变量(如 Props、派生状态),都是当前这一帧的快照。const 保证了在单次渲染(单帧)内,fullName 绝对不会被意外篡改;而状态的改变触发了函数的重新执行,从而在新的渲染周期中生成了新的快照。这就是 React 中 UI = f(state) 的核心思想。

二、React 状态批量更新的时机

React 状态批量更新(batch update)时机

前提:React 18 之后默认开启自动批量更新(automatic batching) ,和 React17 行为不一样,分开讲。

什么是批量更新

多次调用 setState不会每次都触发一次重渲染 ;React 收集所有状态更新,合并后只触发一次组件重渲染,提升性能。

✅ React18 自动批量更新(重点)

只要状态更新是发生在下面这些场景,全部自动批量

  1. React 原生事件回调:onClick / onChange / onSubmit
  2. useEffect 里面
  3. 定时器 setTimeout / setInterval
  4. Promise.then/async 回调
  5. 原生事件(addEventListener)

👉 简单一句话:React18 几乎所有场景都会批量更新

示例(React18,点击按钮,只渲染 1 次):

ini 复制代码
<button onClick={() => {
  setNumber(n => n + 1);
  setFlag(true);
  // 两次set,只重渲染一次 
}}/>

代码示例

点击 +3 按钮,数字只 +1,而不是 +3

javascript 复制代码
import { useState } from 'react';
export default function Counter() {
  const [number, setNumber] = useState(0);
  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}
原因核心
  1. setNumber 是异步状态更新,不会立刻修改当前作用域里的 number 变量
  2. 点击按钮这一次事件处理函数内,number 的值始终是点击瞬间渲染快照的值

举例:当前界面显示 0,整个 onClick 回调里面,number 固定等于 0

scss 复制代码
setNumber(0 + 1); // setNumber(1)
setNumber(0 + 1); // setNumber(1)
setNumber(0 + 1); // setNumber(1)

三次全部提交更新为 1,最后结果就是 1。 React 会批量处理 state 更新,后执行的相同值覆盖前面。

⚠️ 误区:以为调用完 setNumber,本行代码下面 number 就立刻变新值。不会,当前函数执行上下文不会变,只有下一次组件重渲染才会拿到新 number

修复方案:使用函数式更新(prev)

setNumber(最新的上一次状态 => 计算新值)

ini 复制代码
<button onClick={() => {
  setNumber(prev => prev + 1);
  setNumber(prev => prev + 1);
  setNumber(prev => prev + 1);
}}>+3</button>

执行流程:

三、 闭包陷阱:被困在"过去"的定时器

理解了"快照"的概念,我们就能解开 React 中最著名的 Bug 之一------闭包陷阱(Stale Closure)。

假设我们有一个定时器,希望在 3 秒后打印当前的计数值:

javascript 复制代码
import { useState } from 'react';

export default function TimerTrap() {
  const [count, setCount] = useState(0);

  function handleClick() {
    // 设置一个 3 秒后执行的定时器
    setTimeout(() => {
      // 3秒后,这里的 count 会打印什么呢?
      console.log('3秒后的 count 是:', count);
    }, 3000);
  }

  return (
    <div style={{ padding: '20px' }}>
      <p>当前计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>点击 +1</button>
      <button onClick={handleClick} style={{ marginLeft: '10px' }}>
        3秒后打印 count
      </button>
    </div>
  );
}

现象 :如果你点击"+1"将页面数字变为 3,然后立刻点击"3秒后打印",并在等待期间继续点击"+1"直到数字变成 10。3 秒后,控制台打印的依然是 3

为什么会这样?

当你点击"3秒后打印"时,React 执行了组件函数。在这一帧里,count 的值是 3setTimeout 里的回调函数被创建,它通过 JavaScript 的闭包机制,死死捕获(Closure)了当前这一帧的 count 快照(也就是 3)。

在接下来的 3 秒内,你疯狂点击按钮,触发了多次 setCount。每次更新都会让 React 重新执行组件函数,生成新的 count 快照(4, 5, 6...10)。但是,之前那个 setTimeout 里的回调函数,依然抱着它被创建时的那个旧快照(3)不放。它根本不知道外面的世界已经变天了。

四、 破局之道:如何获取"最新"的状态?

既然闭包会锁死旧值,如果我们在异步场景下确实需要读取或计算最新的状态,该怎么办?React 提供了两种优雅的解法:

1. 使用 useRef 穿透闭包

useRef 返回的是一个普通的 JS 对象,它的 .current 属性在内存中永远只有一份,不会被快照捕获。

javascript 复制代码
const countRef = useRef(count);
countRef.current = count; // 每次渲染都同步最新值

setTimeout(() => {
  console.log('最新的 count:', countRef.current); // 永远打印最新的值
}, 3000);

2. 使用函数式更新(Functional Update)

如果你是为了基于旧值计算新值,可以直接传函数给 setCount

javascript 复制代码
setTimeout(() => {
  setCount(prev => prev + 1); // React 会自动把最新的 prev 传进来
}, 3000);

函数式更新之所以有效,是因为它绕过了闭包捕获的旧值。React 不是直接接收一个计算好的值,而是接收一个"计算公式"。React 会在内部按顺序串行执行这些回调,确保每一步计算都基于上一步的最新结果。

五、 总结

React 的状态管理并不是在直接修改内存中的变量,而是通过**"状态快照 + 函数重新执行"**的机制来驱动 UI 的更新。

  • const 变量在每次渲染时都会生成新的快照,保证了单帧内的数据不可变性。
  • 闭包陷阱的本质,是异步回调函数捕获了旧渲染周期中的状态快照。
  • 面对异步场景,善用 useRef 或函数式更新,就能轻松规避陈旧闭包带来的 Bug。

理解了这些底层逻辑,你不仅能避开 React 开发中的各种"暗坑",更能真正体会到 React 声明式编程的优雅与严谨。

相关推荐
尘世中一位迷途小书童1 小时前
在 Cesium 上画全球网格:我们怎么把 GeoSOT 接到瓦片调度上
前端·javascript·前端框架
aixingpan1 小时前
aixingpan.cn API开发文档:api_docs_trichart_natal_progression_ter_transit2接口指南
前端·php
liuyt20221 小时前
【javaweb】day3
前端
CoderYanger1 小时前
Java EE 进阶:2.3 JavaScript
java·开发语言·前端·javascript·css·职场和发展·java-ee
恋猫de小郭1 小时前
ADB Wi-Fi 2.0 ,Android 17 把无线调试的连接链路重新做了一遍
android·前端·flutter
薛一半1 小时前
React路由初始
前端·react.js·前端框架
问心无愧05132 小时前
ctf show web 178
前端·笔记
岁岁种桃花儿2 小时前
Vue组件化编程第二篇:非单位件组件
前端·javascript·vue.js
晓得迷路了2 小时前
栗子前端技术周刊第 146 期 - React 19.3、jQuery 1.0 20 周年、Bun 1.4.2...
前端·javascript·react.js