谈一谈React18的批处理

最近笔者腾出了大把的时间,学习了一下React18。

回首React过往的一些重大版本:

  • React 16.0(2017年9月):带来了全新的Fiber架构,引入了异步渲染提升性能,奠定了并发模式的基础。
  • React 16.8(2019年2月):引入了Hooks,允许函数组件中管理状态和副作用。从此函数组件一发不可收拾。
  • React 17.0(2020年10月):没有API更新,主要是为了未来平滑过渡18.0以及提升稳定性。
  • React 18.0(2022年3月):React18带来了许多新特性,包括Concurrent Mode(并发模式)、Automatic batching(自动批处理)、新的SSR API、新的Suspense、useTransition等等…

这些新特性是在React18正式发布的,但是看源码的话其实16.13的版本里就已经有了并发模式相关代码。只不过那个时候不稳定,处于开发状态。并且在17版本时,还提供了很多实验性的API。

并发模式是一种底层设计,不单单指一个功能,而是一系列小特性的集合。并发模式的开启方式就是将ReactDOM.render改为ReactDOM.createRoot

18新特性很多,我们一个一个去讲。本次主要讲解其中的一个:Automatic batching,即自动批处理。官方解释说自动批处理是一次性能提升,更改了批量更新的方式以自动执行更多批处理。

更多批处理?那在React18之前有批处理吗?现在又做了哪些改进?让我们娓娓道来:

什么是批处理?

批处理就是将一组状态更新合并,只触发一次渲染的性能优化手段。比如在一个点击事件里执行多条状态更新:

function App() {
  console.log('App rendered');
  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>
  );
}

示例中有两个状态更新在handleClick回调里同步执行,如果它们只触发一次渲染,即为批处理。(示例CodeSandbox)。

包括下面的示例,参考的是官方discussion

React18之前的批处理

上面的示例如果在React17上执行的话,是只触发一次渲染的。说明在17版本已经存在了批处理的能力。但是17版本只对事件处理器中的状态更新进行了批处理。而对于setTimeoutPromise、或者是原生dom事件里的状态更新则不会使用批处理。

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

示例中,多加了一个fetch接口并在Promise处理后触发两个状态更新。在17版本上会发现,它触发了两次渲染。(示例CodeSandbox)。

React18的自动批处理

上面示例中运行在React18的并发模式下。不管是未加fetch还是加了fetch的多次状态更新(示例CodeSandbox),运行后都只触发了一次渲染,所以它们的更新都进行了批处理。这就是18版本的自动批处理。

但是要注意的是,如果18版本未开启并发模式。即仍然使用ReactDOM.render作为渲染入口,则使用的是Legacy模式。它的表现会和17版本的一致。(示例CodeSandbox)。

批处理原理

接下来我们直接查看React18源码,并将Legacy模式对应为React17的表现。分析下三个问题:

  1. 为什么Legacy模式下,事件处理器下的更新可以批处理。

  2. 为什么Legacy模式下,Promise或setTimeout或原生dom事件下的更新不可以批处理。

  3. 为什么并发模式下,事件处理器、Promise、setTimeout、原生dom事件下的更新都可以批处理。

我们从触发更新的入口开始查看。如果是类组件的话,会调用this.setState;如果是函数组件,会调用useState返回的dispatch函数。这里看哪个都可以,除了第一步不一样,后续流程都一样。我们直接分析类组件的this.setState入口:

  1. setState方法会执行到classComponentUpdaterenqueueSetState方法。enqueueSetState方法会创建Update,请求lane,然后调用scheduleUpdateOnFiber准备调度更新。
// 简化,去掉不重要的部分
enqueueSetState: function(inst, payload, callback) {
    // ...
    var lane = requestUpdateLane();
    var update = createUpdate(lane);
    // ...
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
    }
    // ...
}
  1. requestUpdateLane方法用于获取当前更新的lane。重点是Legacy模式下一定是SyncLane。并发模式下会读取提前预设的值或根据window.event获取
function requestUpdateLane(fiber) {
  // 非并发模式,一定是SyncLane
  if ((mode & ConcurrentMode) === NoMode) {
    return SyncLane;
  }
  // ...

  // 先从预设的字段读取lane。由react在部分情况(比如click事件前)下提前设置
  var updateLane = getCurrentUpdatePriority();
  if (updateLane !== NoLane) {
    return updateLane;
  }
  // 否则根据window.event判断当前lane
  var eventLane = getCurrentEventPriority();
  return eventLane;
}
  1. scheduleUpdateOnFiber方法会调用ensureRootIsScheduled方法发起调度更新,进到第4步。并在结束后,判断是否要刷新Scheduler的同步callback,进到第5步。
// 简化,去掉不重要的部分
function scheduleUpdateOnFiber(root, fiber, lane) {
    // ...
    ensureRootIsScheduled(root);
    // 同步优先级 && 没有执行上下文 && 非并发模式
    if (lane === SyncLane && executionContext === NoContext && (fiber.mode & ConcurrentMode) === NoMode) {
      // Scheduler执行高优任务
      flushSyncCallbacks();
    }
}
  1. ensureRootIsScheduled方法根据lane判断是否:不需要发起一次新调度;需要取消已调度更新,并发起新调度;不需要取消已调度更新,并发起新调度;发起指定优先级的调度。调度成功后,发起一个微任务处理掉Scheduler高优任务。
function ensureRootIsScheduled(root, currentTime) {
  // ...
  // 获取本次更新的lanes
  var nextLanes = getNextLanes(root, workInProgressRootRenderLanes);
  var newCallbackPriority = getHighestPriorityLane(nextLanes);
  var existingCallbackPriority = root.callbackPriority;
  // 已调度更新的优先级 === 本次更新优先级,不需要再发起一次调度
  if (existingCallbackPriority === newCallbackPriority) {
    return;
  }
  // 打断已调度更新
  if (existingCallbackNode != null) {
    cancelCallback$1(existingCallbackNode);
  }
  // 本次是同步优先级,则在Scheduler那里调度一次高优渲染任务。
  if (newCallbackPriority === SyncLane) {
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  } else {
    // lane转为Scheduler优先级
    switch (lanesToEventPriority(nextLanes)) { /* ... */ }
    // 在Scheduler那里调度一次指定优先级渲染任务
    scheduleCallback$1(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
  }
  // 作为微任务中,处理掉Scheduler中的高优任务
  scheduleMicrotask(function () {
    flushSyncCallbacks();
  });
}
  1. 如果第3步中,lane为同步优先级没有执行上下文非并发模式,就会走到flushSyncCallbacks方法,该方法就是将Scheduler中高优任务执行掉。这个任务就包括我们可能在刚刚调度的渲染任务。

源码看完了,我们接下来调试下代码,再结合代码,找一下3个问题的答案:

  • Legacy模式下,更新的lane值始终是SyncLane。并发模式下,根据更新类型获取lane。

  • ensureRootIsScheduled方法中,如果前面调度了一次更新,后面触发的更新优先级一样,那就直接return掉。

  • 如果更新没有被return掉的话,Legacy模式下,就会去Scheduler那里调度一次高优渲染任务。并发模式下,就会去调度一次指定优先级的渲染任务。

  • scheduleUpdateOnFiber方法里,如果当前没有执行上下文,则高优任务会立刻执行。如果有上下文,则高优任务会作为微任务在同步代码结束后异步执行。

  • 事件处理器里面触发的更新,在执行时是有执行上下文的。但是Promise里面触发的更新没有执行上下文。(这是因为执行上下文是在事件处理器callback回调前设置,在结束后置空。所以异步的Promise里执行上下文就是空的)。

  • 这就解释了问题1的表现,由于有上下文,并且都是同步优先级。所以后面的setState的更新被return掉了,渲染任务会在微任务里执行。实现了批处理。

  • 问题2,由于没有上下文,但是是同步优先级,所以调用一次setState,便立即完成一次渲染。再次调用setState时,又立即完成一次渲染。没有实现批处理。

  • 问题3,并发模式下不会立即执行渲染任务,最快也要等到微任务。所以不管在哪里的多次更新,因为有相同优先级,最后走到return。也就是自动批处理特性。

到这里,我们就非常清楚的了解了批处理的实现原理,以及18版本前后的差异原因。

再来一个复杂的例子,尝试分析下这个情况:

function App() {
  const [count, setCount] = useState(0);
  console.log("App render");

  function plus() {
    setCount(count + 1);
    Promise.resolve().then(() => {
      setCount(count + 2);
    });
  }

  return (
    <div>
      <p>{count}</p>
      {/* 点击会render两次,滑入却只render一次 */}
      <span onClick={plus} onMouseEnter={plus}>
        滑入或点击
      </span>
    </div>
  );
}

示例中,在onClickonMouseEnter回调中都执行了plus()方法,但是结果却完全不一样。有童鞋知道是为什么吗?

直接公布答案:

onMouseEnter回调中触发的更新为InputContinuousLane,而onClick回调中触发的更新为SyncLane。第一次setCount和第二次setCount的lane相同(只是一个是从预设值获取,一个是通过window.event获取)。

由于mouseEnter事件是普通优先级,所以调用第一次setCount后,会调度一次渲染任务,后续宏任务中执行。微任务中的第二次setCount,优先级相同,所以return掉了。最终仅渲染一次。

由于click事件是同步优先级,所以第一次setCount后会在微任务完成一次渲染任务。第二次setCount也是在微任务中,它排在渲染任务后面。所以会先完成渲染,再执行第二次setCount,并再次完成一次渲染。最终渲染两次。

思考题:如果将plus()中的Promise换成setTimeout。两个事件分别会渲染几次?(都渲染两次)。

退出批处理

我们上面分析到问题2的情况,会在调用setState后立即完成一次渲染。是不是说明在setState后获取this.state能取到最新的状态呢?

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

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

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

答案是肯定的,由于是同步执行的渲染,所以this.state也在更新完成后变更了。所以取到的就是最新的state。这是类组件的表现,那函数组件下取到的state会是最新的吗?(当然不可能啊!因为函数组件中的变量是快照啊,也即临时变量,可不管你渲染完成了还是没完成)。

React18并发模式下由于都是批处理,所以同样的代码,在这里的console,就会打印出不一样的结果。即还是原来的state({ count: 0, flag: false })。这也是个问题哈,如果我从React17升级到React18,这里就出现了表现不一致了。

所以React提供了一个新的api:ReactDOM.flushSync,这个方法可以让某个状态更新立即完成渲染。所以代码变为:

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

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

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值