我正在参加「掘金·启航计划」
你敢信上了5天班了才星期三!!!!!!
大家好,我是三重堂堂主(公众号:咪仔和汤圆,欢迎关注~) 引个流~
React
的Concurrent Mode
,改变了原来的架构,新增了scheduler
模块(不知道scheduler
的可以先看下React v18
的官方更新文档,或者我们上一期的烂尾文档一文到底React v18(烂尾版~。
这次我们新开一个系列研究一下React
中scheduler
模块的要解决的问题、技术的选型、以及怎么实现。
并发模式原理
在React v17
中,改变最大的也是最核心的内容就是支持并发模式。不过并发模式对开发者的体感影响不是很大,所以我们作为使用者是不用过多担心的。但是对于一些库的维护者,可能就需要在这方面下一点功夫。
并发模式,也就是Concurrent Mode
,能够支持可中断的异步更新,并且不会占用过多时间导致浏览器卡死掉帧。实话说这不是一两句能说清的,下面我尽可能的解释一下:
在并发模式之前,是同步渲染模式,React
的架构只有reconciler
和renderer
两部分,分别是协调器和渲染器。协调器reconciler
在更新触发后(比如我们的mount
和update
),会去重新获取vdom
,然后再交由渲染器renderer
去重新渲染。这个过程是深度优先遍历(DFS
),并且每一次的遍历都会交替运行reconciler
和renderer
,并且采用的是"栈"来存储相关的数据。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
,批处理更新,reconciler
和renderer
不再交替执行,而是让reconciler
执行一批更新以后,产出一个vdom
,再交由renderer
进行更新。还是上面那个例子,[1,2,3]
到[2,3,4]
的3次更新,同样进行深度优先遍历,每次遍历做的工作不再含有renderer
,遍历完成以后生成vdom
(fiber
节点树),执行commitRoot
交给renderer
渲染。
这样做的好处是,可以对reconciler
进行打断了。因为reconciler
整个过程反应在内存中,这个过程不存在对渲染的影响,如果有更高优先级的更新需要执行,那么打断reconciler
,重新进行reconciler
就行了。怎么打断以及打断的时候怎么保存之前的运算结果,这就是React
实现的fiber
架构的内容了。那么,怎么知道有更高优先级的更新呢,这个时候,就需要新的架构模块来完成。在并发模式中,React
新增的scheduler
调度器,三个大作用:
- 用来给更新打上优先级
- 调度和发起更新
- 提供时间切片和中断能力
React的架构也就变成了scheduler
到reconciler
到renderer
。
哪些场景会打断reconciler
呢?一个是有更高优先级的更新,还有一个就是浏览器当前帧没有空余时间留给React
用了(这里可以多看看浏览器相关知识)。
所以并发模式解决了同步模式的不可中断和掉帧卡死问题。
schedule模块的作用
scheduler
模块需要让耗时任务能够中断并在合适的时机重新唤醒,紧接着中断时候的状态继续运行,也就是要实现时间分片的功能,这是其一。在同一个时间点上,可能存在多个更新,比如正在渲染某一部分界面的时候,用户此时又去input
中输入内容,因此也需要提供判断更新的优先级的功能,这是其二。由以上两点,可以划分scheduler
模块的组成部分,主要分为三个:
- 实现时间分片的功能并提供中断和重新唤起任务的能力
- 设计出任务的优先级机制
- 设计一个存储结构来存储要执行的任务
时间分片
这一节探讨时间分片的一个整体初貌,首先说时间分片的原理。
顾名思义就是将任务切成一片一片的进行执行,执行完一个分片后检查当前的条件下支不支持下一个分片继续运行,能则运行;不能则暂停。等待时机成熟(条件满足)后,再重新运行。
JS
是单线程执行,浏览器中在执行一些耗时过长的任务时就会导致渲染卡顿,形成long task
,时间切片要做的事情就是将long task
切为多个普通task
,浏览器在每一帧的处理时间中处理耗时合理的task
,然后在下一帧中继续处理,避免卡顿。
做个对比,在ul
标签中渲染一万个li
,React v18
执行时的性能图中可以看到很多耗时基本相同的task
,因为处理一万个li
的任务耗时较长,就进行了时间分片,将long task
分为了很多小的task
(每一个task
执行时间大概是5.x
毫秒,这个后面会说到),页面表现上不会出现卡顿:
再看一下没有
Concurrent Mode
的表现,渲染一万个li
标签,明显的白屏卡顿,并且在性能图中是耗时很长的long task
:
React scheduler实现时间分片的方案
scheduler
怎么实现时间分片呢?
先说一下浏览器渲染的过程,浏览器大多以60HZ
进行刷新,也就是在理想情况下,浏览器每秒会渲染出60
张界面出来(注意是理想情况)。那么从一帧的开始到渲染完成,大概是1000/60 ≈ 16.6ms
,在这16.6ms
中,浏览器进行一轮渲染,下图就是浏览器在一帧中所要做的事情:
大致就是"
交互、事件触发 >> 事件循环>> 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")
所以在浏览器渲染一帧的过程中,有3个地方可以让我们来运行js,即TASK、RAF、RIC
这3个地方能够实现时间分片,scheduler
选择了在TASK
中的宏任务实现时间分片,而没有在RAF
和RIC
中实现。
首先大家可以看下这篇文章了解下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
实现,其次是setTimeout
,setTimeout
作为最次的方案主要是因为setTimeout(fn,0)
的延时问题。
在part 1
我们主要只了解原理和选型方案,在scheduler
系列的后面的文章进行源码分析。
公众号:咪仔和汤圆,帮帮忙哇~