很多初学 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 声明的常量,为什么当 firstName 或 lastName 改变时,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 自动批量更新(重点)
只要状态更新是发生在下面这些场景,全部自动批量:
- React 原生事件回调:
onClick/onChange/onSubmit useEffect里面- 定时器
setTimeout/setInterval - Promise.then/async 回调
- 原生事件(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>
</>
)
}
原因核心
setNumber是异步状态更新,不会立刻修改当前作用域里的number变量- 点击按钮这一次事件处理函数内,
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 的值是 3。setTimeout 里的回调函数被创建,它通过 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 声明式编程的优雅与严谨。