说说react的渲染流程

390 阅读14分钟

前置知识:js是单线程的

众所周知,浏览器是多进程的,而我们切图仔呢一般就研究的是其中的渲染进程。而渲染进程里面就有很多线程啦(OS面试题:进程和线程的区别?),比如GUI线程,事件触发线程,js线程等等。其中GUI线程主要负责html解析生成dom树,,css解析生成cssom树以及页面的渲染等功能。而js线程主要就是负责执行脚本代码。js之所以是单线程的,是因为我们可以通过js代码去进行页面dom操作亦或者是修改页面样式,这和GUI线程的工作内容冲突了,所以两者为互斥线程。

stack reconciler的痛点

stack reconciler是通过深度优先去遍历我们的虚拟dom,那么就免不了递归操作啦。由于虚拟dom树结构本身的问题,导致在递归遍历的过程中如果发生了中断,那么react将重新从头开始遍历。所以react基于stack reconciler实现的是同步渲染,渲染过程不可被打断。而当我们的组件很庞大时,也就是说递归的层数嵌套深,那么js线程占用主线程的时间就很长,这个时候GUI线程会被长时间挂起,无法及时的进行页面渲染,也就是页面出现卡顿。同时由于递归遍历是不可中断的,有点不撞南墙不回头那味。所以当我们需要进行一些页面交互这些高优先级的操作时,必须等到react渲染结束才会实现,比如用户在input框输入内容,只有递归结束之后才会把内容渲染出来,用户体验极差。

Fiber是如何解决问题的

什么是Fiber

从架构角度来看,Fiber是对React核心算法的重写。

从编码角度来看,Fiber是React内部所定义的一种数据结构

从工作流的角度来看,Fiber节点保存了组件需要更新的状态和副作用,涉及到hooks

核心:增量渲染。目的是为了实现任务的可中断、可恢复,并给不同的任务赋予不同的优先级,最终达到更加顺滑的用户体验。

架构变化

render的工作单元有着不同的优先级,react可以根据优先级的高低去实现工作单元的打断和恢复。

每个更新任务都会被赋予一个优先级,若发现B的优先级高于当前任务A,那么处于Reconciler层的A任务就会被中断,当任务B执行完之后,A任务将会被重新推入Reconciler层继续渲染,这便是所谓的"可恢复"。

对生命周期的影响

react的异步渲染虽然用户可能感知不到,但是以下的生命周期函数会在render阶段触发,由于任务进入reconclier层就会执行部分生命周期函数,而导致这些生命周期函数重复执行,消耗性能。

ReactDOM.render调用栈

react渲染机制工作流程:初始化阶段->render->commit

初始化阶段

核心:完成Fiber树基本实体的创建

fiberRoot和rootFiber

在最后的updateContainer函数中执行performSyncWorkOnRoot函数开始渲染。我们可以看到ReactDOM.render是同步渲染的,搞了半天发现react渲染过程是同步的?无所谓,ReactDOM.createRoot会出手

渲染模式

legacy模式:ReactDOM.render(<App/>,rootNode),该模式为同步的

blocking模式:ReactDOM.createBlockingRoot(rootNode).render(<App />),中间过渡产物

concurrent模式:ReactDOM.createRoot(rootNode).render(<App/>),在react16正式引入,该模式为异步的

React会通过fiber.mode判断当前的渲染是在什么模式下进行的,这决定了当前的渲染工作流是一气呵成(同步)or可分片的(异步的)

Fiber架构一定是异步渲染吗?

不一定。只能说react中的Fiber架构确实是为了concurrent模式而存在的,但Fiber架构并不能和异步渲染划严格的等号。Fiber架构是一种支持同步和异步渲染的设计。

render阶段

performSyncWorkOnRoot 标志着 render 阶段的开始,finishSyncRender 标志着 render 阶段的结束。这中间包含了大量的 beginWork、completeWork 调用栈,正是 render 的工作内容。

workinprogress节点的创建

WIP节点是current节点的副本,本质也是fiberRoot节点。

接着就进行WIP树的构建,最后当前组件容器的dom节点就对应着current树和workinprogress树两棵树,这就是著名的"双缓冲"机制啦,后面再聊

beginwork开启fiber节点的创建过程

核心:beginwork函数的入参是一对用alternate连接的current节点和wip节点。实现原理是根据fiber节点的tag属性不同,调用不同的节点创建函数。

这些节点创建函数最终都会调用reconcileChildren方法,生成当前节点的子节点。

首先会创建App组件对应的fiber节点,由此可以把WIP树中的rootFiber看成App组件的父组件。

App的FiberNode的创建流程

fiber树的构建

接着进入workLoopSync函数开始构建WIP树,循环调用performUnitofwork函数(主要是beginwork和completework函数在发力)生成fiber节点,最后连接成WIP树。

fiber树的结构:

可以看出fiber树的本质就是链表,这为异步渲染提供了实现基础。

fiber结构实现中断恢复

stack reconciler进行的是递归遍历节点。

在进行深度优先遍历时,某个节点发生了中断,那么由于递归会被中断,那么我们只能继续往下递归遍历当前节点的子节点,访问不到父节点了,所以当渲染阶段被中断时,会重新从头开始遍历组件。

如图,当节点2渲染发生中断,子节点3,4都能访问,但是节点5678就访问不到了。

而在fiber架构下,fiber有指向父节点的指针!利用这个指针我们就可以在中断结束之后继续循环遍历构建fiber树。如图,fiber完美记录当前渲染任务进度

completeWork

核心:将fiber节点映射为真实的dom节点,根据fiber.tag进行不同dom节点的创建、处理逻辑。

completeUnitOfwork主要干了两件事情:

completeUnitOfWork开启completeWork

在performunitofwork中,当beginwork执行完,生成当前fiber之后,就会判断当前递归过程中的"递"结束了没,一旦结束了就进入了"归",那么就会触发completeunitofwork。completeunitofwork就会触发completework为当前fiber生成对应的真实dom节点,并将fiber的stateNode属性映射为当前真实dom节点。最后将当前fiber的dom节点连接到父fiber对应的dom节点上,这就很同步了。

completeUnitOfWork开启收集EffectList的"大循环"

将当前节点的副作用链EffectList插入到父节点的副作用链EffectList中,最后将wip树中所有节点的副作用统一收集到wip树的根节点中,交予commit阶段处理。

在react中,节点的副作用有节点增加,删除,修改等,通过effectTag属性标识。因为最后是把副作用汇总到根节点,所以整个收集的过程是严格自底向上的,也就是说我们completeunitofwork是在"归"的阶段执行的,那么当遍历到兄弟节点时会先跳出当前节点的performunitofwork函数,进入兄弟节点的performunitofwork阶段并等到beginwork执行完了,也就是兄弟节点有对应的fiber节点了才会返回到当前节点的performunitofwork函数继续执行complete函数。

综上,completework的执行是严格自底向上的,子节点的completework会优先于父节点。

effectList的设计与实现

首先我们知道render阶段就干了一件事,就是对比current树和wip树找不同,实现增量渲染。而effectList就是render阶段的"工作成果",当render阶段结束后,commit阶段只需要遍历effectList处理对应的副作用就能快速实现组件更新。每个fiber节点都会维护自己的effectList,本质上是链表,记录所有子节点的副作用,不包括自己。通过一层一层往上传,最后根节点的终极effectList就记录了所有子节点的副作用。

更新阶段对比每一对current节点和wip节点:

commit阶段

这是一个绝对同步的过程。

分为三个阶段:before mutation、mutation、layout

before mutation阶段,这个阶段DOM节点还没被渲染到页面上,react会在这个阶段执行getSnapshotBeforeUpdate这个生命周期函数来对更新前dom树进行快照,比如当前滚动条的滚动位置、输入框焦点等,还有就是在函数式组件中执行useEffect这个hook,因为在useEffect处理的副作用可能会进行dom操作,react希望我们在渲染dom节点之前把dom更新完。

mutation阶段,这个阶段负责DOM节点的渲染。在渲染过程中会遍历effectList,根据flags(effectTag)的不同,执行不同的DOM操作。

layout阶段,这个阶段处理DOM渲染完毕之后的收尾逻辑。比如调用componentDidMount/componentDidUpdate,调用useLayoutEffect钩子函数的回调等。除此之外还有把fiberRoot的current指针指向workinprogress树

Concurrent并发模式

核心:"时间切片"与"优先级"

双缓冲机制

在计算机图形领域,通过让图形硬件交替读取两套缓冲数据,可以实现画面的无缝切换,减少视觉效果上的抖动甚至卡顿,有点空间换时间的意思。而在 React 中,双缓冲模式的主要利好,则是能够帮我们较大限度地实现 Fiber 节点的复用,从而减少性能方面的开销。

current树是当前页面dom节点的映射,而wip树则是在背后默默构造的新树。当组件触发更新,在beginwork中循环调用createworkinprogress生成新的fiber节点。如果当前节点的alternate属性存在,则直接复用current树的fiber节点,需要修改再在此基础上修改即可。如果不存在,则生成新的fiber节点。

更新链路要素分析

其实,挂载可以理解为一种特殊的更新,ReactDOM.render 和 setState 一样,也是一种触发更新的姿势。在 React 中,ReactDOM.render、setState、useState 等方法都是可以触发更新的,这些方法发起的调用链路很相似,是因为它们最后“殊途同归”,都会通过创建 update 对象来进入同一套更新工作流

每个fiber节点都会维护一个updatequeue队列,存储多个更新,并且updateQueue 的内容会成为 render 阶段计算 Fiber 节点的新 state 的依据。 接着进入scheduleUpdateOnFiber调度 update。

这是 scheduleUpdateOnFiber 中的一段逻辑。在同步的渲染链路中,lane === SyncLane 这个条件是成立的,因此会直接进入 performSyncWorkOnRoot 的逻辑,开启同步的 render 流程;而在异步渲染模式下,则将进入 else 的逻辑。

在 else 中,需要引起你注意的是 ensureRootIsScheduled 这个方法,该方法很关键,它将决定如何开启当前更新所对应的 render 阶段。

performSyncWorkOnRoot 和 performConcurrentWorkOnRoot 这两个方法:前者是同步更新模式下的 render 阶段入口;而后者是异步模式下的 render 阶段入口

React 会以当前更新任务的优先级类型为依据,决定接下来是调度 performSyncWorkOnRoot 还是 performConcurrentWorkOnRoot。这里调度任务用到的函数分别是 scheduleSyncCallback 和 scheduleCallback,这两个函数在内部都是通过调用 unstable_scheduleCallback 方法来执行任务调度的

有时候同步渲染也很香:

Scheduler——“时间切片”与“优先级”的幕后推手

时间切片

React DOM.render同步渲染模式下,渲染一个长列表:

我们知道浏览器刷新频率60hz,1000ms/60hz=16.6ms,也就是说16.6ms浏览器刷新一次。但同步渲染下js线程占用主线程的时间远超16.6ms,这就导致GUI渲染线程无法正常工作,可能出现页面卡顿等问题。

我们再看看异步渲染:

我们可以看到task长条被切成若干的小task条,每个task短条为5ms。也就是说GUI渲染线程有16.6-5=11.1ms左右的时间进行渲染,浏览器可以在这些时间缝隙中进行渲染。这就是时间切片的好处啦~

时间切片的实现原理

React 会根据浏览器的帧率,计算出时间切片的大小,并结合当前时间计算出每一个切片的到期时间。在 workLoopConcurrent 中,while 循环每次执行前,会调用 shouldYield 函数来询问当前时间切片是否到期,若已到期,则结束循环、出让主线程的控制权。

优先级调度

我们已经知道,无论是 scheduleSyncCallback 还是 scheduleCallback,最终都是通过调用 unstable_scheduleCallback 来发起调度的。unstable_scheduleCallback 是 Scheduler 导出的一个核心方法,它将结合任务的优先级信息为其执行不同的调度逻辑

unstable_scheduleCallback 的主要工作是针对当前任务创建一个 task,然后结合 startTime 信息将这个 task 推入 timerQueuetaskQueue,最后根据 timerQueue 和 taskQueue 的情况,执行延时任务或即时任务。

timetQueue小顶堆维护的是未到点执行的任务,根据时间先后排序。

taskQueue小顶堆维护的是到点执行的任务,根据优先级高低排序。

任务一到,分析是否到点执行,还没到点的就入timerQueue队列,开启延时调用。直到任务开始时间到了,推入taskQueue队列,执行workloop,逐一执行taskQueue队列直到调度过程被暂停(时间片用完了)或任务全部做完清空了。如果已经到点执行的话直接把任务推入taskQueue队列,执行当前的即时任务。

面试题:说说react的渲染流程

答:

react渲染过程大致一致,主要区别在协调过程。在react16之前是stack reconciler,之后引入了fiber reconciler。reconclier在狭义上讲指diff算法,广义上讲包括diff算法以及一些公共逻辑。stack reconciler的核心调度方式的递归,调度的基本单位是事务。整个递归过程不可中断,可能会出现页面阻塞等问题。而fiber reconclier的调度方式有两个特点,第一个是协作式多任务模式,在这个模式下,线程会定时放弃自己的运行权利,交还给主线程,通过requestIdlecallback实现。第二个是策略优先级,调度任务通过标记tag的方式来区分优先级。fiber reconciler的基本调度单位是fiber,fiber是对过去的reactElement的二次封装,提供了指向父子,兄弟节点的引用,为react异步渲染提供了实现基础。整个生命周期划分为了render和commit两个阶段,render阶段的执行特点是可中断,无副作用的,主要是通过构造workinprogress树,计算出diff,以current树为基础,将每个fiber作为基本单位,自下而上地逐个节点检查并构造wip树, 整个过程不再是递归,而是基于循环来完成。在执行上通过requestIdlecallback来调度执行每组任务,每组的每个计算任务被称为work,每个work在完成后确认是否有优先级更高的work需要插入,如果有就让位,如果没有就就继续。优先级通常是标记为动画或者high的会先处理,每完成一组后将调度权交回主线程,直到下次requestIdlecallback调用后再继续构建wip树。在commit阶段需要处理effectlist,effectlist包含根据diff更新dom树,回调生命周期等,commit阶段是同步执行的不可中断的。所以不要在commit阶段,比如componentdidmount,componentdidupdate,componentwillunmount中进行进行复杂的js逻辑代码。