漫谈 React 系列(四): React18 自己的防抖节流 - useTransition

6,045 阅读15分钟

前言

在之前的 漫谈 react 系列(三): 三层 loop 弄懂 Concurrent 模式 一文中,我们有讲到在 React18 中,Concurrent 模式是否开启,需要根据触发更新的上下文来决定。如果更新是在 eventsetTimeoutnetwork requestcallback 中触发,react 会采用 Legacy 模式,而如果更新与 SuspenseuseTransitionOffScreen 相关,那么 react 会采用 Concurrent 模式。

其中,useTransitionReact18 提供的新的 API。使用 useTransition,更新的协调过程会采用可中断Concurrent 模式,能给我们带来用户体验上的提升。

今天我们就借着本文和大家一起聊一下 useTransition

本文的目录结构如下:

防抖、节流 VS useTransition

在日常开发中,相信大家都使用过带 search 功能的 Select 组件。这种 Select 组件会根据输入值的变化显示满足条件的 Option

当满足条件的 Option 数量较少时,Select 组件的交互会很流畅,能快速显示结果给用户。但是当 Option 的数量很大时,那么整个交互效果就不容乐观了。

我们通过两个简单的 demo 来给大家模拟一下上述场景。

Option 数量较少:

Nov-21-2021 11-59-16.gif

在上面的 demo 中,我们定义了 100div,来模拟满足条件的 Option 数量较少的情形。如图,整个交互过程非常流畅。随着用户的输入,对应的结果可以及时呈现给用户。

但是当 Option 数量较多时,情况就不容乐观了。

Option 数量较多

Nov-21-2021 13-52-57.gif

在该 demo 中,我们定义了 50000div,来模拟满足条件的 Option 数量较多的情形。对比上一个 demo,整个交互过程非常糟糕。用户在连续输入时,整个页面会陷入卡死的状态,只有在用户停止输入以后,才能看到输入内容以及对应的结果列表。

我们先通过该 domoperformance 分析图,来看一下为什么页面会卡死:

more.png

performance 分析图,我们可以看到,造成页面卡死的原因是浏览器主线程一直被 react 更新过程浏览器布局过程占用,导致一直无法完成图层绘制,给用户造成了页面卡死的影响。

这个非常好理解,多达 50000 个节点,这不仅需要在更新协调时花费大量的时间来 diff,还要在渲染过程中花费大量的时间来做布局,导致浏览器无法快速完成渲染工作,用户体验极差。

针对这个 demo,我们做稍许改造,不让它在浏览器渲染过程中处理那么多节点 - less-dom:

Nov-26-2021 19-58-22.gif

less-dom 中,只有 100 多个节点需要处理,但整个体验依旧很差。当用户连续输入时,依旧会出现页面卡死的情况。

我们再来看一下对应的 performance 分析图:

less.png

performance 分析图中,我们可以看到这一次页面卡死的原因是由于更新时有大量的节点需要协调,使得 js 引擎长时间占据浏览器主线程,导致渲染引擎一直无法工作。

面对该种情形,我们通常会采取 防抖 - debounce节流 - throttle 的方式来进行优化。

防抖 - debounce、节流 - throttle

我们先使用 防抖 - debounce 来进行优化。

Nov-26-2021 20-23-27.gif

观察 demo,我们可以发现使用 防抖 - debounce 以后,交互有明显改善,没有再出现页面卡死的情况。防抖 - debounce,使得连续输入触发的更新,只在最后一次输入发生时才真正开始处理。在这之前,浏览器的渲染引擎没有被阻塞,用户可以及时看到输入的内容。

防抖 - debounce,本质上是延迟react 更新操作。该方式存在一些不足:

  • 会出现用户输入长时间得不到响应的情况;
  • 更新操作正式开始以后,渲染引擎仍然会被长时间阻塞,依旧会存在页面卡死的情况;

针对用户输入长时间得不到响应的情况,通常我们会使用 节流 - throttle 来进行优化。

Nov-26-2021 20-30-09.gif

通过节流 - throttle 优化以后,用户不仅可以及时看到输入内容,还可以比较快速的看到对应的结果列表,整个交互效果有了极大的改善。

但使用节流 - throttle,依然会存在问题:

  • 到达指定时间后,更新开始处理,渲染引擎会被长时间阻塞,页面交互会出现卡顿
  • throttle 的最佳时间,一般是根据开发人员所使用的设备来配置的。而这个配置好的时间,针对某些设备比较差的用户来说,并不一定能起作用,无法起到优化的效果。

综上,尽管使用防抖节流,能在一定程度上改善交互效果,但是治标不治本,依旧会出现页面卡顿甚至卡死的情况。究其原因,是因为示例中 react 更新时 fiber tree 协调花费时间过长且不可中断,导致 js 引擎长时间占据浏览器主线程,使得渲染引擎被长时间阻塞。

针对 fiber tree 协调花费较长时间导致渲染引擎被阻塞的问题,React18 新的 api - useTransition 为我们提供了新的解决方法。

useTransition

使用 useTransition 时,react 会以 Concurrent 模式来协调 fiber treeConcurrent 模式下,协调过程是并行可中断的,渲染进程不会长时间被阻塞,使得用户操作可以及时得到响应,极大提升了用户体验。

useTransiton 的使用如下:

Nov-26-2021 20-40-14.gif

在上面的 demo 中,我们可以发现使用 useTransition 以后,整个交互效果有了提升:

  • 用户可以及时看到输入内容,交互也很流畅;
  • 用户连续输入时,不会一直得不到响应(最迟 5s 必会开始更新渲染列表);
  • 开始更新渲染后,整个协调过程是可中断的,不会长时间阻塞渲染引擎

最后,我们来做一个简单的总结。对比防抖 - debounce节流 - throttleuseTransition 有如下优势:

  • 更新协调过程是可中断的,渲染引擎不会长时间被阻塞,用户可以及时得到响应;
  • 不需要开发人员去做额外的考虑,整个优化过程交给 react浏览器即可;

至于 useTransition 为什么可以实现类似 debouncethrotte 效果,我们将会在第三节 - 原理解析 中进行详细说明。

使用 useTransition

在这一节,我们来了解一下 transition 相关的两个 api - useTransition hookstartTransition。其中, useTransition hook 用于函数组件startTransition 用于类组件(当然也可直接用于函数组件)。

useTransition

useTransition 的具体用法如下:

Nov-27-2021 21-28-44.gif

useTransition hook 执行时会返回一个 isPending 变量和 startTransition 方法。当通过 startTransiton(() => setState(xxx)) 的方式触发更新时, react 就会采用 Concurrent 模式来协调 fiber tree

在前面 漫谈 react 系列(三): 三层 loop 弄懂 Concurrent 模式 - react 任务的优先级 一文中,我们已经知道每次通过 setState 触发更新时,react 都会为更新安排一个 task。触发更新的上下文不同,导致生成的 task优先级不同,相应的 task处理顺序也不相同。

当通过 startTransition 的方式触发更新时,更新对应的优先级等级为 NormalPriority。而在 NormalPriority 之上,还存在 ImmediatePriorityUserBlockingPriority 这两种级别更高的更新。通常,高优先级的更新会优先级处理,这就使得尽管 transition 更新先触发,但并不会在第一时间处理,而是处于 pending - 等待状态。只有没有比 transition 更新优先级更高的更新存在时,它才会被处理。

针对这种情况,我们可以使用 useTransition 返回的 isPending 来展示 transition 更新的状态。当 isPendingture 时,表示 transition 更新还未被处理,此时我们可以显示一个中间状态来优化用户体验;当 isPendingfalse 时, 表示 transition 更新被处理,此时就可以显示实际需要的内容。

useTransition 作为一个 hook,是无法在类组件中使用的。为了能在类组件中也可以触发 transition 更新,React18 提供了另一个新的 API - startTransition

直接使用 startTransition

直接使用 startTransiton 的用法如下:

Nov-27-2021 22-25-51.gif

startTransition 给我们提供了一种直接触发 transition 更新的方式,不管是在函数组件还是类组件中,都可以使用。

不足的是,直接使用 startTransition,无法像 useTransition 一样提供 isPending 来展示 transition 更新的状态变化。

另外,我们还发现一个有趣的现象。当我们进行连续快速输入时,使用 startTransition 是无法触发类似节流的效果的。关于这一点的原因,我们也会在第三节进行解释。

原理解析

知道了 useTransition 和 startTransition 这两个新的 API 的用法以后,我们再来了解一下 transition 更新涉及的一些内部原理。

在本节,我们将会针对下列问题为大家一一分析:

  • isPending 的是如何工作的?
  • useTransition 是如何实现类似 debounce 效果的?
  • useTransition 是如何实现类似 throtte 效果的?
  • 使用 useTransition 和直接使用 startTransition 有什么区别?
  • transition 更新是一个怎样的处理过程?

isPending 是如何工作的

当我们使用 useTransition 返回的 isPendingstartTransition 时,页面会先显示 pending 中间状态,然后再显示 transition 更新的结果。

很明显,这里发生了两次 react 更新。但回头查看代码,我们却发现只在 startTransition 中调用了一次 setState,这意味着只应该触发一次 react 更新。 那这是怎么回事呢?

首先要强调一点,既然发生了两次 react 更新,那么肯定是有某两个地方触发了更新。一次是我们在 startTransitioncallback 中我们自己通过 setState 触发更新,那另一次是在哪里触发的呢?

答案是在调用 startTransition 方法时,react 内部通过 setState 触发了另外一次更新。

我们先来看一下 react 内部的源码实现:

// useTransition hook
useTransition: function () {
    ...
    return mountTransition();
}

function mountTransition() {
  var _mountState2 = mountState(false),  // 相当于 const [isPending, setPending] = useState(false);
      isPending = _mountState2[0],
      setPending = _mountState2[1];

  var start = startTransition.bind(null, setPending);
  var hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [isPending, start];  // 我们在组件中使用的isPending、startTransition
}

function startTransition(setPending, callback) {
  ...
  setPending(true);    // 第一次调用 setState
  var prevTransition = ReactCurrentBatchConfig$2.transition;
  ReactCurrentBatchConfig$2.transition = 1;
  try {
    setPending(false);  // 第二次调用 setState
    callback();  // 第三次调用 setState, callback 中会触发 setState 
  } finally {
    ...
  }
}

当我们调用 useTransition hook 时, react 内部会通过 useState hook 定义 isPendingsetPending,这个 isPending 就是 useTransition 返回的 isPending

当调用 startTransition 时,会先通过 setPendingisPending 改为 true,然后再通过 setPendingisPening 改为 false,并在 callback 中触发我们自己定义的更新。在这里,有些同学可能就有疑问了,这里不是连续调用了三次 setState 吗,为什么会触发两次 React 更新吗?不是应该只触发一次吗?(react 批处理机制)

要想解释这个问题,就需要特别注意一行代码 - ReactCurrentBatchConfig$2.transition = 1。这一句代码会将更新的上下文改为 transition,使得 setPending(true)setPending(false)callback() 的上下文不一样。

在这里,我们通过一张图来为大家梳理一下 isPending 涉及的过程:

ispending.png

关于 workLooplane 的说明,详见 漫谈 react 系列(三): 三层 loop 弄懂 Concurrent 模式

从上图中我们可以看到,setPending(true) 上下文为 DiscreteEvent,而 setPending(false)、callback() 的上下文为 Transition。尽管连续三次 setState,但由于存在两类不同的上下文,导致实际需要两次更新。

setPending(true) 对应的更新会先处理,经过 fiber tree 协调、effect 处理、浏览器渲染,我们就可以在页面中先看到中间状态setPending(false)callback 对应的更新后处理,经过同样的过程以后,我们就可以在页面中看到实际需要的结果了。

(isPending 如何工作问题解决,✌🏻)。

useTransition 实现类似 debounce 效果

useTransition 示例中,当我们快速连续输入时,会出现类似 防抖 - debounce 的效果,如下所示:

Nov-29-2021 18-29-25.gif

连续输入,页面仅显示中间状态,最终的结果列表会在我们停止输入时才显示。整个过程看起来就像是连续输入发生防抖 - debounce,然后在最后一次 onchange 事件中触发 react 更新。

那是不是 useTransition 也采用相同的逻辑呢?

回头看上一节 useTransition 的实现源码, 我们并没有发现和 debounce 相关的代码,那 useTransition 是如何实现类似 debounce 效果呢?

答案是:高优先级更新会中断低优先级更新,优先处理。

startTransition 方法执行过程中, setPending(true) 触发的更新优先级较,而setPending(false)callback 触发的更新优先级较。当 callback 触发的更新进入协调阶段以后,由于协调过程可中断,并且用户一直在输入导致一直触发 setPending(true),使得 callback 触发的更新一直被中断,直到用户停止输入以后才能被完整处理。

整个过程,我们可以通过 performance 分析图来一探究竟。

debounce.png

这是示例 useTransition 连续输入的 performance 分析图。在图中,我们可以清楚的看到确实只发生了一次长列表的渲染。我们将左侧连续输入的更新过程放大,如下:

debounce-1.png

在这一幅图中,我们可以清楚的看到 callback 更新的整个协调过程是可中断的,会被连续输入触发的 setPending(true) 更新中断。我们再将这副图的中间部分放大,如下:

debounce - 2.png

在这幅图中, setPending(true) 更新中断 callback 更新一目了然吧。

综上,通过三幅 performance 分析图,我们可以发现 useTransition 实现类似 debounce 效果的秘密就是:高优先级更新中断低优先级更新,使得低优先级更新延迟处理

(useTransition 实现类似 debounce 效果的问题解决,✌🏻)。

useTransition 实现类似 throtte 效果

示例 useTransition 中,如果你一直输入,最多 5s,长列表必然会渲染,和 节流 - throtte 效果一样。

这是因为为了防止低优先级更新一直被高优先级更新中断而得不到处理,react 为每种类型的更新定义了最迟必须处理时间 - timeout。如果在 timeout 时间内更新未被处理,那么更新的优先级就会被提升到最高 - ImmediatePriority,优先处理。

transiton 更新的优先级为 NormalPrioritytimeout5000ms5s。如果超过 5s, transition 更新还因为一直被高优先级更新中断而没有处理,它的优先级就会被提升为 ImmediatePriority,优先处理。这样就实现了 throttle 的效果。

(useTransition 实现类似 throtte 效果的问题解决,✌🏻)

使用 useTransition 和直接使用 startTransition 的区别

在示例 startTransitonuseTransition 中,我们会发现一个有趣的现象: 用户连续输入时,使用 useTransition 会出现 debounce 的效果,而直接使用 startTransition 则不会。

为什么会这样呢?

我们先来看一下 startTransition 的源码实现:

function startTransition(scope) {
    var prevTransition = ReactCurrentBatchConfig.transition;
    ReactCurrentBatchConfig.transition = 1;  // 修改更新上下文
    try {
      scope();   // 触发更新
    } finally {
      ...
    }
}

对比 useTransitionstartTransition, 我们会发现 startTransition 中少了 setPending(true) 的过程。而正是这缺失的 setPending(true), 导致直接使用 startTransition 不会出现 debounce 效果。

在这里,有一个关键点需要声明。那就是 Concurrent 模式下,当低优先级更新高优先级更新中断时,低优先级更新已经开始的协调会被清理,低优先级更新会被重置未开始状态。

使用 useTransition 时,transition 更新会一直被连续的 setPending(true) 中断,每次中断时都会被重置未开始状态,导致 transition 更新只有在用户停止输入(或者超过 5s)时才能得到有效处理,也就出现了类似 debounce 的效果。

而直接使用 startTransition 时, 尽管协调过程会每隔 5ms 中断一次,但由于没有 setPending(true) 的中断, 连续的输入并不会重置 transition 更新。当 transition 更新结束协调时,自然而然的就会开始浏览器渲染过程,不会出现类似 debounce 的效果。

(使用 useTransition 和直接使用 startTransition 的区别解决,✌🏻)。

transition 更新的处理过程

最后,结合上面四个问题的答疑,我们来总结一下 transition 更新的处理过程:

transition.png

想必通过这张图,大家能对 transition 更新的工作过程能有一个比较清晰的认识了吧。

(transition 更新处理过程的问题解决,✌🏻)。

写在最后

到这里,本文就结束了,最后我们来一起做一个小结,看看通过本文大家会有哪些收获:

  • React18 提供了新的 api - useTransitionstartTransition,来帮助我们实现更新协调可中断,能极大的提升用户体验;
  • transition 更新可被高优先级更新中断,中断后会被重置为未开始状态
  • transition 更新被中断后,最迟 5s 必会处理;
  • 函数组件中使用 useTransition,在类组件中可以直接使用 startTransition
  • 不同的更新上下文,导致更新协调的模式不同;

传送门

参考文档