乐趣区

关于前端:原理篇你真的了解-React18-的并发吗

前言

前阵子,打磨已久的 React18 终于正式公布,其中最重要的一个更新就是 并发(concurrency)。其余的新个性如SuspenseuseTransitionuseDeferredValue 的外部原理都是基于并发的,可想而知在这次更新中并发的重要性。

然而,并发到底是什么?React 团队引入并发又是为了解决哪些问题呢?它到底是如何去解决的呢?后面提到的 React18 新个性与并发之间又有什么关系呢?

置信大家在看官网文档或者看其他人形容 React 新个性时,或多或少可能会对以上几个问题产生疑难。因而,本文将通过分享 并发更新 的整体实现思路,来帮忙大家更好地了解 React18 这次更新的内容。

什么是并发

首先咱们来看一下 并发 的概念:

并发,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行结束之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

举个艰深的例子来讲就是:

  • 你吃饭吃到一半,电话来了,你始终到吃完了当前才去接,这就阐明你不反对并发也不反对并行。
  • 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后持续吃饭,这阐明你反对并发。
  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这阐明你反对并行。

并发的要害是具备解决多个工作的能力,但不是在同一时刻解决,而是交替解决多个工作。比方吃饭到一半,开始打电话,打电话到一半发现信号不好挂断了,持续吃饭,又来电话了…然而每次只会解决一个工作。

在理解了并发的概念后,咱们当初思考下,在 React 中并发指的是什么,它有什么作用呢?

React 为什么须要并发

咱们都晓得,js 是单线程语言,同一时间只能执行一件事件。这样就会导致一个问题,如果有一个耗时工作占据了线程,那么后续的执行内容都会被阻塞。比方上面这个例子:

<button id="btn" onclick="handle()"> 点击按钮 </button>

<script> // 用户点击事件回调
  function handle() {console.log('click 事件触发')
  }

  // 耗时工作,始终占用线程,阻塞了后续的用户行为
  function render() {for (let i = 0; i < 10 ** 5; i++) {console.log(i)
    }
  }
  window.onload = function () {render()
  } </script> 

当咱们点击按钮时,因为 render 函数始终在执行,所以 handle 回调迟迟没有执行。对于用户来讲,界面是卡死且无奈交互的。

如果咱们把这个例子中的 render 函数类比成 React 的更新过程:即 setState 触发了一次更新,而这次更新耗时十分久,比方200ms。那么在这200ms 的工夫内界面是卡死的,用户无奈进行交互,十分影响用户的应用体验。如下图所示,200ms内浏览器的渲染被阻塞,且用户的 click 事件回调也被阻塞。

那咱们该如何解决这个问题呢?React18 给出的答案就是:并发

咱们能够将 react 更新 看作一个工作,click事件看作一个工作。在并发的状况下,react 更新到一半的时候,进来了 click 工作,这个时候先去执行 click 工作。等 click 工作执行实现后,接着继续执行残余的 react 更新。这样就保障了即便在耗时更新的状况下,用户仍旧是能够进行交互的(interactive)。

尽管这个想法看上去十分不错,然而实现起来就有点艰难了。比方更新到一半时怎么中断?更新中断了又怎么复原呢?如果 click 又触发了 react 更新不就同时存在了两个更新了吗,它们的状态怎么辨别?等等各种问题。

尽管很艰难,但 React18 的确做到了这一点:

Concurrency is not a feature, per se. It’s a new behind-the-scenes mechanism that enables React to prepare multiple versions of your UI at the same time.

正如官网中形容的:并发是一种新的幕后机制,它容许在同一时间里,筹备多个版本的 UI,即多个版本的更新,也就是后面咱们提到的并发。上面咱们将逐渐理解 React 是怎么实现并发的。

浏览器的一帧里做了什么?

首先,咱们须要理解一个前置知识点——window.requestIdleCallback。它的性能如下:

window.requestIdleCallback() 办法插入一个函数,这个函数将在浏览器闲暇期间被调用。

网上有许多文章在聊到 React 的调度(schedule)和工夫切片(time slicing)的时候都提到了这个 api。那么这个 api 到底有什么作用呢?浏览器的闲暇工夫又是指的什么呢?

带着这个疑难,咱们看看浏览器里的一帧产生了什么。咱们晓得,通常状况下,浏览器的一帧为 16.7ms。因为 js 是单线程,那么它外部的一些事件,比方 click 事件,宏工作,微工作,requestAnimatinFrame,requestIdleCallback 等等都会在浏览器帧里按肯定的程序去执行。具体的执行程序如下:

咱们能够发现,浏览器一帧里回调的执行程序为:

  1. 用户事件:最先执行,比方 click 等事件。
  2. js 代码:宏工作和微工作,这段时间里能够执行多个宏工作,然而必须把微工作队列执行实现。宏工作会被浏览器主动调控。比方浏览器如果感觉宏工作执行工夫太久,它会将下一个宏任务分配到下一帧中,防止掉帧。
  3. 在渲染前执行 scroll/resize 等事件回调。
  4. 在渲染前执行 requestAnimationFrame 回调。
  5. 渲染界面:面试中常常提到的浏览器渲染时 html、css 的计算布局绘制等都是在这里实现。
  6. requestIdleCallback 执行回调:如果后面的那些工作执行实现了,一帧还剩余时间,那么会调用该函数。

从下面能够晓得,requestIdleCallback 示意的是浏览器里每一帧里在确保其余工作实现时,还剩余时间,那么就会执行 requestIdleCallback 回调。比方其余工作执行了 10ms,那么这一帧里就还剩 6.7ms 的工夫,那么就会触发 requestIdleCallback 的回调。

理解了这个办法后,咱们能够做一个假如:如果咱们把 React 的更新(如 200ms)拆分成一个个小的更新(如 40 个 5ms 的更新),而后每个小更新放到 requestIdleCallback 中执行。那么就意味着这些小更新会在浏览器每一帧的闲暇工夫去执行。如果一帧里有多余工夫就执行,没有多余工夫就推到下一帧继续执行。这样的话,更新始终在持续,并且同时还能确保每一帧里的事件如 click,宏工作,微工作,渲染等可能失常执行,也就能够达到用户可交互的目标。

然而,requestIdleCallback 的兼容性太差了:

因而,React 团队决定本人实现一个相似的性能:工夫切片(time slicing)。接下来咱们看看工夫切片是如何实现的。

工夫切片

如果 React 一个更新须要耗时 200ms,咱们能够将其拆分为 40 个 5ms 的更新(后续会讲到如何拆分),而后每一帧里只花 5ms 来执行更新。那么,每一帧里不就残余 16.7 – 5 = 11.7ms 的工夫能够进行用户事件,渲染等其余的 js 操作吗?如下所示:

那么这里就有两个问题:

  • 问题 1:如何管制每一帧只执行 5ms 的更新?
  • 问题 2:如何管制 40 个更新调配到每一帧里?

对于问题 1 比拟容易,咱们能够在更新开始时记录 startTime,而后每执行一小段时间判断是否超过 5ms。如果超过了 5ms 就不再执行,等下一帧再继续执行。

对于问题 2,咱们能够通过宏工作实现。比方 5ms 的更新完结了,那么咱们能够为下一个 5ms 更新开启一个宏工作。浏览器则会将这个宏任务分配到以后帧或者是下一帧执行。

留神:
浏览器这一行为是内置的,比方设置 10000setTimeout(fn, 0),并不会阻塞线程,而是浏览器会将这 10000 个回调正当调配到每一帧当中去执行。
比方:10000setTimeout(fn, 0) 在执行时,第一帧里可能执行了 300 个 setTimeout 回调,第二帧里可能执行了 400 个 setTimeout 回调,第 n 帧里可能执行了 200 个回调。浏览器为了尽量保障不掉帧,会正当将这些宏任务分配到帧当中去。

解决了下面两个问题,那么这个时候咱们就有上面这种思路了:

  1. 更新开始,记录开始工夫 startTime。
  2. js 代码执行时,记录间隔开始工夫 startTime 是否超过了 5ms。
  3. 如果超过了 5ms,那么这个时候就不应该再以同步的模式来执行代码了,否则仍然会阻塞后续的代码执行。
  4. 所以这个时候咱们须要把后续的更新改为一个宏工作,这样浏览器就会调配给他执行的机会。如果有用户事件进来,那么会执行用户事件,等用户事件执行实现后,再继续执行宏工作中的更新。

如上图所示,因为更新拆分成了一个个小的宏工作,从而使得 click 事件的回调有机会执行。

当初咱们曾经解决了更新阻塞的问题,接下来就须要解决如何将一个残缺的更新拆分为多个更新,并且让它能够暂停等到 click 事件实现后再回来更新。

Fiber 架构

React 传统的 Reconciler 是通过相似于虚构 DOM 的形式来进行比照和标记更新。而虚构 DOM 的构造不能很好满足将更新拆分的需要。因为它一旦暂停比照过程,下次更新时,很难找到上一个节点和下一个节点的信息,尽管有方法能找到,然而相对而言比拟麻烦。所以,React 团队引入了 Fiber 来解决这一问题。

每一个 DOM 节点对应一个 Fiber 对象,DOM 树对应的 Fiber 构造如下:

Fiber 通过链表的模式来记录节点之间的关系,它与传统的虚构 DOM 最大的区别是多加了几个属性:

  • return 示意父节点 fiber。
  • child 示意子节点的第一个 fiber。
  • sibling 示意下一个兄弟节点的 fiber。

通过这种链表的模式,能够很轻松的找到每一个节点的下一个节点或上一个节点。那么这个个性有什么作用呢?

联合下面提到的工夫切片的思路,咱们须要判断更新是否超过了 5ms,咱们以下面这棵 Fiber 树梳理一下更新的思路。从 App Fiber 开始:

  • 浏览器第一帧:

    • 记录更新开始工夫 startTime。
    • 首先计算 App 节点,计算实现时,发现更新未超过 5ms,持续更新下一个节点。
    • 计算 div 节点,计算实现时,发现更新超过了 5ms,那么不会进行更新,而是开启一个宏工作。
  • 浏览器第二帧:

    • 上一帧最初更新的是 div 节点,找到下一个节点 i am,计算该节点,发现更新未超过 5ms,持续更新下一个节点。
    • 计算 span 节点,发现更新超过了 5ms,那么不会进行更新,而是开启一个宏工作。
  • 浏览器第三帧:

    • 上一帧最初更新的是 span 节点,找到下一个节点 KaSong,计算该节点,更新实现。

留神:

  1. 理论的更新过程是 beginWork / completeWork 递与归的阶段,与这里有出入,这里仅做演示介绍。
  2. 这里的更新过程有可能不是第二帧和第三帧,而是在一帧里执行实现,具体须要看浏览器如何去调配宏工作。
  3. 更新过程分为 reconciler 和 commit 阶段,这里只会将 reconciler 阶段拆分。而 commit 阶段是映射为实在 DOM,无奈拆分。

对应浏览器中的执行过程如下:

在这个过程中,每个节点计算实现后都会去校验更新工夫是否超过了5ms,而后找到下一个节点持续计算,而双向链表恰好是切合这种需要。

小结

通过下面的剖析,咱们能够总结成以下思路:

  1. 更新时遍历更新每一个节点,每更新一个 Fiber 节点后,会判断累计更新工夫是否超过 5ms。
  2. 如果超过 5ms,将下一个更新创立为一个宏工作,浏览器主动为其调配执行机会,从而不阻塞用户事件等操作。
  3. 如果更新的过程中,用户进行触发了点击事件,那么会在 5ms 与下一个 5ms 的间隙中去执行 click 事件回调。

通过以上步骤,咱们可能将现有的 同步更新转变为多个小更新调配到浏览器帧里,并且不会阻塞用户事件。接下来看看在 React 中理论是如何做到的。

Scheduler 调度

在 React 中,有一个独自的 Scheduler 库专门用于解决下面探讨的工夫切片。

咱们简略看一下 Scheduler 要害源码实现:

  1. 首先,在 packagegs/react-reconciler/src/ReactFiberWorkLoop.new.js 文件中:
// 循环更新 fiber 节点
function workLoopConcurrent() {while (workInProgress !== null && !shouldYield()) {
    // 更新单个 fiber 节点
    performUnitOfWork(workInProgress);
  }
} 

在更新时,如果是 Concurrent 模式,低优先级更新会进入到 workLoopConcurrent 函数。该函数的作用就是遍历 Fiber 节点,创立 Fiber 树并标记哪些 Fiber 被更新了。performUnitOfWork 示意的是对每个 Fiber 节点的解决操作,每次解决前都会执行 shouldYield()办法,上面看一下 shouldYield。

  1. 其次,在 packages/scheduler/src/forks/Scheduler.js 文件中:
export const frameYieldMs = 5;
let frameInterval = frameYieldMs;

function shouldYieldToHost() {const timeElapsed = getCurrentTime() - startTime;
  // 判断工夫距离是否小于 5ms
  if (timeElapsed < frameInterval) {return false;}
  ...
} 

shouldYield()办法会去判断累计更新的工夫是否超过 5ms。

  1. 最初,在 packages/scheduler/src/forks/Scheduler.js 文件中:
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {schedulePerformWorkUntilDeadline = () => {localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {port.postMessage(null);
  };
} else {schedulePerformWorkUntilDeadline = () => {localSetTimeout(performWorkUntilDeadline, 0);
  };
} 

如果超过了 5ms,就会通过 schedulePerformWorkUntilDeadline 开启一个宏工作进行下一个更新。这里 react 做了兼容的解决,实际上是优先应用 MessageChannel 而不是 setTimeout,这是因为在浏览器帧中 MessageChannel 更优先于 setTimeout 执行。

总的来说,Scheduler 库的解决和后面探讨的工夫切片相似。事实上,浏览器也正在做同样的 Scheduler 库做的事件:通过内置一个 api——scheduler.postTask 来解决用户交互在某些状况下无奈即时相应的问题,有趣味的话能够看看相干内容。

最终,通过这种工夫切片的形式,在浏览器下的 performance 面板中,会呈现出如下渲染过程:本来一个耗时的更新(如渲染 10000 个 li 标签),被宰割为一个个 5ms 的小更新:

到这里,咱们曾经分明了如何让一个耗时的更新不去阻塞用户事件和渲染了。然而这只是有一个更新工作的状况,如果在 React 更新一半时,click 事件进来,而后执行 click 事件回调,并且触发了新的更新,那么该如何解决共存的两个更新呢?如果 click 事件的更新过程中,又有其余的 click 事件触发更新呢?这就波及到多个更新并存的状况,这也是咱们接下来须要探讨的点。

更新优先级

在 React 中,更新分为两种,紧急更新和过渡更新:

  • 紧急更新(Urgent updates):用户交互等,比方点击,输出,按键等等,因为间接影响到用户的应用体验,属于紧急情况。
  • 过渡更新(Transition updates):如从一个界面过渡到另一个界面,属于非紧急情况。

对于用户体验来讲,紧急更新应该是优先于非紧急更新的。例如用 input 搜寻时,咱们应该确保用户输出的内容是可能是实时响应的,而依据输出值搜寻进去的内容在渲染更新的时候不应该阻塞用户的输出。

这里就回到了下面提到的多更新并存的问题:哪些更新优先级高,哪些更新优先级低,哪些更新须要立刻去执行,哪些更新能够缓一缓再执行。

为了解决这个问题,React 为通过 lane 的形式每个更新调配了相干优先级。lane 能够简略了解为一些数字,数值越小,表明优先级越高。然而为了计算不便,采纳二进制的模式来示意。比方咱们在判断一个状态的更新是否属于以后更新时,只须要判断 updateLanes & renderLanes 即可。

在 react-reconciler/src/ReactFiberLane.new.js 文件中,外面一共展现了 32 条 lane:

export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

// 同步
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001;

// 间断事件
export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lanes = /*            */ 0b0000000000000000000000000000100;

// 默认
export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000001000;
export const DefaultLane: Lanes = /*                    */ 0b0000000000000000000000000010000;

// 过渡
const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000000100000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111111000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000001000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000001000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane16: Lane = /*                       */ 0b0000000001000000000000000000000;

// 重试
const RetryLanes: Lanes = /*                            */ 0b0000111110000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000010000000000000000000000000;
const RetryLane5: Lane = /*                             */ 0b0000100000000000000000000000000;

export const SomeRetryLane: Lane = RetryLane1;

export const SelectiveHydrationLane: Lane = /*          */ 0b0001000000000000000000000000000;

const NonIdleLanes = /*                                 */ 0b0001111111111111111111111111111;

export const IdleHydrationLane: Lane = /*               */ 0b0010000000000000000000000000000;
export const IdleLane: Lanes = /*                       */ 0b0100000000000000000000000000000;

// 离屏
export const OffscreenLane: Lane = /*                   */ 0b1000000000000000000000000000000; 

不同的 lane 示意不同的更新优先级。比方用户事件比拟紧急,那么能够对应比拟高的优先级如 SyncLane;UI 界面过渡的更新不那么紧急,能够对应比拟低的优先级如 TransitionLane;网络加载的更新也不那么紧急,能够对应低优先级 RetryLane,等等。

通过这种优先级,咱们就能判断哪些更新优先执行,哪些更新会被中断滞后执行了。举个例子来讲:如果有两个更新,他们同时对 App 组件的一个 count 属性更新:

<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
  A 按钮
</button>
<button onClick={() => startTransition(() => {setCount(count + 1) })}>
  B 按钮
</button> 
  • 一个是 A 按钮:click 事件触发的更新,叫做 A 更新,对应于 SyncLane。
  • 一个是 B 按钮:startTransition 触发的更新,叫做 B 更新,对应于 TransitionLane1。

假如 B 按钮先点击,B 更新开始,依照之前提到工夫切片的模式进行更新。中途触发了 A 按钮点击,进而触发 A 更新。那么此时就会通过 lane 进行比照,发现 DefaultLane 优先级高于 TransitionLane1。此时会中断 B 更新,开始 A 更新。直到 A 更新实现时,再从新开始 B 更新。

那么 React 是如何辨别 B 更新对 App 的 count 的更改和 A 更新中对 count 的更改呢?

实际上,在每次更新时,更新 state 的操作会被创立为一个 Update,放到循环链表当中:

export function createUpdate(eventTime: number, lane: Lane): Update<*> {
  const update: Update<*> = {
    eventTime,
    lane,

    tag: UpdateState,
    payload: null,
    callback: null,

    next: null,
  };
  return update;
} 

在更新的时候就会顺次去执行这个链表上的操作,从而计算出最终的 state。

从 Update 的定义能够留神到,每个 Update 里都有一个 lane 属性。该属性标识了以后的这个 Update 的更新优先级,属于哪个更新工作中的操作。

因而当 A 更新在执行的时候,咱们在计算 state 的时候,只须要去计算与 A 更新雷同 lane 的 update 即可。同样,B 更新开始,也只更新具备等同 lane 级别的 Update,从而达到不同更新的状态互不烦扰的成果。

React18 并发渲染

回顾一下后面探讨的 React 并发渲染:

  1. 为什么须要并发?
  • 因为咱们冀望一些不重要的更新不会影响用户的操作,比方长列表渲染不会阻塞用户 input 输出,从而晋升用户体验。
  1. 并发模式是怎么的?
  • 在多个更新并存的状况下,咱们须要依据更新优先级,优先执行紧急的更新,其次再执行不那么紧急的更新。比方优先响应 click 事件触发的更新,其次再响应长列表渲染的更新。
  1. 并发模式是如何实现的?
  • 对于每个更新,为其调配一个优先级 lane,用于辨别其紧急水平。
  • 通过 Fiber 构造将不紧急的更新拆分成多段更新,并通过宏工作的形式将其正当调配到浏览器的帧当中。这样就能使得紧急任务可能插入进来。
  • 高优先级的更新会打断低优先级的更新,等高优先级更新实现后,再开始低优先级更新。

新个性

接下来看看 React18 局部并发相干的新 api。

Suspense

在 v16/v17 中,Suspense 次要是配合 React.lazy 进行 code spliting。在 v18 中,Suspense 退出了 fallback 属性,用于将读取数据和指定加载状态拆散。那么这种拆散有什么益处呢?

举一个例子:

function List({pageId}) {const [data, setData] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  
  useEffect(() => {setIsLoading(true)
    fetchData(pageId).then((data) => {setData(data)
      setIsLoading(false)
    })
  }, [])
  
  if (isLoading) {return <Spinner />}
  
  return data[pageId].map(item => <li>{item}</li>)
} 

这是咱们最常见的解决异步数据的形式。尽管看上去还能承受,但实际上会有一些问题:

  1. 存储了两套数据 isLoading/data 和两种渲染后果,并且代码比拟冗余,不利于开发保护。如果用 Suspense,能够间接读取数据而不关怀加载状态,如:
const wrappedData = unstable_createResource((pageId) => fetchData(pageId))

function List({pageId}) {const data = wrappedData.read(pageId)
  
  return data[pageId].map(item => <li>{item}</li>)
}

// 在须要应用 List 组件的中央包裹一层  Suspense 即可自动控制加载抓昂太
<Suspense fallback={<div>Loading...</div>}>
  <List />
</Suspense> 

能够看出应用 Suspense 后代码变得简洁清晰易懂,对于开发效率和代码维护性都有很大的晋升。

  1. 另外一个问题:如果有两个组件 Header 和 List,它们别离有本人的 loading 状态。当初咱们想要把这两个 loading 状态合并在一起,放到 page 里。如下所示:

如果依照传统的形式,咱们须要将大量的代码移动到上一层 page 里。然而在 React18 里,Suspense 可能很轻松的解决这一问题:

<Suspense fallback={<Skeleton />}>
  <Header />
  <List pageId={pageId} />
</Suspense> 

如果 Header 组件和 List 组件都在申请数据当中,那么就会显示 Skeleton 组件。如果咱们想给 List 组件增加一个独自的占位组件,只须要再套一层 Suspense 即可实现,无需对数据进行做非凡解决。

<Suspense fallback={<Skeleton />}>
  <Header />
  <Suspense fallback={<ListPlaceholder />}>
    <List pageId={pageId} />
  </Suspense>
</Suspense> 

能够看出,Suspense 通过数据和加载状态拆散的形式,极大地简化了加载状态的解决。

上面咱们看另外一个理论的 Suspense 应用案例,理解下 Suspense 如何实现的:

import React, {Suspense} from 'react'
import {request} from './utils/api'
import {unstable_createResource} from 'react-cache'

const data = unstable_createResource((data) => request(data))

const AsyncComponent = () => {const res = data.read(10000)
  return (
    <ul>
      {new Array(res).fill(0).map((_, i) => (<li key={i}>{i}</li>
      ))}
    </ul>
  )
}

const SuspenseComp = () => (<Suspense fallback={<div>Loading...</div>}>
    <AsyncComponent />
  </Suspense>
)

export default SuspenseComp 

在数据读取时咱们须要对数据加载的 promise 通过 unstable_createResource 办法进行一层封装。其外围目标是为了在 promise 处于 pending 状态时会抛出谬误,将 promise 抛出,而 Suspense 组件会去捕获这个 promise,从而显示 fallback。并在 promise.then 办法中从新触发更新。伪代码如下:

// 抛出谬误
unstable_createResource(promise) {
  // 数据没加载实现,抛出 promise
  if (promise.status === pending) {throw promise}
  // 数据加载实现,返回加载完的后果
  if (promise.status === fulfilled) {return promise.result}
}

// Suspense 捕获谬误,捕捉到抛出的 promise,并增加更新
promise.then(() => {renderAgain()
}) 

须要留神的是,Suspense 捕获谬误后触发的更新为低优先级更新,会通过工夫切片的模式去更新,因而不会阻塞用户交互和渲染流程,这也是后面提到的并发更新的一个理论利用。

useTransition/useDeferredValue

useTransition 和 useDeferredValue 其实性能上相差不太多,都是通过工夫切片的模式进行更新。对于它们之间的区别,react 有做相干形容:

It’s tricky. We didn’t document useDeferredValue precisely because we don’t know how to explain it well yet. So I won’t be able to come up with a great explanation on the spot.

startTransition requires you to have access to the place where state is being set. In long term it’ll likely mostly be used by code like routers (page navigations) or data fetching libraries (refetching data). Whereas useDeferredValue can be used anywhere because it only takes a value — it doesn’t care where the state was set.

useDeferredValue 不关怀输数据在哪里设置的,它次要用于将一些紧急的事转换为非紧急的事。而 useTransition 将来可能会用于 page navigations 或数据获取库等。

那么这两个 hook 在理论中有什么作用呢?咱们看一个理论例子:

import React, {useState, useDeferredValue} from 'react'

const Defer = () => {const [searchValue, setSearchValue] = useState(100)
  const deferredSearchValue = useDeferredValue(searchValue)
  
  return (
    <>
      <input
        type="number"
        value={searchValue}
        onChange={(e) => {setSearchValue(Number(e.target.value) || 0)
        }}
      />
      
      {new Array(deferredSearchValue).fill(0).map((_, idx) => (<li key={idx}>{idx}</li>
      ))}
      {/* {new Array(searchValue).fill(0).map((_, idx) => (<li key={idx}>{idx}</li>
      ))} */}
    </>
  )
}

export default Defer 

在 input 内容扭转时,会依据输出内容去渲染一个比拟耗时的列表。

  • 在传统模式下,因为渲染列表占据了线程,导致用户输出时,无奈立刻响应。
  • 而在 React18 中应用 useDeferredValue,会将列表渲染的更新置为低优先级更新。并且当 input 值疾速变动的时候,React 会合并触发的更新,渲染最初的一个更新。\

那么 useDeferredValue 与防抖节流有什么区别呢?

首先看一下防抖,比方触发 onChange 事件时,通过 setTimeout 设置 100ms 的提早:

onChange={(value) => {clearTimeout(timer)
  timer = setTimeout(() => {setSearchValue(value)
  }, 100)
}} 

尽管这曾经很好的解决了频繁触发渲染的问题,然而还是会存在一些小问题。比方列表渲染十分快时,远远小于 100ms,然而却须要期待到 100ms 后才会开始执行更新。当然,咱们也能够尝试节流来解决频繁渲染问题,然而防抖节流却都无奈解决更新耗时过长的问题。比方列表渲染须要耗时 1s,那么在这 1s 内用户仍旧无奈去交互。

而 useTransition/useDeferredValue 很好的解决了这一问题,能够看一下这两个 hook 源码中比拟要害的一部分如下:

const prevTransition = ReactCurrentBatchConfig.transition;
// 每次更新之前,扭转优先级,为 transition 优先级
ReactCurrentBatchConfig.transition = {};
try {setValue(value);
} finally {ReactCurrentBatchConfig.transition = prevTransition;} 

在每次更新之前,会将优先级更新为 transition,属于低优先级更新,通过工夫切片的模式去更新,从而不阻塞其余紧急的渲染。这在一些耗时渲染和 CPU 性能绝对不高的场景下还是比拟有用的,可能稳固保障用户界面是可交互的。

useSyncExternalStore

后面提到的几个新 API 都是通过并发更新的模式解决渲染阻塞的问题,然而并发同样会带来新的问题。

比方咱们将一个低优先级更新拆分成了 40 个小更新,并且这 40 个小更新里须要获取全局变量,比方 globalVariable = 1。以后 20 个小更新实现时,这个时候用户点击事件触发,将 globalVariable 设置为 2,那么后续 20 个小更新在获取这个变量时与前 20 个更新不统一。这就造成了一个界面对于同一个变量却渲染出了 2 个值,呈现不统一的状况。这种状况咱们称之为 tearing。

为了解决这一问题,React 提供了 useSyncExternalStore。它相当于对并发更新应用到的额定数据进行监听,当并发更新时数据发生变化,进行强制渲染:

function updateStoreInstance<T>( fiber: Fiber,
  inst: StoreInstance<T>,
  nextSnapshot: T,
  getSnapshot: () => T,) {
  
  ...
  
  if (checkIfSnapshotChanged(inst)) {
    // Force a re-render.
    forceStoreRerender(fiber);
  }
} 

当然,这个 api 是给库作者提供的,用于将库深度整合到 React 当中,通常不会用于理论业务开发当中。

至此,React18 的并发原理及相干个性分享完了。总的来说,React18 这次的更新大都是底层内容的更新,理论的 api 变动并不是很大。对于开发者来讲,尽管能够很快上手这些新的 api,然而却越来越难以了解背地的一些原理了。

最初,以上局部内容蕴含我集体的了解,不免存在一些了解上的偏差,如果有谬误的中央欢送大家斧正。如果你有什么问题也欢送探讨。

退出移动版