使用 hooks 时,React 是否会批处理状态更新函数?

117

对于类组件,如果在事件处理程序中调用this.setState,则会进行批处理。 但是,如果在事件处理程序外部使用useState hook更新状态会发生什么呢?


答案:对于函数组件中使用的useState hook,在事件处理程序之外更新状态也会触发批处理。
function Component() {
  const [a, setA] = useState('a');
  const [b, setB] = useState('b');

  function handleClick() {
    Promise.resolve().then(() => {
      setA('aa');
      setB('bb');
    });
  }

  return <button onClick={handleClick}>{a}-{b}</button>
}

它会立即呈现aa - bb吗?还是它将是aa - b,然后是aa - bb

7个回答

157

TL;DR - 如果状态更改是异步触发的(例如包装在一个promise中),它们将不会被批处理;如果直接触发,它们将被批处理。

我已经建立了一个沙盒来尝试这个: https://codesandbox.io/s/402pn5l989

import React, { Fragment, useState } from 'react';
import ReactDOM from 'react-dom';

import './styles.css';

function Component() {
  const [a, setA] = useState('a');
  const [b, setB] = useState('b');
  console.log('a', a);
  console.log('b', b);

  function handleClickWithPromise() {
    Promise.resolve().then(() => {
      setA('aa');
      setB('bb');
    });
  }

  function handleClickWithoutPromise() {
    setA('aa');
    setB('bb');
  }

  return (
    <Fragment>
    <button onClick={handleClickWithPromise}>
      {a}-{b} with promise
    </button>
    <button onClick={handleClickWithoutPromise}>
      {a}-{b} without promise
    </button>
      </Fragment>
  );
}

function App() {
  return <Component />;
}

const rootElement = document.getElementById('root');
ReactDOM.render(<App />, rootElement);

我做了两个按钮,一个触发在您的代码示例中包装在承诺中的状态更改,另一个则直接触发状态更改。

如果您查看控制台,当您点击“带有承诺”的按钮时,它将首先显示a aab b,然后是a aab bb

因此,答案是否定的,在这种情况下,它不会立即呈现aa-bb,每个状态更改都会触发新的呈现,没有批处理。

但是,当您单击“不带承诺”的按钮时,控制台将立即显示a aab bb

因此,在这种情况下,React会批处理状态更改,并为二者一起进行一次呈现。


1
顺便提一下,我尝试了一下没有 Promise.resolve 的方法。setA 和 setB 就像类组件一样被批处理了,类似于在事件处理程序中调用 setState。 - vadirn
7
来自 https://github.com/facebook/react/issues/10231#issuecomment-316644950 的注释 - 这是实现细节,未来版本可能会发生更改。 - Aprillion
1
我认为@Aprillion提到的问题不适用于hooks,而是关于类组件的。 - ned
1
@ned 虽然这个问题是在 Hooks 之前创建的,但是评论本身适用于任何状态实现,应用程序不应该依赖于当前的优化细节。 - Aprillion
1
这个例子中使用的字母非常令人困惑。b b是正确的吗?我认为那是一个打字错误。 - temporary_user_name
显示剩余2条评论

26

目前在 React v16 及更早版本中,默认仅对位于 React 事件处理程序(例如 clickonChange 等)内部的更新进行批处理。因此,就像类组件一样,在 hook 中也采用了类似的方式批处理状态更新。

对于罕见情况下需要批量处理的场景,React 提供了一个不稳定的 API,可强制在事件处理程序之外批处理更新。

ReactDOM.unstable_batchedUpdates(() => { ... })

未来版本的React可能是v17或更高版本,有一个批处理所有状态更新的计划。 如果状态更新是在异步函数中调用或由异步代码触发的话,它们将不会被批处理,但直接更新会被批处理。没有同步代码的情况下,状态更新是被批处理的,而异步代码更新则不是。
function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // async update from useEffect
  useEffect(() => {
    setTimeout(() => {
      setCount1(count => count + 1);
      setCount2(count => count + 2);
    }, 3000);
  }, []);

  const handleAsyncUpdate = async () => {
    await Promise.resolve("state updated");
    setCount1(count => count + 2);
    setCount2(count => count + 1);
  };

  const handleSyncUpdate = () => {
    setCount1(count => count + 2);
    setCount2(count => count + 1);
  };

  console.log("render", count1, count2);
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button type="button" onClick={handleAsyncUpdate}>
        Click for async update
      </button>
      <button type="button" onClick={handleSyncUpdate}>
        Click for sync update
      </button>
    </div>
  );
}

https://codesandbox.io/s/739rqyyqmq


来自 https://github.com/facebook/react/issues/10231#issuecomment-316644950 的注释 - 这是实现细节,可能会在未来版本中更改。 - Aprillion
componentDidMount 中的状态更改也会被批处理。 - holmberd
据我所知,React 17.0.1仍然无法在React事件处理程序之外批量更新。 - aldel

5
如果事件处理程序是基于React的,那么它会批量更新。无论是setState还是useState调用都是如此。但是,如果事件是非React基础的,即setTimeout、Promise调用等,则不会自动批处理。简而言之,任何来自Web APIs的事件。

4

@Patrick Hund已经给出了答案。

想在这里更新一下,使用React 18,Promise、setTimeout等也可以默认批量处理状态更新了。

在React 18之前,我们仅在React事件处理程序期间批量更新。在Promise、setTimeout、本机事件处理程序或任何其他事件内部的更新不会被React默认批量处理。

点击查看详细说明:https://github.com/reactwg/react-18/discussions/21


3

React 18使用createRoot,自动批量处理所有更新,无论它们来自哪里。

请注意,使用旧版的ReactDOM.render()仍然保留旧的行为。如果您想在超时、承诺或任何其他事件内批处理更新,请使用ReactDOM.createRoot()

这里我们在超时内两次更新状态,但React只渲染一次:

import React, { useState } from "react";
import ReactDOM from "react-dom";

function App() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  function handleClick() {
    setTimeout(() => {
      setX((p) => p + 1);
      setY((p) => p + 1);
    }, 100);
  }

  console.log(`render x: ${x} y: ${y}`);

  return (
    <div className="App">
      <button onClick={handleClick}>Update with promise</button>

      <div>X: {x} </div>
      <div>Y: {y} </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);

1

这是针对React 18的更新。他们引入了一种叫做“自动批处理”的东西。在早期版本的React中,只有由浏览器事件触发/更新的状态才进行批处理,但是使用React 18后,无论状态来自何处,都会进行批处理。

考虑以下组件:


    const App = () => {
      const [count, setCount] = useState(0);
      const [trigger, setTreigger] = useState(false);

      const handleClick = () => {
        setTimeout(() => {
          setCount(count => count++);
          setTrigger(trigger => !trigger);
        }, 100)    
      }
      
      console.log("Re-render", count, trigger);

      return (
        <div>
          <button onClick={handleClick}>
            Click Me!
          </button>
        </div>);
    }

现在,React版本已经更新到18,对于这种情况,React也执行了批处理。这表明,React无论状态来自何处,都变得更加高效。

您可以从上述组件中console.log的输出进行验证。


0
这是一个Jest测试,它展示了只有在渲染和useEffect执行一次的情况下才会触发。
  it("two set states will trigger only one render with effect check", async () => {
    const renderFn = jest.fn();
    const effectFn = jest.fn();
    function MyComponent() {
      const [foo, setFoo] = useState("foo");
      const [bar, setBar] = useState("bar");

      const handleClick = useCallback(() => {
        setFoo("blah");
        setBar("blah");
      }, [])

      useEffect(()=> {
        noop(foo);
        noop(bar);
        effectFn();
      },[foo,bar]);
      renderFn();
      return <div data-testid="test" onClick={handleClick}>{foo}{bar}</div>;
    }

    const { unmount } = render(<MyComponent />);
    expect(screen.getByTestId("test").textContent).toEqual("foobar");
    expect(renderFn).toBeCalledTimes(1);
    expect(effectFn).toBeCalledTimes(1);
    fireEvent.click(screen.getByTestId("test"))
    expect(renderFn).toBeCalledTimes(2);
    expect(effectFn).toBeCalledTimes(2);
    expect(screen.getByTestId("test").textContent).toEqual("blahblah");
    unmount();
    expect(renderFn).toBeCalledTimes(2);
    expect(effectFn).toBeCalledTimes(2);
  })

如果您正在使用 async,则它不会批处理它们并要求另一个渲染来完成下一组。
  it("two set states will trigger render with effect check with async handler per await", async () => {
    const renderFn = jest.fn();
    const effectFn = jest.fn();
    function MyComponent() {
      const [foo, setFoo] = useState("foo");
      const [bar, setBar] = useState("bar");

      const handleClick = useCallback(async () => {
        await new Promise<void>((resolve) => { setFoo("blah"); resolve() })
        await new Promise<void>((resolve) => { setBar("blah"); resolve() })
      }, [])

      useEffect(() => {
        noop(foo);
        noop(bar);
        effectFn();
      }, [foo, bar]);
      renderFn();
      return <div data-testid="test" onClick={handleClick}>{foo}{bar}</div>;
    }

    const { unmount } = render(<MyComponent />);
    expect(screen.getByTestId("test").textContent).toEqual("foobar");
    expect(renderFn).toBeCalledTimes(1);
    expect(effectFn).toBeCalledTimes(1);
    fireEvent.click(screen.getByTestId("test"))
    expect(renderFn).toBeCalledTimes(2);
    expect(effectFn).toBeCalledTimes(2);
    expect(screen.getByTestId("test").textContent).toEqual("blahbar");
    // second state update after await
    await act(() => Promise.resolve());
    expect(renderFn).toBeCalledTimes(3);
    expect(effectFn).toBeCalledTimes(3);
    expect(screen.getByTestId("test").textContent).toEqual("blahblah");
    unmount();
    expect(renderFn).toBeCalledTimes(3);
    expect(effectFn).toBeCalledTimes(3);
  })

完整的源代码,包括其他场景在 https://github.com/trajano/react-hooks/blob/master/src/__tests__/useStateBatchTest.tsx 中。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接