Skip to content

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 而言,它收到的指令是:

  1. setCount(0 + 1)
  2. setCount(0 + 1)
  3. 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 的处理流程变成了:

  1. prevCount => prevCount + 1 推入更新队列。
  2. 再次将 prevCount => prevCount + 1 推入队列。
  3. 第三次推入队列。
  4. 在处理阶段,取出第一个函数,执行 0 + 1,结果为 1。
  5. 取出第二个函数,此时 prevCount 是 1,执行 1 + 1,结果为 2。
  6. 取出第三个函数,此时 prevCount 是 2,执行 2 + 1,结果为 3。
  7. 最终,将 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,提升代码的整体质量。

基于 VitePress 的本地知识库