react18 批处理解决不必要的render(译文)

1,053 阅读6分钟

[原文地址](Automatic batching for fewer renders in React 18 · Discussion #21 · reactwg/react-18 · GitHub)

写在前面

最近在学习React18中相关更新,也可以说是心血来潮想翻译一些东西,也是为了自己学习,也想跟社区同学一起学习,初次翻译,有很多语义可能不同,同学们如果遇到不对的地方,可以查阅原文,也期望同学能指出文中不合事实的地方。

概述

react 18添加开箱即用的性能改进,通过默认情况下进行更多的批处理,删除在应用程序或库代码中手动的批量更新。 这篇文章将解释批处理是什么,它以前的工作原理,以及改变了什么。

Note: 这是一个底层的新特性(新改变),我们不希望引起许多用户的思考。 但是,它可能与教育工作者和库开发人员有关。

什么是批处理

批处理是指为了更好的性能将一组状态更新,融合进一次单独的重渲染中

例如,你如果有两个状态需要在一次点击事件中更新,React总会把它们放入一次重渲染中。如果你点击以下代码,你会发现每次你点击的时候,React只会重渲染一次,尽管你更新了两次状态。

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

  function handleClick() {
    setCount(c => c + 1); // 暂时不回重渲染
    setFlag(f => !f); // 暂时不回重渲染
    // React 只会最后重渲染一次
    // 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>
  );
}

Demo: React 17 batches inside event handlers(注意console 中的信息)

这对性能是非常好的提升,因为它避免了很多不必要的重渲染。他还能阻止你的组件因为单个状态的更新而显示出一个半成品的状态,有些甚至会造成一些问题。这可能会提醒你,直到你点完单后,服务员才会去厨房下单,并不会在你点第一道菜时就去。

但是在批处理中,react的表现并不一致。例如,如果你获取线上数据,然后在回调中更新状态,react并不会对这些更新进行批处理,而是进行独立的更新。

这是因为react过去只在浏览器时间中进行批处理(比如click),但是我们却在处理事件的回调中更新状态

function 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>
  );
}

Demo: React 17 does NOT batch outside event handlers. (注意在一次点击中两次更新)

直到React18,我们只在React事件处理中进行批处理。我们不会在promise setTimeout native Event Handle中进行批处理。

什么是自动批处理

让我们关注到react18,所有的更新都会被自动批处理,不管它来自哪里。

这意味着,promise native Event Handle 或者任何其他的事件,都会像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 会自动批处理,不管更新来自哪里,如下例

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
}

我们不希望这成为常用方法

这是否会影响hook的功能

如果你正在使用hook 我们期望批处理能正常工作(如果不能请告诉我们)

这是否会影响class 的功能

记住,在React时间处理中的更新总是会被批处理,所以对于这些更新是没有影响的。

这里有一些边界case 能用来讨论一下

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

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

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

在React18 中这不会是问题了,因为即使是在setTimeout 中的更新也会被批处理,React不会同步的渲染第一个setState,这个渲染会出现在浏览器的下一帧中,所以这个渲染还没有更新

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

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

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

案例.

如果这会成为你更新React18的阻碍,你可以使用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 }));
  });
};

案例.

这个问题不回影响具有hook 的函数组件,因为更新状态不会更新useState中现存的变量(猜测是指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)

虽然当你使用hook时,这种行为会惊讶到你,但他为自动批处理铺平了道路。

关于unstable_batchedUpdates

将多次更新进行批处理,一般使用在setTimeout 等在React 18之前不会批处理的函数中

import { unstable_batchedUpdates } from 'react-dom';

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

这个API在React18中仍然存在,但是不再需要他了,因为有自动批处理,我们暂时不回在18版本中移除,但是可能在主流React库不再依赖它之后移除