React scheduler模块part1 - 整体介绍以及时间分片

533 阅读9分钟

我正在参加「掘金·启航计划」

你敢信上了5天班了才星期三!!!!!!

大家好,我是三重堂堂主(公众号:咪仔和汤圆,欢迎关注~) 引个流~

qrcode_for_gh_20f4d9f727a5_258.jpeg


ReactConcurrent Mode,改变了原来的架构,新增了scheduler模块(不知道scheduler的可以先看下React v18的官方更新文档,或者我们上一期的烂尾文档一文到底React v18(烂尾版~

这次我们新开一个系列研究一下Reactscheduler模块的要解决的问题、技术的选型、以及怎么实现。

WX20220507-100833@2x.png

并发模式原理

React v17中,改变最大的也是最核心的内容就是支持并发模式。不过并发模式对开发者的体感影响不是很大,所以我们作为使用者是不用过多担心的。但是对于一些库的维护者,可能就需要在这方面下一点功夫。

并发模式,也就是Concurrent Mode,能够支持可中断的异步更新,并且不会占用过多时间导致浏览器卡死掉帧。实话说这不是一两句能说清的,下面我尽可能的解释一下:

在并发模式之前,是同步渲染模式,React的架构只有reconcilerrenderer两部分,分别是协调器和渲染器。协调器reconciler在更新触发后(比如我们的mountupdate),会去重新获取vdom,然后再交由渲染器renderer去重新渲染。这个过程是深度优先遍历(DFS),并且每一次的遍历都会交替运行reconcilerrenderer,并且采用的是"栈"来存储相关的数据。reconciler的工作在内存中就可以完成,但是renderer的工作需要浏览器的渲染引擎来参与,而这个工作是不能打断的。所以整个遍历过程是一个不能中断的运行,举个例子:

import { FC, Fragment, useState } from 'react'
const ShowKey: FC = () => {
  const [list, setList] = useState([1, 2, 3])

  return (
    <Fragment>
      <button
        onClick={() => {
          setList(list.map((item) => item + 1))
        }}
      >
        +1
      </button>
      <ul>
        {list.map((item, index) => {
          return <li key={index}>{item}</li>
        })}
      </ul>
    </Fragment>
  )
}
export default ShowKey

功能上很简单,渲染一个列表,当点击按钮的时候每项都加1。

当我们点击+1按钮,list变为[2,3,4],这个时候触发更新。正常情况下,第一个li变为2的时候,reconciler工作,然后交给renderer,界面变为[2,2,3];第二项li变为3的时候,界面变为[2,3,3];第三项li变为4的时候,界面变为[2,3,4];更新完成。这个过程假设在第二次的时候由于某种原因中断了,中断后的界面显示的是[2,3,3],和开发者预期的结果[2,3,4]是不同的。所以这是不能中断的主要因素,也因此在同步渲染模式中,业务计算很大,会导致页面出现停顿。

并发渲染怎么解决这些问题,首先是增加auto batch,批处理更新,reconcilerrenderer不再交替执行,而是让reconciler执行一批更新以后,产出一个vdom,再交由renderer进行更新。还是上面那个例子,[1,2,3][2,3,4]的3次更新,同样进行深度优先遍历,每次遍历做的工作不再含有renderer,遍历完成以后生成vdomfiber节点树),执行commitRoot交给renderer渲染。

这样做的好处是,可以对reconciler进行打断了。因为reconciler整个过程反应在内存中,这个过程不存在对渲染的影响,如果有更高优先级的更新需要执行,那么打断reconciler,重新进行reconciler就行了。怎么打断以及打断的时候怎么保存之前的运算结果,这就是React实现的fiber架构的内容了。那么,怎么知道有更高优先级的更新呢,这个时候,就需要新的架构模块来完成。在并发模式中,React新增的scheduler调度器,三个大作用:

  1. 用来给更新打上优先级
  2. 调度和发起更新
  3. 提供时间切片和中断能力

React的架构也就变成了schedulerreconcilerrenderer

哪些场景会打断reconciler呢?一个是有更高优先级的更新,还有一个就是浏览器当前帧没有空余时间留给React用了(这里可以多看看浏览器相关知识)。

所以并发模式解决了同步模式的不可中断和掉帧卡死问题。

schedule模块的作用

scheduler模块需要让耗时任务能够中断并在合适的时机重新唤醒,紧接着中断时候的状态继续运行,也就是要实现时间分片的功能,这是其一。在同一个时间点上,可能存在多个更新,比如正在渲染某一部分界面的时候,用户此时又去input中输入内容,因此也需要提供判断更新的优先级的功能,这是其二。由以上两点,可以划分scheduler模块的组成部分,主要分为三个:

  1. 实现时间分片的功能并提供中断和重新唤起任务的能力
  2. 设计出任务的优先级机制
  3. 设计一个存储结构来存储要执行的任务

时间分片

这一节探讨时间分片的一个整体初貌,首先说时间分片的原理。

顾名思义就是将任务切成一片一片的进行执行,执行完一个分片后检查当前的条件下支不支持下一个分片继续运行,能则运行;不能则暂停。等待时机成熟(条件满足)后,再重新运行。

JS是单线程执行,浏览器中在执行一些耗时过长的任务时就会导致渲染卡顿,形成long task,时间切片要做的事情就是将long task切为多个普通task,浏览器在每一帧的处理时间中处理耗时合理的task,然后在下一帧中继续处理,避免卡顿。

做个对比,在ul标签中渲染一万个liReact v18执行时的性能图中可以看到很多耗时基本相同的task,因为处理一万个li的任务耗时较长,就进行了时间分片,将long task分为了很多小的task(每一个task执行时间大概是5.x毫秒,这个后面会说到),页面表现上不会出现卡顿: image.png 再看一下没有Concurrent Mode的表现,渲染一万个li标签,明显的白屏卡顿,并且在性能图中是耗时很长的long taskimage.png

React scheduler实现时间分片的方案

scheduler怎么实现时间分片呢?

先说一下浏览器渲染的过程,浏览器大多以60HZ进行刷新,也就是在理想情况下,浏览器每秒会渲染出60张界面出来(注意是理想情况)。那么从一帧的开始到渲染完成,大概是1000/60 ≈ 16.6ms,在这16.6ms中,浏览器进行一轮渲染,下图就是浏览器在一帧中所要做的事情: image.png 大致就是"交互、事件触发 >> 事件循环>> requestAnimationFrame >> layout >> requestIdleCallback"这样一个过程,这里我们可以写一个简单页面进行验证:

console.log("第一次的主执行栈,'宏任务', start")
new Promise(resolve => {
  console.log("微任务")
  resolve()
})
window.requestAnimationFrame(() => {
  console.log("RAF的回调")
})
window.requestIdleCallback(() => {
  console.log("RIF的回调")
})
console.log("第一次的主执行栈,'宏任务', end")
WX20221012-160117@2x.png

image.png

所以在浏览器渲染一帧的过程中,有3个地方可以让我们来运行js,即TASK、RAF、RIC这3个地方能够实现时间分片,scheduler选择了在TASK中的宏任务实现时间分片,而没有在RAFRIC中实现。

首先大家可以看下这篇文章了解下RAF的执行时机:RAF,然后我们再解释下为什么不在RAF中实现:

浏览器虽然每一帧都会检查RAF,但是浏览器在什么时候执行RAF(浏览器在什么时候更新界面)并不确定。原因在于现在的规范并没有规定在什么时候更新,通常认为是在宏任务后,浏览器自己判断是否应该更新页面,如果需要更新页面,执行RAF的回调并更新页面;如果不需要更新页面,就不执行RAF,直接执行下一个宏任务。

这也是用RAF做动画会比settimeout顺滑的原因:可能一帧(注意用词是"一帧")中浏览器觉得不需要更新界面而多次执行了settimeout

基于上面的解释,RAF执行时机的“不能预测性”会导致一个问题,一旦RAF触发比较晚的情况下,前面可能会有的空闲时间,就会被浪费掉。(所以这里也可以说,选择task作为实现时间分片的一个理由就是一帧中task是最先执行),弃用RAF方案的主要因素就是这个。

一个番外,针对网上说的RAF选为时间分片实现的地方会被执行两次,不对。当选择RAF作为实现的时间分片方案时,执行scheduleTask函数下发任务的时候,必然是RAF去触发,而一帧中只会有一次RAF,所以执行两次的说法是错误的,伪代码会是这样:

const scheduleTask = (fn) =>{
  // do what 
  // 通过RAF执行任务
  window.requestAnimationFrame(fn)
}
// 调度任务
scheduleTask()

那么为什么不用RIC实现呢?

首先RIC每个浏览器实现不一样,存在很大的兼容问题。其次RIC在规范中指的是空闲状态下运行,规范中写的执行间隔是50ms,1秒内执行20次,和浏览器刷新率有差异,一定情况下(浏览器认为暂时不需要更新+RIC没执行)会造成UI和逻辑的不一致,最主要的问题还是兼容问题。

所以到目前还剩TASK了,也就是Event Loop。微任务由于每次都执行完队列,满足不了分片的要求,所以分片采用的是宏任务实现。目前在scheduler中首选是通过messageChannel实现,在不支持messageChannel的环境中通过setImmediate实现,其次是setTimeoutsetTimeout作为最次的方案主要是因为setTimeout(fn,0)的延时问题。

part 1我们主要只了解原理和选型方案,在scheduler系列的后面的文章进行源码分析。

公众号:咪仔和汤圆,帮帮忙哇~