banner
NEWS LETTER

为什么你的 useState 总是在“复读”?—— 深度解析 React 渲染快照

Scroll down

useState 渲染快照示意图

前言:useState 真的只是存个值吗?

很多初学者(包括我)在刚接触 React Hooks 时,觉得 useState 简直是开发神器。但随着项目复杂度增加,你总会遇到一些诡异的 Bug:明明写了三行更新代码,页面只变了 1 次?明明状态更新了,定时器里拿到的还是旧值?

今天,咱们就来撕开 useState 的“温情面纱”,看看它底层的“残酷真相”。


场景一:消失的“+1” —— 批量更新与快照逻辑

业务场景

我们要写一个计数器,用户点击按钮时,我们希望逻辑里能连续触发三次加法。

初始代码(直觉误区)

const [count, setCount] = useState<number>(0);

const handleAdd = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 预期是 3,结果页面显示是 1
};

深度分析:为什么失效了?

这里有两个核心概念:批处理(Batching)渲染快照(Snapshot)

  1. 渲染快照:在 React 中,每一次渲染都有它自己的 PropsState。当你调用 setCount(count + 1) 时,这个 count 是当前渲染周期里的常量。即便你写了三行,由于这三行代码里的 count 都是 0,React 接收到的指令其实是:“请把下一次渲染的 count 设为 0 + 1”。
  2. 异步批处理:React 为了性能,会将同一个事件处理函数中的多个 setState 合并处理。

解决方案:函数式更新

const handleAdd = () => {
// 这里的 prev 代表上一次更新队列处理完后的最新值
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
};

强烈建议“如果你需要基于当前状态计算下一个状态,永远优先使用函数式更新。”


场景二:被锁死的时间 —— 闭包陷阱(Stale Closure)

业务场景

我们需要一个定时器,每秒打印当前的 count 值。

踩坑代码

useEffect(() => {
const timer = setInterval(() => {
console.log("当前计数:", count);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖项为空,希望只在挂载时启动一次

深度分析:为什么拿不到最新值?

这就是闭包陷阱

  • useEffect 的回调函数在组件挂载时创建,它“捕捉”了当时闭包环境里的 count(即 0)。
  • 由于依赖项数组是 [],这个 Effect 永远不会重新执行。即便外层的 count 变了,定时器里的函数依然引用着那个出生时的 count: 0

横向对比 (Vue3):在 Vue3 中,ref 本质是一个 Proxy 对象,我们访问的是 count.value。即使函数地址没变,但我们始终是通过引用去“盒子”里拿最新的值,所以 Vue 不存在这种闭包陷阱。

解决方案

  1. 添加依赖项(不推荐):让 Effect 随 count 变化而重启(但会导致定时器频繁重置)。
  2. 使用 Ref(推荐):利用 useRef 创造一个不触发渲染但能保持引用的“盒子”。

代码演示

const latestCount = useRef(count);

// count变化就更新 ref 的值
useEffect(() => {
latestCount.current = count;
}, [count]);

//挂载启动一次
useEffect(() => {
const timer = setInterval(() => {
console.log("当前计数:", latestCount.current); // 永远能拿到最新值
}, 1000);
return () => clearInterval(timer);
}, []);

场景三:引用类型的“冷漠” —— 不可变数据(Immutability)

业务场景

更新一个用户对象的年龄。

错误演示

const [user, setUser] = useState({ name: "Jack", age: 18 });

const updateAge = () => {
user.age = 20; // 直接修改原对象
setUser(user); // 页面没反应
};

深度分析:Object.is 的判断

React 底层使用 Object.is 算法来对比新旧状态。
当你执行 user.age = 20 时,user 对象在内存里的地址(指针)完全没变。React 进行判断:“既然引用地址没变,那数据就没变,不需要重渲染。”

解决方案:拥抱不可变性,创造全新对象

const updateAge = () => {
// 展开运算符创建一个全新的对象,地址变了,React 才会响应
setUser({
...user,
age: 20,
});
};

避坑总结

  1. 状态不是变量,是快照:每一次渲染都是一张独立的照片。
  2. 不要在异步回调里迷信 State:如果要在 setTimeoutsetInterval 里拿最新值,考虑 useRef 或函数式更新。
  3. 永远不要修改原对象:在 React 里,state 是只读的。想更新?请先 Copy 副本!
其他文章
目录导航 置顶
  1. 1. 前言:useState 真的只是存个值吗?
  2. 2. 场景一:消失的“+1” —— 批量更新与快照逻辑
    1. 2.1. 业务场景
    2. 2.2. 初始代码(直觉误区)
    3. 2.3. 深度分析:为什么失效了?
    4. 2.4. 解决方案:函数式更新
  3. 3. 场景二:被锁死的时间 —— 闭包陷阱(Stale Closure)
    1. 3.1. 业务场景
    2. 3.2. 踩坑代码
    3. 3.3. 深度分析:为什么拿不到最新值?
    4. 3.4. 解决方案
  4. 4. 场景三:引用类型的“冷漠” —— 不可变数据(Immutability)
    1. 4.1. 业务场景
    2. 4.2. 错误演示
    3. 4.3. 深度分析:Object.is 的判断
    4. 4.4. 解决方案:拥抱不可变性,创造全新对象
  5. 5. 避坑总结