1

React 并发模式到底是个啥?

 7 months ago
source link: https://www.51cto.com/article/781259.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React 并发模式到底是个啥?

作者:这波能反杀丶 2024-02-07 12:35:00
到目前为止,React 的并发模式就只体现在任务优先级和任务可被中断上。如果单独考虑任务可被中断,他实现的效果就跟防抖、节流比较类似,概念比较高大上,但说穿了其实也没啥用。
c29179559882c83b7644739584398431611af0.png

在计算机里,并发「concurrent」一词,最早是用来表示多个任务同时进行。但是由于早期的计算机能力有限,单核计算机同一时间,只能运行一个任务。因此,为了做到看上去多个应用是在同时运行的,单核计算机就快速的在不同的应用中来回切换,它执行完 A 应用的一个任务,就执行 B 应用的任务,只要切换得足够快,对于用户而言,A 应用与 B 应用就是在同时运行。

因此,对于单核 CPU 来说,多个任务同时执行这种情况并不存在。

后来的主流计算机已经可以做到多个任务同时执行了,但是并发一词已经有了自己专属的场景,于是我们把真正的多个任务同时执行又重新取了一个名字,并行「parallel」

而并发则保留了它原本在单核 CPU 上的的含义:多个任务切换执行。为了知道下一个任务到底应该是谁执行了,那么单核 CPU 上必定会设计一个调度模式,用来确定任务的优先级。因此,并发的另外一个角度的解读,就是多个任务对同一执行资源的竞争。

一、React 的并发

在页面使用 JS 操作 DOM 渲染页面的过程中,也是同样的道理,他不存在有两个任务能同时执行的情况。不过,React 设计了一种机制,来模拟渲染资源的竞争。

首先,React 设计了一个调度器,Scheduler,来调度任务的优先级。

但是在争取谁更先渲染这个事情,在浏览器的渲染原理里,他经不起推敲。为什么呢?因为浏览器的底层渲染机制有收集逻辑,他会合并所有的渲染指令

div.style.color = 'red'
div.style.backgroundColor = '#FFF'
...

多个指令,会被合并成一个渲染任务。那也就意味着,对于浏览器而言,不存在渲染资源的竞争,因为不同的渲染指令都会被合并。既然这样,那 React 的并发又是怎么回事呢?

还有更诡异的事情,React 的渲染指令,是通过 setState 来触发,我们知道,多个 setState 指令,React 也会将他们合并批处理

setLoading(false)
setList([])

// 等价于
setState({
  loading: false,
  list: []
})

既然如此,并发体现在什么地方呢?也不存在渲染资源的竞争啊?我们看不到任务的切换执行,也看不到不同任务对渲染资源的竞争。所以真相就是...

大多数情况下,React 确实并不存在任何并发现象。

而事实上,当我们已经明确了哪些 DOM 需要被操作,对于浏览器来说,他可以足够快的渲染更新,因此,在一帧的时间里,就算合并非常多的 DOM 操作,浏览器也足以应对。够用,就表示竞争毫无意义。

只有在渲染超大量的 DOM 和大量表单时,浏览器的渲染引擎表示有压力

因此,资源竞争只会发生在,渲染能力不够用的时候。

一次渲染包括两个部分,一个部分是 JS 逻辑,我们需要在 JS 逻辑中明确具体的 DOM 操作是什么。第二个部分是渲染引擎执行渲染任务。很明显,对于 React 而言,他无法改变渲染引擎的逻辑。那么也就意味着,React 的并发只会发生在第一个部分:JS 逻辑中。

因此,react 还设计了第二步骤,Reconciler。当我们通过 setState 触发一个渲染任务时,react 需要在 Reconciler 中,利用 diff 算法找出来哪些 DOM 需要被更改。如果多个 setState 指令合并之后,我们发现 diff 过程超出了一帧的时间,这个时候就有可能会存在渲染资源的竞争。

Scheduler

Reconciler

Renderer

操作 DOM

但是,如果只有一帧超出的时候,这一帧之后,浏览器再也没有新的渲染任务,那么就算超出了也无所谓。也没有必要去竞争渲染资源,只有一种可能,那就是短时间之内需要多次渲染。如果每一帧的时间都超标了,那么页面就会卡顿。

因此,只有在短时间之内页面需要多次渲染,才会存在资源竞争的情况。这个时候我们才会考虑并发的存在。

我们还需要进一步思考。刚才我们已经分析出,只有在短时间之内多次渲染,并且造成了页面卡顿,我们才会考虑并发。说明此时我们想要使用并发来解决的问题就是让页面不卡顿。因此,在多次渲染的前提下,多个任务的竞争结果就一定是渲染任务总量减少了,才会不卡顿。所以我们要做的事情就是,找出优先级更低的任务,即使他掉帧,只要不影响页面卡顿,我们都可以接受。

在 React 的底层设计中,setState 是一个任务,但是这个任务会影响哪些 UI 发生变化,它就可能会对应多个 Fiber,每一个 Fiber 的执行都是一个小任务,我们可以把一个任务看成一个函数。

一旦一个任务开始执行之后,React 不具备提前判断这个任务执行结束需要多少时间。只有等他执行完了,我们才能够算出来他一共执行了多久。因此,对于哪些 setState 是耗时较长的任务,React 无法判断,只有通过开发者自己去判断。我们需要在触发 setState 时,就标记这个任务的优先级,否则 react 也判断不了这个任务是否耗时比较长。因此,我们需要手动使用 startTransition 来标记耗时的 setState

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('about');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }
  // ……
}

另外一个问题就是,竞争是如何发生的。

通过时间切片中断任务的执行,给优先级更高的任务一个插队的机会。

例如上面例子,当我们使用 StartTransition 标记了 setTab 为一个耗时较长的任务时。setTab 会有许多小的 Fiber 节点任务组成,我们在 Reconciler 阶段执行每一个小的 Fiber 节点任务之前,都会判断此时是否应该打断循环。

function workLoop(hasTimeRemaining, initialTime) {
  var currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);

  while (currentTask !== null && !(enableSchedulerDebugging )) {
    if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
      // 当前任务尚未过期,但时间已经到了最后期限
      break;
    }

这里的 frameInterval 的具体值为 5ms,就是一个时间分片。也就是说,在 子 Fiber 任务执行的遍历过程中,每大于 5ms,就会被打断一次。这样才有给更高优先级任务执行的机会。

function shouldYieldToHost() {
  var timeElapsed = getCurrentTime() - startTime;

  if (timeElapsed < frameInterval) { // 5ms
    // 主线程只被阻塞了很短时间;
    // smaller than a single frame. Don't yield yet.
    return false;
  } 
  // 主线程被阻塞的时间不可忽视
  return true;
}

这里需要注意的是,setTab 最终被中断,是由于时间分片之内没有足够的时间给他执行每一个 Fiber 节点任务,而并非是由更高优先级的任务产生了导致它的中断。优先级只会影响队列的排序结果。

例如,假设 setTab 影响的 UI 中包含一个父级 Fiber 节点和 250 个子级Fiber 节点。如果我们对子 Fiber 节点增加一个 1ms 的阻塞,此时就至少有 50 个中断间隔给优先级更高的任务执行。

function Item(props: { text: string }) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {}
  console.log('text')
  return (
    <div>{props.text}</div>
  )
}

因此,在真实的渲染逻辑中,如果我的设备足够强悍,执行速度足够快,就算是我标记了低优先级,也可能不会被中断。

这里还需要注意的是,任务的最小单位是 Fiber,如果你的单个 Fiber 执行时间过长,react 也无法拆分这个任务。这种情况下,我们应该想办法把执行压力分散到子组件中去。

到目前为止,React 的并发模式就只体现在任务优先级和任务可被中断上。如果单独考虑任务可被中断,他实现的效果就跟防抖、节流比较类似,概念比较高大上,但说穿了其实也没啥用。如果你不用 useTransition/useDefferedValue 的话,基本上你的任务也不会被中断。

但是如果不考虑任务可被中断呢,优先级队列其实也没啥太大的意义。所以 react 的并发模式,从我个人主观的角度来看的话,宣传意义大于实际意义。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK