为何使用 push() 会导致 React 用户界面过时,以及如何通过新引用解决此问题
了解为何在 useState 中修改数组不会触发重新渲染,React 的引用检查机制是如何工作的,以及何时应该使用展开复制和函数式更新。
来源: React Survival Guide, Part 2: Don’t Mutate! Why `.push()` is Killing Your UI
在 React 中,没有哪种错误比界面拒绝更新而控制台却显示数据正确的状况更令人困惑了。既没有错误提示,也没有警告信息,屏幕只是停留在旧的数据上。在绝大多数情况下,其原因是状态变异:直接修改数组或对象的内容而非替换整个对象。本文将详细解释为何 React 会忽略这种直接修改的行为,展示一行代码的解决方案,并介绍当更新频繁发生时所需使用的函数式更新方式。
问题所在:数据变了,界面却没有变化
想象一个简单的购物车、待办事项列表或用户表格。其中最常见的操作就是向 useState 中存储的数组添加元素,而最初的实现方式看起来就像普通的 JavaScript 代码。
const [cart, setCart] = useState(['Coffee']);
const addItem = (newItem) => {
// It's just JavaScript, right?
cart.push(newItem);
console.log("Added! Cart is now:", cart);
};
点击“加入购物车”后,日志显示为 ['Coffee', 'Tea'],数据看似正确。但实际渲染的购物车列表中仍然只有“Coffee”。此时界面向用户展示的信息已不再真实,这种情况比程序崩溃更糟糕,因为直到有顾客发现问题之前没人会察觉。面对此类情况,开发人员往往要花费一小时重新检查那些根本不是问题根源的循环和渲染逻辑。
注意,这个处理函数根本没有调用 setCart。由于没有向 React 发出任何操作发生的信号,它自然没有理由进行渲染。该漏洞的更隐蔽版本会在 push() 之后添加 setCart(cart),但屏幕依然不会发生变化。下一节将解释原因。
React 比较的是引用而非内容
需要牢记的核心理念很简单:React关注的是身份标识,而非内容。
.push()、.pop()、.splice()、.unshift()这类方法,或是user.name = 'Alice'这样的赋值操作都属于状态变更。它们会指示JavaScript修改内存中某个位置的值,JavaScript会立即执行该操作,因此console.log(cart)才能显示出新增的物品。然而React并不会监控内存中的这些变化,它的更新机制假设状态是不可变的。
当你调用状态设置函数时,React会使用Object.is将新值与当前值进行比较。对于数组和对象,这是一种引用比较:它只判断该对象是否与之前的完全相同,而不考虑其内容是否有变化。在执行push()操作后,你传回的数组仍是React已持有的那个对象。检查结果显示“未改变”,React就会跳过重新渲染,界面仍会显示上次保存的版本。
这种简单的检查是经过精心设计的。无论状态大小如何,引用比较始终需要恒定时间,而每次更新时都对所有嵌套值进行深度比较则会耗费大量资源。因此,你必须通过提供新的引用来表明状态发生了变化。
解决方案:复制、修改副本、设置副本
不可变性听起来比实际要求更高。要更新状态中的数组或对象,需保留原对象不变,创建一个包含更改内容的新对象,然后将其传递给设置函数。在现代 JavaScript 中,展开运算符(...)是实现这一目标的最快方式。
修正后的 addItem 会创建一个新数组,其中包含原有元素以及新增元素。
const addItem = (newItem) => {
// The right way (creating a new identity):
setCart([...cart, newItem]); // Create a new array, add newItem at the end.
};
此时设置函数接收到的引用已改变,Object.is 检查会失败,React 便会重新渲染并显示新增的元素。其他操作也遵循相同原理:
- 删除:使用
cart.filter(item => item !== target)而非splice()。 - 替换:使用
cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item))。
setUser({ ...user, name: 'Alice' }),而非直接修改属性值。[...cart].sort() 创建副本,或在目标浏览器支持的情况下使用不会修改原数据的 toSorted() 和 toReversed() 方法。需注意,展开运算符仅能复制一层数据。如果状态中包含嵌套对象,则每层需要单独创建副本。如需更深入地了解 React 状态之外的引用变异问题,包括嵌套结构的相关内容,请参阅 避免因 JavaScript 引用变异导致的隐性状态错误。
不可变更新的优势
这不仅是 React 强制的规则,它还能让代码更易于理解。
- 清晰的更新节点。每一次状态变化都会通过setter处理,因此可以清楚地知道是哪些函数在何处修改了状态。
- 更便捷的调试。由于每次更新都会生成独立的值,React DevTools及其他工具能够显示每次状态变化前后的数值。
- 没有隐藏的副作用。由于数据从不会被就地修改,某个组件中的错误就不会悄无声息地破坏应用其他部分仍在使用的数组或对象。
用于快速或重叠状态变化的函数式更新
在高频更新场景下,比如快速连续点击或多个异步响应几乎同时到达且可能顺序混乱时,会出现另一个相关问题。每个处理函数都会获取其创建时的状态值。如果在重新渲染之前有两次更新发生,两者都可能基于同一个过时的快照开始处理,从而导致其中一个更改覆盖另一个更改。这就是“过时闭包问题”,它会直接影响新状态的构建方式。
解决方法是向设置函数传递一个函数。React会用最新的待处理状态调用该函数,这样每次更新都会基于上一次的结果进行,而非基于之前捕获的值。下面的示例演示了如何追加一批生成的项:它从参数中读取prevCart.length来为这些项编号,然后返回一个全新的数组,将之前的项与新的项合并在一起。
// A very fast counter:
const addManyItems = (count) => {
// Good: Uses spread to be immutable
// Better: Uses functional update for consistency
setCart(prevCart => {
// We are generating new array content:
const newItems = Array.from({ length: count }, (_, i) => `Item ${prevCart.length + i + 1}`);
// Spread operator for the final, clean, new identity:
return [...prevCart, ...newItems];
});
};
这里有两点在起作用。展开运算符仍然会生成一个新的引用,正是这一点促使 React 重新渲染。更新函数则确保基础数组是最新版本,从而避免更新丢失。一个好习惯是:每当需要根据之前的状态计算下一个状态时,都使用函数式形式。
总结
- 修改操作会改变数据但不会改变引用,因此 React 的
Object.is检查会判定没有变化并跳过渲染。 - 当界面更新滞后于数据时,首先检查是否有对状态的
.push()、.unshift()、.splice()操作,或是state.property = value这类修改。 - 使用展开运算符、
map或filter创建新的数组或对象,并复制所有需要修改的嵌套层级。
修复通常只需一行代码,而这正是用户能够信任的接口与那些会默默显示过时信息的接口之间的区别。