React 18 批量更新减少渲染次数

472次阅读  |  发布于2年以前

概述

React 18 增加了一个新的优化特性,在代码中无需手动处理,就可以支持更多场景下的批量更新 (batching)。本文将说明什么是批量更新,在 React 18 版本以前它是如何工作的,以及它在 React 18 版本发生了怎样的变化。

什么是批量更新?

批量更新是指 React 将多次 state 更新进行合并处理,最终只进行一次渲染,以获得更好的性能。

例如,如果在同一个点击事件中有两个状态更新,React 总是会把它们批量处理成一个重新渲染。如果运行以下代码,我们会看到每次点击时,虽然设置了两次状态,React 也只执行一次渲染:

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1); // Does not re-render yet
    setFlag(f => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

批量更新可以提高组件的渲染性能,因为它避免了不必要的渲染。同时,也防止了组件中只更新一个状态变量,导致组件其他状态变化并未完全渲染出来,这可能会引起 bug。与餐馆点菜的情景类似,服务员不会在我们点第一道菜时就到厨房下单,而是等点单完成才会一起下单。

然而,React 的批量更新并不是所有场景都会生效。例如,如果在 handleClick 中请求数据,然后在数据请求成功之后更新状态,那么 React 不会触发批量更新,而是执行两次独立的更新。

这是因为,之前版本的 React 要求只有在浏览器事件(如点击事件)中才会触发批量更新。但是,在下面的代码示例中,当数据请求成功之后,点击事件早已经结束了,这时进行状态更新(在 fetch 回调函数中)是不会触发批量更新的:

unction App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

在 React 18 之前,我们只在 React 事件处理函数中执行过程中进行批量更新。在默认情况下,对 promisessetTimeout、原生事件处理函数或其他任何事件中的状态更新都不会进行批量更新。

什么是自动批量更新?

从 React 18 的 createRoot 开始,不论在哪里, 所有更新都将自动进行批量更新。

这意味着 setTimeoutpromises、原生事件处理函数或其他任何事件的批量更新都将与 React 事件一样,以相同的方式进行批量更新。我们希望这样可以减少渲染工作量,从而提高应用程序的性能:

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 18 and later DOES batch these:
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

注意:希望你升级到 React 18 并使用其中的 createRoot。旧的 render 仅仅是为了简化两个版本的生产实验。

无论状态在哪里发生变化,React 都会进行批量更新,像这样:

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}

或者像这样:

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}, 1000);

或者像这样:

fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
})

或者像这样:

elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
});

注意:只有在通常安全的情况下 React 才会执行批量更新。例如,React 需要确保对于每个用户发起的事件(如点击或按键),DOM 在下一个事件之前完全更新。再例如,这可以禁止提交时禁用的表单被提交两次。

如果不想批量更新怎么办?

通常批处理是安全的,但有些代码可能依赖于在状态更改后立即从 DOM 中读取某些内容。对于这种情况,可以使用 ReactDOM.flushSync() 选择不进行批量处理:

import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}

这种场景应该不会经常出现。

这对 Hooks 有什么影响吗?

如果你正在使用 Hooks,在绝大多数情况下批量更新都能“正常工作”。

这对 Classes 有什么影响吗?

React 在执行事件回调期间的状态更新一直都是批量处理的,所以对于这些更新不会有任何变化。

在类组件中有一个比较极端的情况,可能会引起问题。

类组件有一个要特别的注意的现象,它可以同步读取事件内部的状态更新。也就是说,能够在两次调用 setState 之间读取 this.state

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

在 React 18 中,不会出现上面提到的现象。因为在 setTimeout 中的所有状态更新都是进行批量处理的,因此 React 不会同步渲染第一个 setState 的结果 —— 渲染将发生在下一个浏览器周期中。所以渲染还没有发生:

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 0, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

见 sandbox

如果这个问题阻碍了升级到 React 18,可以使用 ReactDOM.flushSync 强制更新,但建议谨慎使用:

handleClick = () => {
  setTimeout(() => {
    ReactDOM.flushSync(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
    });

    // { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

见 sandbox

这个问题对 Hooks 函数组件没有影响,因为设置 state 不会更新 useState 中的现有变量:

function handleClick() {
  setTimeout(() => {
    console.log(count); // 0
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
    console.log(count); // 0
  }, 1000)

在采用 Hooks 函数组件中,不用做任何处理,它已经为批量更新铺平了道路。

unstable_batchedUpdates 是什么?

一些 React 库会使用这个 API 强制对事件处理之外的 setState 进行批量更新:

import { unstable_batchedUpdates } from 'react-dom';

unstable_batchedUpdates(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
});

这个 API 在 18 中仍然存在,但不再需要它了,因为批量处理是自动进行的。我们没有在 18 版本中删除它,当主流库不再依赖于它之后,在未来的某个版本会删除它。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8