当前位置: 首页 > Web前端 > HTML

ReactFiberArchitecture

时间:2023-03-28 16:04:02 HTML

背景介绍因为最近团队在准备技术分享,想把自己零散的知识整理一下,串联一下。现总结如下,以供日后回顾。Filber概念Fiber是React16中采用的一种新的协调引擎,主要目标是支持虚拟DOM的渐进式渲染。这是Facebook历时两年的突破性成果。简单的说就是React内部实现的一套状态更新机制。支持不同优先级的任务,可以中断和恢复,恢复后可以复用之前的中间状态。为了更好地探究fiber的用途,当然我们还是要从React15开始,看看之前的react有哪些瓶颈和问题。React15面临的问题在正式讲React15的问题之前,我们先来了解一下React15的架构。React15架构可以分为两层:●协调器(coordinator)——负责找出发生变化的组件渲染器(renderer)——负责将发生变化的组件渲染到Reconciler所在的页面,基于堆栈协调器(stackreconciler),使用同步递归更新方法。说到递归更新,也是diffing的过程,但是递归的劣势还是很明显的。它不能暂停。一旦开始,就必须从头到尾。如果需要渲染的树结构嵌套很多,而且特别深,那么更新组件树时,会经常出现掉帧、卡顿的情况。当页面组件频繁更新时,页面的动画总是卡住,或者输入框使用键盘输入文字,文字已经输入了,但是在输入框里面一直有延迟。卡顿真的是前端展示交互的一个难以忍受的问题。接下来我们分析一下页面卡顿的原因。只有知道问题的根本原因,才能找到最优的解决方案。这要从浏览器执行机制说起。我们都知道浏览器中常见的线程有JS引擎线程、GUI渲染线程、HTTP请求线程、定时触发线程、事件处理线程。其中,GUI渲染线程和JS线程是互斥的。当JS引擎执行时,GUI线程会被挂起,GUI更新会保存在一个队列中,直到JS引擎空闲时才会执行。主流浏览器的刷新频率是60Hz,即每16.6ms刷新一次,每16.6ms浏览器都要执行JS脚本----样式布局----样式绘制。所以一旦递归更新时间超过16ms,时间超过16.6ms,浏览器就来不及进行样式布局绘制,表现为页面卡顿,当页面有动画时尤为明显。虽然react团队已经将树操作的复杂度从O(n*3)提升到了O(n),但是优化diff算法似乎有点棘手。这样做的原因是diff算法用于旧组件和新组件。和小孩比起来,小孩一般不会显得太长,有点像打蚊子的大炮。更何况,当我们的应用变得非常庞大,页面上有几万个组件,那么多的组件需要diff,再优秀的算法也不能保证浏览器不累。因为他们没想到浏览器会累,也没想到这是一个长跑的问题。如果是百米短跑,或者是1000米赛跑,当然是越快越好。如果是马拉松,需要考虑节省体力,需要注意休息,所以解决问题的关键是给浏览器一个适当的休息时间。Filber架构解决的问题下面是React官网描述的Fiber架构的目标:可以对可中断的任务进行切片和处理。能够调整优先级、重置和重用任务。能够在父元素和子元素之间交错以支持React中的布局。能够在render()中返回多个元素。更好地支持错误边界。前两个关键点就是解决React15的上述问题,在详细介绍之前,我们先看看React16之后的Scheduler(调度器)——调度任务的优先级。Reconciler中优先处理高质量的任务Reconciler(Coordinator)——负责找出变化的组件Renderer(渲染器)——负责将变化的组件渲染到页面上是否有剩余时间作为任务中断的判断标准。当浏览器有剩余时间时,调度器会通知我们。同时,调度器会进行一系列的任务优先级判断,保证任务时间得到合理分配。调度器包含两个功能:时间分片和优先级调度。时间分片是因为浏览器的刷新频率是16.6ms,当js执行超过16.6ms时页面会卡顿,时间分片是在浏览器的每一帧给JS线程预留一些时间,React就是利用了这部分更新组件的时间,初始预留时间为5ms。如果超过5ms,React会中断js,等下一帧时间到来继续执行js。上面我们大致说了浏览器一帧的执行,接下来我们会详细分析。我们可以看看时间片应该放在哪里。宏观任务似乎是可行的。看看有没有更好的选择:微任务和微任务会在页面更新之前执行,这样就达不到“将主线程还给浏览器”的目的。---没有通过requestAnimationFramerequestAnimationFrame一直是浏览器js动画的首选。它采用16.6ms的系统时间间隔,可以保持最佳的绘图效率。一直是js动画的首选。不会因为间隔太短而造成透支,增加开销;也不会因为间隔太长而卡动画卡。暂停不流畅,让各种网页动画效果可以有统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果,但是React还是没有用,因为在页面处理没有启动的时候,requestAnimationFrame会停止执行;稍后页面激活时,requestAnimationFrame会继续在上次执行的地方执行。这种被用户行为干扰的API只能被Scheduler抛弃。---nopassrequestIdleCallbackrequestIdleCallback其实是浏览器自带的时间分片功能,但是因为它有以下两个缺陷,react只能作罢。---nopassrequestIdleCallback不兼容各种浏览器。和requestAnimationFrame一样,在页面未激活时会停止执行。requestIdleCallback每50ms刷新一次,刷新频率远低于16.6ms,远低于页面流畅度要求。宏任务1、既然setTimeout是宏任务,那么setTimeout可以用吗?答案是肯定的,但不是最优的。我们来分析一下原因:当递归执行setTimeout(fn,0)时,最后的间隔时间会变成4ms,而不是最初的1ms,因为setTimeout的执行时机与js执行有关,递归会不准确。4ms是因为W3C规定的标准,setTimeout的第二个参数不能小于4ms,小于4ms默认为4ms。varcount=0varstartVal=+newDate()console.log("开始时间",0,0)functionfunc(){setTimeout(()=>{console.log("执行时间",++count,+newDate()-startVal)if(count===50){return}func()},0)}func()的结果如下:2.messageChannelScheduler最终使用MessageChannel生成宏任务,但是由于为了兼容性,如果当前主机环境不支持MessageChannel,仍??然使用setTimeout。简单介绍一下,window.MessageChannel和window.postMessage一样,可以创建一个通信通道。这个通道有两个端口,每个端口都可以通过postMessage发送数据。一个端口只要绑定了onmessage回调方法,就可以从另一个端口接收数据。下面是一个简单的MessageChannel例子:那为什么不用postMessage而用MessageChannel呢?当我们使用postMessage时,会触发addEventlistener绑定的所有消息事件处理器。同时,postMessage意味着全局管道,而MessageChannel只在一定范围内生效。因此,为了减少串扰,react使用MessageChannel搭建了一个专用的pipeline来减少对外的crosstalk(当对外通信频繁,数据量过大时,bufferqueue溢出,pipeline被阻塞,会影响调度反应的表现)和外界。干涉。任务调度任务优先级的定义这是任务调度的关键。React根据用户预期的交互顺序,为交互产生的状态更新分配不同的优先级:React16使用expirationTime来确定优先级,过期时间越短,优先级越高。//无优先级任务exportconstNoPriority=0;//立即执行任务,像一些生命周期方法需要同步执行exportconstImmediatePriority=1;//用户阻塞任务,比如在输入框输入文字,需要立即执行exportconstUserBlockingPriority=2;//普通任务exportconstNormalPriority=3;//低优先级任务,比如数据请求,不需要用户感知exportconstLowPriority=4;//空闲任务,比如在界面上隐藏意想不到的内容exportconstIdlePriority=5;//同时每个优先级对应的任务都对应一个过期时间constIMMEDIATE_PRIORITY_TIMEOUT=-1;constUSER_BLOCKING_PRIORITY_TIMEOUT=250;constUSER_BLOCKING_PRIORITY_TIMEOUTOR=250constLOW_PRIORITY_TIMEOUT=10000;constIDLE_PRIORITY_TIMEOUT=1073741823;constpriorityMap={[ImmediatePriority]:IMMEDIATE_PRIORITY_TIMEOUT,[UserBlockingPriority]:USER_BLOCKING_PRIORITY_TIMEOUT,[NormalPriority]:NORMAL_PRIORITY_TIMEOUT,[LowPriority]:LOW_PRIORITY_TIMEOUT,[IdlePriority]:IDLE_PRIORITY_TIMEOUT}Storage:taskQueue:Sortaccording到任务的过期时间(expirationTime),过期时间越小越紧急,过期时间越小的在前。过期时间根据任务优先级计算,优先级越高,过期时间越短。timerQueue:按照任务开始时间(startTime)排序。开始时间越小越早开始,开始时间小的排在最前面。当任务进来时,开始时间默认为当前时间。如果进入日程时超过了延迟时间,则开始时间为当前时间与延迟时间之和。当前时间:这里的当前时间不是Date.now(),而是window.performance.now(),它返回一个毫秒值,表示从打开当前页面到执行命令的毫秒数为比Date.now()更准确。进程Scheduler会周期性的将timerQueue中过期的任务放到taskQueue中,然后让scheduler通知executor循环taskQueue执行每一个任务。执行器控制每个任务的执行,一旦任务的执行时间超过时间片的限制。它会被中断,然后当前的执行者会退出。退出前会通知调度器调度新的执行器继续完成任务。新的executor在下一帧执行任务时,仍然会按照时间片中断任务,然后退出,重复这个过程,直到当前任务完全执行完毕,再将任务从taskQueue中踢出。taskQueue中的每一个任务都是这样处理的,最终所有的任务都完成了。这就是Scheduler的完整工作流程。算法缺陷React16中的expirationTimes模型比较任务的相对优先级。constisTaskIncludedInBatch=priorityOfTask>=priorityOfBatch;除非执行了更高优先级的任务,否则不允许执行更低优先级的任务。例如:给定优先级A>B>C,如果A不执行,则B不能执行;没有B和A,C就不能执行。这个限制是在Suspense之前设计的,在当时已经足够了。当所有执行都受CPU限制(计算密集型)时,除了按优先级外,不需要按任何顺序处理任务。但是当引入IO-bound(即Suspense)任务时,可能会遇到优先级较高的IO-bound任务会阻塞优先级较低的CPU-bound任务完成的情况。所以会有一些低优先级的任务不能一直执行。为了解决这个问题,React17采用了一种新的车道模型,这是一种更细粒度的启发式优先级更新算法。React17更新算法在新算法中,指定了一个连续的优先级区间,每次更新都会生成对应的页面快照,优先级包含在区间内。具体方法是:用一个31位的二进制表示31种可能。每一位称为一条车道(lane),代表优先级,若干条车道组成的二进制数称为一个车道,代表一批优先级。Lanes模型与ExpirationTimes模型相比有两个主要优点:Lanes模型结合了任务优先级的概念(例如:“任务A的优先级是否高于任务B?”)和批处理任务(例如:“任务A是否属于这组的任务?”)是分开的。通道可以用单一的32位数据类型表示许多不同的任务线程。说了这么多scheduler的优秀,你在使用新版react的时候可能还有这样的疑问:为什么我升级到react16,甚至react17,更新渲染组件还是同步的,页面性能是还是不好?React16-17需要开启Concurrent模式才能真正体验到Scheduler,这在react18将是默认模式。为了更好地配合调度器使任务可中断、可重用并按优先级执行,FiberReconciler必须将更新任务拆分成小任务。fiber拆分单元为fiber(节点上的一棵fiber树),实际上是按照虚拟DOM的节点进行拆分。FibernodefunctionFiberNode(tag:WorkTag,pendingProps:mixed,key:null|string,mode:TypeOfMode,){//实例//静态数据存储的属性//定义fiber的类型。在协调算法中使用它来确定需要完成的工作。this.tag=标签;this.key=键;this.elementType=null;//为一个描述他的组件,对于复合组件,type是函数或者类组件本身,对于标准组件(比如div,span),type是stringthis.type=null;//持有对组件、DOM节点或与纤程节点关联的其他React元素类型的类实例的引用。this.stateNode=null;//fiber关系相关属性,用于生成FiberTree结构this.return=null;this.child=null;this.sibling=null;这个.index=0;this.ref=null;//动态数据&状态相关的属性//newprops,新变化带来的新props,即nextPropsthis.pendingProps=pendingProps;//prevprops,用于在上次渲染时创建输出fiberprops//当传入的pendingProps和memoizedProps相同时,表示fiber可以复用之前的fiber,避免重复工作this.memoizedProps=null;//状态更新、回调和DOM更新队列,fiber对应的组件,产生的更新都会放在这个队列中。this.updateQueue=null;//当前屏幕UI对应的状态,上次输入更新的fiber状态this.memoizedState=null;//存储纤程依赖的上下文和事件的列表.dependencies=null;//conCurrentMode和strictMode//共存的模式表示子树默认是否异步渲染//刚创建fiber时,会继承父fiberthis.mode=mode;//Effects//当前fiberStages需要执行的任务,包括:placeholder,update,delete等this.flags=NoFlags;this.subtreeFlags=NoFlags;这个。删除=空;//优先级调度相关属性this.lanes=NoLanes;this.childLanes=NoLanes;//双缓存//当前树和工作在prgoress树相关的属性//在fiber树更新过程中,每个fiber有它对应的fiber//我们称之为current<==>workInProgress//渲染完成后会指向对方this.alternate=null;}我们看下面的例子:上面的JSX会生成下面的Fiber树,这里的父指针叫做return,而不是parent或者father,因为作为一个工作单元,return指的是该节点执行完后要返回的下一个节点Node,所以用return来指代父节点节点。双缓冲技术上面我们看到fiber节点中有一个属性是alternate,这就涉及到了双缓冲技术。简单概述什么是双缓冲技术?当我们看电视时,我们看到的画面称为OSD层。我们看到需要不断重绘屏幕,很容易造成屏幕闪烁。双缓冲使用内存缓冲区来解决这个问题。绘制操作首先提交给内存缓冲区,绘制完成后会在一定时间在OSD层上绘制。Reconciler应用双缓冲技术,React中最多同时有两棵Fiber树。当前屏幕显示的内容对应的Fiber树称为当前Fiber树,内存中正在构建的Fiber树称为workInProgressFiber树。当前Fiber树中的Fiber节点称为currentfiber,workInProgressFiber树中的Fiber节点称为workInProgressfiber,它们通过alternate属性连接。React应用的根节点通过在不同Fiber树的rootFiber之间切换当前指针,完成当前Fiber树指向的切换。即当workInProgressFiber树被构建并交付给Renderer在页面上渲染时,应用根节点的当前指针指向workInProgressFiber树,此时workInProgressFiber树成为当前Fiber树。每次状态更新都会生成一个新的workInProgressFiber树。通过current和workInProgress的替换,完成DOM更新协调过程。协调过程分为两个阶段:1.(Interruptible)render/reconciliation:通过构建workInProgress树获取change2。(不间断)提交:应用这些DOM更改过程。fiber节点遍历过程从当前(Root)开始,通过child向下查找。如果有child,先深入遍历子节点,直到为null,再检查是否有兄弟节点。如果有兄弟节点,则遍历兄弟节点,然后检查兄弟节点是否有子节点。如果没有其他兄弟节点,则返回查看父节点是否有兄弟节点。如果不是,则返回到根协调过程摘要。本文只分析Scheduler和Reconciler的原理。Fiber架构的核心重构部分。name最后我们通过一个例子整体来看一下这两部分的执行过程:上面例子在ReactFiber架构下的整个更新过程是:红框中的步骤随时可能被打断由于原因如下:还有其他更高优先级的任务需要先更新当前帧。没有时间了。由于红框中的工作是在内存中进行的,页面上的DOM不会更新,所以即使反复中断,用户也看不到未完全更新的DOM。