对一系列状态更新进行排队

设置状态变量将排队进行另一次渲染。但有时你可能希望在排队进行下一次渲染之前对该值执行多个操作。为此,了解 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() 调用 *之后* 发生的原因。

这可能会让你想起餐厅服务员下单的过程。服务员不会在你提到你的第一道菜时就跑到厨房去!相反,他们会让你点完菜,让你修改订单,甚至接受同一桌其他人的点菜。

An elegant cursor at a restaurant places and order multiple times with React, playing the part of the waiter. After she calls setState() multiple times, the waiter writes down the last one she requested as her final order.

插图作者 Rachel Lee Nabors

这允许你更新多个状态变量(即使来自多个组件),而不会触发过多的 重新渲染。 但这也意味着在你的事件处理程序及其中的所有代码都完成 *之后*,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。三秒钟后,“待处理”计数器应减少,“已完成”计数器应增加。

但是,“待处理”计数器的行为不符合预期。当你按下“购买”时,它会减少到 -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);
  });
}