Appearance
useReducer 相比useState优势
useState:为什么函数式更新更可靠?
在日常的 React 开发中,useState 是我们最亲密的伙伴之一。我们通常会这样使用它:
javascript
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount(count + 1); // 直接传入一个新值
};这种 setCount(newValue) 的方式直观且易于理解。然而,useState 还提供了另一种看似稍显繁琐的更新模式——函数式更新:
javascript
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount(prevCount => prevCount + 1); // 传入一个函数
};既然已经有了直接更新的方式,为什么 React 还要提供函数式更新呢?它仅仅是一种语法偏好,还是背后蕴含着更深层的原因?
答案是后者。在某些特定场景下,函数式更新不仅是"更好"的选择,甚至是"唯一正确"的选择。它能帮助我们有效规避一类因 State 闭包陷阱(Stale Closure)导致的常见 Bug。
问题浮现:当 State 更新不符合预期
让我们从一个简单的反例开始。假设我们需要在一次交互中,连续两次将 count 的值加一。很多人会很自然地写出以下代码:
jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleTripleIncrement = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleTripleIncrement}>Increment by 3</button>
</div>
);
}直觉上,每次点击按钮,count 应该会增加 3。但实际运行后你会发现,count 每次只增加了 1。
这背后的本质原因是:setCount 的更新是异步的,并且 React 会对短时间内连续的更新进行批处理(Batching)。
在 handleTripleIncrement 函数的执行上下文中,count 的值始终是当次渲染时的快照。当我们连续三次调用 setCount(count + 1) 时,对于 React 而言,它收到的指令是:
setCount(0 + 1)setCount(0 + 1)setCount(0 + 1)
由于这三次更新被批处理,最终的结果是将 count 更新为 1,而不是我们期望的 3。同样的问题也会出现在异步操作中,例如在一个 setTimeout 的回调里去更新一个已经被外部改变的 State。
解决方案:使用函数获取最新 State
为了解决这个问题,函数式更新应运而生。当我们向 set 函数传递一个函数时,React 会保证这个函数的入参(我们通常命名为 prevState 或 prevCount)永远是当前最新的 State 值。
React 会将这些更新函数维护在一个队列中,并在下一次渲染前依次执行,确保每一次计算都是基于前一次更新后的结果。
现在,我们用函数式更新来重构上面的例子:
jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleTripleIncrement = () => {
// 每次都基于最新的 state 进行计算
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleTripleIncrement}>Increment by 3</button>
</div>
);
}这一次,代码的行为就完全符合预期了。每次点击,count 都会正确地增加 3。React 的处理流程变成了:
- 将
prevCount => prevCount + 1推入更新队列。 - 再次将
prevCount => prevCount + 1推入队列。 - 第三次推入队列。
- 在处理阶段,取出第一个函数,执行 0 + 1,结果为 1。
- 取出第二个函数,此时 prevCount 是 1,执行 1 + 1,结果为 2。
- 取出第三个函数,此时 prevCount 是 2,执行 2 + 1,结果为 3。
- 最终,将 count 的状态更新为 3。
函数式更新的核心优势
综上所述,我们可以总结出函数式更新带来的几个关键好处:
1. 保证 State 更新的准确性
这是最核心的优势。它能确保我们的更新逻辑始终基于最新的 State,从而彻底避免因 State 闭包陷阱或批处理带来的数据不一致问题。
2. 代码逻辑更具可预测性
当看到 setCount(c => c + 1) 这样的代码时,我们能立刻明白开发者的意图是"在当前值的基础上加一"。这种更新方式是自包含的,不依赖外部的变量,降低了代码的耦合度和出错的可能性。
3. 优化自定义 Hook 的设计
在编写自定义 Hook 时,有时我们只需要向外暴露 State 的更新能力,而不需要暴露 State 本身。函数式更新使得我们可以只传递 set 函数,而无需同时传递 State 值,从而让 Hook 的接口设计更加简洁和安全。
最佳实践:我们该如何选择?
那么,在实际开发中,我们应该遵循什么样的原则呢?一个简单有效的经验法则是:
- 如果新的 State 依赖于旧的 State,请始终使用函数式更新。
- 计数器:
setCount(prev => prev + 1) - 布尔值切换:
setToggle(prev => !prev) - 数组操作:
setItems(prev => [...prev, newItem])
- 计数器:
- 如果新的 State 与旧的 State 完全无关,可以直接传入新值。
- 表单输入:
setName(e.target.value) - 重置状态:
setCount(0)
- 表单输入:
结论
函数式更新并非只是一个可有可无的语法糖,而是 React 为我们提供的一个用于编写更健壮、更可预测组件的强大工具。养成在"基于旧值计算新值"的场景下使用函数式更新的习惯,将大大减少我们应用中潜在的 Bug,提升代码的整体质量。