一系列状态更新的排队

设置状态变量将排队另一个渲染。但有时你可能希望在排队下一个渲染之前对值执行多个操作。为此,了解 React 如何批量处理状态更新很有帮助。

你将学习

  • 什么是“批量处理”,以及 React 如何使用它来处理多个状态更新
  • 如何连续对同一个状态变量应用多个更新

React 批量处理状态更新

你可能期望点击“+3”按钮会将计数器增加三次,因为它调用了三次 setNumber(number + 1)

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>
    </>
  )
}

然而,正如你可能从上一节回忆起的那样,每次渲染的状态值是固定的,因此第一次渲染的事件处理程序中的 number 值始终为 0,无论你调用多少次 setNumber(1)

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

但是这里还有一个因素。React 会等到事件处理程序中的所有代码都运行完毕后,才会处理你的状态更新。这就是为什么重新渲染只发生在所有这些 setNumber() 调用之后

这可能让你想起餐厅里服务员点菜的情景。服务员不会在你提到你的第一道菜时就跑到厨房!相反,他们会让你点完菜,让你修改菜品,甚至还会接受桌子上其他人的点菜。

这允许你更新多个状态变量——甚至来自多个组件——而不会触发过多的重新渲染。但这同时也意味着,直到你的事件处理程序及其中的任何代码完成后,UI 才会更新。这种行为,也称为批量处理,可以让你的 React 应用运行得更快。它还可以避免处理令人困惑的“半完成”渲染,其中只有部分变量已更新。

React 不会跨多个故意事件(例如点击)进行批量处理——每次点击都会单独处理。请放心,React 只有在通常安全的情况下才会进行批量处理。这确保了,例如,如果第一次点击按钮禁用了表单,第二次点击就不会再次提交表单。

在下次渲染之前多次更新相同的状态

这是一个不常见的用例,但如果你想在下次渲染之前多次更新同一个状态变量,而不是像 setNumber(number + 1) 那样传递下一个状态值,你可以传递一个函数,该函数根据队列中的上一个状态计算下一个状态,例如 setNumber(n => n + 1)。这是一种告诉 React“对状态值执行某些操作”而不是仅仅替换它的方法。

现在尝试递增计数器

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

这里,n => n + 1 被称为更新函数。当你将其传递给状态设置器时

  1. React 将此函数排队,以便在事件处理程序中的所有其他代码运行完毕后进行处理。
  2. 在下一次渲染期间,React 将遍历队列并提供最终更新的状态。
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);

以下是 React 在执行事件处理程序时处理这些代码行的过程

  1. setNumber(n => n + 1): n => n + 1 是一个函数。React 将其添加到队列中。
  2. setNumber(n => n + 1): n => n + 1 是一个函数。React 将其添加到队列中。
  3. setNumber(n => n + 1): n => n + 1 是一个函数。React 将其添加到队列中。

在下一次渲染期间调用 useState 时,React 会遍历队列。之前的 number 状态是 0,所以 React 将其作为 n 参数传递给第一个更新函数。然后,React 获取之前更新函数的返回值,并将其作为 n 传递给下一个更新函数,以此类推。

排队更新n返回
n => n + 100 + 1 = 1
n => n + 111 + 1 = 2
n => n + 122 + 1 = 3

React 将 3 存储为最终结果,并将其从 useState 返回。

这就是为什么在上例中点击“+3”会正确地将值增加 3 的原因。

如果在替换状态后更新状态会发生什么

这个事件处理程序呢?您认为在下一次渲染中 number 将是多少?

<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>Increase the number</button>
    </>
  )
}

以下是这个事件处理程序告诉 React 要做什么

  1. setNumber(number + 5): number0,所以 setNumber(0 + 5)。React 将“替换为 5添加到其队列中。
  2. setNumber(n => n + 1): n => n + 1 是一个更新函数。React 将该函数添加到其队列中。

在下一次渲染期间,React 会遍历状态队列

排队更新n返回
“替换为 50(未使用)5
n => n + 155 + 1 = 6

React 将 6 存储为最终结果,并将其从 useState 返回。

注意

您可能已经注意到 setState(5) 实际上类似于 setState(n => 5),但 n 未使用!

如果在更新状态后替换状态会发生什么

让我们再试一个例子。您认为在下一次渲染中 number 将是多少?

<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}

以下是 React 在执行此事件处理程序时遍历这些代码行的过程

  1. setNumber(number + 5): number0,所以 setNumber(0 + 5)。React 将“替换为 5添加到其队列中。
  2. setNumber(n => n + 1): n => n + 1 是一个更新函数。React 将该函数添加到其队列中。
  3. setNumber(42): React 将“替换为 42添加到其队列中。

在下一次渲染期间,React 会遍历状态队列

排队更新n返回
“替换为 50(未使用)5
n => n + 155 + 1 = 6
“替换为 426(未使用)42

然后,React 将 42 存储为最终结果,并将其从 useState 返回。

总而言之,您可以这样考虑传递给 setNumber 状态设置器的参数

  • 更新函数(例如 n => n + 1)将添加到队列中。
  • 任何其他值(例如数字 5)会将“替换为 5”添加到队列中,忽略已排队的內容。

事件处理程序完成后,React 将触发重新渲染。在重新渲染期间,React 将处理队列。更新函数在渲染期间运行,因此更新函数必须是 纯函数,并且只返回结果。不要尝试在其中设置状态或运行其他副作用。在严格模式下,React 将运行每个更新函数两次(但会丢弃第二个结果),以帮助您查找错误。

命名约定

通常情况下,更新函数的参数名以对应状态变量的首字母命名

setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

如果您更喜欢更详细的代码,另一个常见的约定是重复完整的状态变量名,例如 setEnabled(enabled => !enabled),或者使用前缀,例如 setEnabled(prevEnabled => !prevEnabled)

总结

  • 设置状态不会更改现有渲染中的变量,但会请求新的渲染。
  • React 在事件处理程序运行完毕后处理状态更新。这称为批量处理。
  • 要在单个事件中多次更新某个状态,可以使用 setNumber(n => n + 1) 更新函数。

挑战 1 2:
修复请求计数器

您正在开发一款艺术品市场应用程序,允许用户同时提交多个艺术品订单。每次用户按下“购买”按钮,“待处理”计数器应该增加一。三秒钟后,“待处理”计数器应该减少,“已完成”计数器应该增加。

但是,“待处理”计数器没有按预期工作。当您按下“购买”按钮时,它会减少到 -1(这是不可能的!)。如果您快速点击两次,两个计数器似乎都会出现不可预测的行为。

为什么会发生这种情况?请修复这两个计数器。

import { useState } from 'react';

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  async function handleClick() {
    setPending(pending + 1);
    await delay(3000);
    setPending(pending - 1);
    setCompleted(completed + 1);
  }

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy     
      </button>
    </>
  );
}

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}