这是第 83 篇不掺水的原创,想获取更多原创好文,请搜索公众号关注咱们吧~ 本文首发于政采云前端博客: 前端工程师的自我修养:React Fiber 是如何实现更新过程可控的
从 React 16 开始,React 采用了 Fiber 机制替代了原先基于原生执行栈递归遍历 VDOM 的方案,提升了页面渲染性能和用户体验。乍一听 Fiber 好像挺神秘,在原生执行栈都还没搞懂的状况下,又整出个 Fiber,还能不能愉快的写代码了。别慌,老铁!下面就来唠唠关于 Fiber 那点事儿。javascript
Fiber 的英文含义是“纤维”,它是比线程(Thread)更细的线,比线程(Thread)控制得更精密的执行模型。在广义计算机科学概念中,Fiber 又是一种协做的(Cooperative)编程模型,帮助开发者用一种【既模块化又协做化】的方式来编排代码。html
简单点说,Fiber 就是 React 16 实现的一套新的更新机制,让 React 的更新过程变得可控,避免了以前一竿子递归到底影响性能的作法。前端
页面的内容都是一帧一帧绘制出来的,浏览器刷新率表明浏览器一秒绘制多少帧。目前浏览器大可能是 60Hz(60帧/s),每一帧耗时也就是在 16ms 左右。原则上说 1s 内绘制的帧数也多,画面表现就也细腻。那么在这一帧的(16ms) 过程当中浏览器又干了啥呢?java
经过上面这张图能够清楚的知道,浏览器一帧会通过下面这几个过程:react
第七步的 RIC 事件不是每一帧结束都会执行,只有在一帧的 16ms 中作完了前面 6 件事儿且还有剩余时间,才会执行。这里提一下,若是一帧执行结束后还有时间执行 RIC 事件,那么下一帧须要在事件执行结束才能继续渲染,因此 RIC 执行不要超过 30ms,若是长时间不将控制权交还给浏览器,会影响下一帧的渲染,致使页面出现卡顿和事件响应不及时。算法
React Fiber 出现以前,React 经过原生执行栈递归遍历 VDOM。当浏览器引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并将其压入执行栈,接下来每遇到一个函数调用,又会往栈中压入一个新的上下文。好比:编程
function A(){ B(); C(); } function B(){} function C(){} A();
引擎在执行的时候,会造成以下这样的执行栈: 数组
浏览器引擎会从执行栈的顶端开始执行,执行完毕就弹出当前执行上下文,开始执行下一个函数,直到执行栈被清空才会中止。而后将执行权交还给浏览器。因为 React 将页面视图视做一个个函数执行的结果。每个页面每每由多个视图组成,这就意味着多个函数的调用。浏览器
若是一个页面足够复杂,造成的函数调用栈就会很深。每一次更新,执行栈须要一次性执行完成,中途不能干其余的事儿,只能"一心一意"。结合前面提到的浏览器刷新率,JS 一直执行,浏览器得不到控制权,就不能及时开始下一帧的绘制。若是这个时间超过 16ms,当页面有动画效果需求时,动画由于浏览器不能及时绘制下一帧,这时动画就会出现卡顿。不只如此,由于事件响应代码是在每一帧开始的时候执行,若是不能及时绘制下一帧,事件响应也会延迟。前端工程师
时间分片指的是一种将多个粒度小的任务放入一个时间切片(一帧)中执行的一种方案,在 React Fiber 中就是将多个任务放在了一个时间片中去执行。
在 React Fiber 中用链表遍历的方式替代了 React 16 以前的栈递归方案。在 React 16 中使用了大量的链表。例如:
例以下面这个组件:
<div id="id"> A1 <div id="B1"> B1 <div id="C1"></div> </div> <div id="B2"> B2 </div> </div>
会使用下面这样的链表表示:
链表是一种简单高效的数据结构,它在当前节点中保存着指向下一个节点的指针,就好像火车同样一节连着一节
遍历的时候,经过操做指针找到下一个元素。可是操做指针时(调整顺序和指向)必定要当心。
链表相比顺序结构数据格式的好处就是:
但链表也不是完美的,缺点就是:
React 用空间换时间,更高效的操做能够方便根据优先级进行操做。同时能够根据当前节点找到其余节点,在下面提到的挂起和恢复过程当中起到了关键做用。
前面讲完基本知识,如今正式开始介绍今天的主角 Fiber,看看 React Fiber 是如何实现对更新过程的管控。
更新过程的可控主要体如今下面几个方面:
前面提到,React Fiber 以前是基于原生执行栈,每一次更新操做会一直占用主线程,直到更新完成。这可能会致使事件响应延迟,动画卡顿等现象。
在 React Fiber 机制中,它采用"化整为零"的战术,将调和阶段(Reconciler)递归遍历 VDOM 这个大任务分红若干小任务,每一个任务只负责一个节点的处理。例如:
import React from "react"; import ReactDom from "react-dom" const jsx = ( <div id="A1"> A1 <div id="B1"> B1 <div id="C1">C1</div> <div id="C2">C2</div> </div> <div id="B2">B2</div> </div> ) ReactDom.render(jsx,document.getElementById("root"))
这个组件在渲染的时候会被分红八个小任务,每一个任务用来分别处理 A1(div)、A1(text)、B1(div)、B1(text)、C1(div)、C1(text)、C2(div)、C2(text)、B2(div)、B2(text)。再经过时间分片,在一个时间片中执行一个或者多个任务。这里提一下,全部的小任务并非一次性被切分完成,而是处理当前任务的时候生成下一个任务,若是没有下一个任务生成了,就表明本次渲染的 Diff 操做完成。
再说挂起、恢复、终止以前,不得不提两棵 Fiber 树,workInProgress tree 和 currentFiber tree。
workInProgress 表明当前正在执行更新的 Fiber 树。在 render 或者 setState 后,会构建一颗 Fiber 树,也就是 workInProgress tree,这棵树在构建每个节点的时候会收集当前节点的反作用,整棵树构建完成后,会造成一条完整的反作用链。
currentFiber 表示上次渲染构建的 Filber 树。在每一次更新完成后 workInProgress 会赋值给 currentFiber。在新一轮更新时 workInProgress tree 再从新构建,新 workInProgress 的节点经过 alternate 属性和 currentFiber 的节点创建联系。
在新 workInProgress tree 的建立过程当中,会同 currentFiber 的对应节点进行 Diff 比较,收集反作用。同时也会复用和 currentFiber 对应的节点对象,减小新建立对象带来的开销。也就是说不管是建立仍是更新,挂起、恢复以及终止操做都是发生在 workInProgress tree 建立过程当中。workInProgress tree 构建过程其实就是循环的执行任务和建立下一个任务,大体过程以下:
当没有下一个任务须要执行的时候,workInProgress tree 构建完成,开始进入提交阶段,完成真实 DOM 更新。
在构建 workInProgressFiber tree 过程当中能够经过挂起、恢复和终止任务,实现对更新过程的管控。下面简化了一下源码,大体实现以下:
let nextUnitWork = null;//下一个执行单元 //开始调度 function shceduler(task){ nextUnitWork = task; } //循环执行工做 function workLoop(deadline){ let shouldYield = false;//是否要让出时间片交出控制权 while(nextUnitWork && !shouldYield){ nextUnitWork = performUnitWork(nextUnitWork) shouldYield = deadline.timeRemaining()<1 // 没有时间了,检出控制权给浏览器 } if(!nextUnitWork) { conosle.log("全部任务完成") //commitRoot() //提交更新视图 } // 若是还有任务,可是交出控制权后,请求下次调度 requestIdleCallback(workLoop,{timeout:5000}) } /* * 处理一个小任务,其实就是一个 Fiber 节点,若是还有任务就返回下一个须要处理的任务,没有就表明整个 */ function performUnitWork(currentFiber){ .... return FiberNode }
当第一个小任务完成后,先判断这一帧是否还有空闲时间,没有就挂起下一个任务的执行,记住当前挂起的节点,让出控制权给浏览器执行更高优先级的任务。
在浏览器渲染完一帧后,判断当前帧是否有剩余时间,若是有就恢复执行以前挂起的任务。若是没有任务须要处理,表明调和阶段完成,能够开始进入渲染阶段。这样完美的解决了调和过程一直占用主线程的问题。
那么问题来了他是如何判断一帧是否有空闲时间的呢?答案就是咱们前面提到的 RIC (RequestIdleCallback) 浏览器原生 API,React 源码中为了兼容低版本的浏览器,对该方法进行了 Polyfill。
当恢复执行的时候又是如何知道下一个任务是什么呢?答案在前面提到的链表。在 React Fiber 中每一个任务其实就是在处理一个 FiberNode 对象,而后又生成下一个任务须要处理的 FiberNode。顺便提一嘴,这里提到的FiberNode 是一种数据格式,下面是它没有开美颜的样子:
class FiberNode { constructor(tag, pendingProps, key, mode) { // 实例属性 this.tag = tag; // 标记不一样组件类型,如函数组件、类组件、文本、原生组件... this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key ,也就是最终 ReactElement 上的 this.elementType = null; // createElement的第一个参数,ReactElement 上的 type this.type = null; // 表示fiber的真实类型 ,elementType 基本同样,在使用了懒加载之类的功能时可能会不同 this.stateNode = null; // 实例对象,好比 class 组件 new 完后就挂载在这个属性上面,若是是RootFiber,那么它上面挂的是 FiberRoot,若是是原生节点就是 dom 对象 // fiber this.return = null; // 父节点,指向上一个 fiber this.child = null; // 子节点,指向自身下面的第一个 fiber this.sibling = null; // 兄弟组件, 指向一个兄弟节点 this.index = 0; // 通常若是没有兄弟节点的话是0 当某个父节点下的子节点是数组类型的时候会给每一个子节点一个 index,index 和 key 要一块儿作 diff this.ref = null; // reactElement 上的 ref 属性 this.pendingProps = pendingProps; // 新的 props this.memoizedProps = null; // 旧的 props this.updateQueue = null; // fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会造成一个链表结构,最后作批量更新 this.memoizedState = null; // 对应 memoizedProps,上次渲染的 state,至关于当前的 state,理解成 prev 和 next 的关系 this.mode = mode; // 表示当前组件下的子组件的渲染方式 // effects this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新 this.nextEffect = null; // 指向下个须要更新的fiber this.firstEffect = null; // 指向全部子节点里,须要更新的 fiber 里的第一个 this.lastEffect = null; // 指向全部子节点中须要更新的 fiber 的最后一个 this.expirationTime = NoWork; // 过时时间,表明任务在将来的哪一个时间点应该被完成 this.childExpirationTime = NoWork; // child 过时时间 this.alternate = null; // current 树和 workInprogress 树之间的相互引用 } }
额…看着好像有点上头,这是开了美颜的样子:
是否是好看多了?在每次循环的时候,找到下一个执行须要处理的节点。
function performUnitWork(currentFiber){ //beginWork(currentFiber) //找到儿子,并经过链表的方式挂到currentFiber上,每一偶儿子就找后面那个兄弟 //有儿子就返回儿子 if(currentFiber.child){ return currentFiber.child; } //若是没有儿子,则找弟弟 while(currentFiber){//一直往上找 //completeUnitWork(currentFiber);//将本身的反作用挂到父节点去 if(currentFiber.sibling){ return currentFiber.sibling } currentFiber = currentFiber.return; } }
在一次任务结束后返回该处理节点的子节点或兄弟节点或父节点。只要有节点返回,说明还有下一个任务,下一个任务的处理对象就是返回的节点。经过一个全局变量记住当前任务节点,当浏览器再次空闲的时候,经过这个全局变量,找到它的下一个任务须要处理的节点恢复执行。就这样一直循环下去,直到没有须要处理的节点返回,表明全部任务执行完成。最后你们手拉手,就造成了一颗 Fiber 树。
其实并非每次更新都会走到提交阶段。当在调和过程当中触发了新的更新,在执行下一个任务的时候,判断是否有优先级更高的执行任务,若是有就终止原来将要执行的任务,开始新的 workInProgressFiber 树构建过程,开始新的更新流程。这样能够避免重复更新操做。这也是在 React 16 之后生命周期函数 componentWillMount 有可能会执行屡次的缘由。
React Fiber 除了经过挂起,恢复和终止来控制更新外,还给每一个任务分配了优先级。具体点就是在建立或者更新 FiberNode 的时候,经过算法给每一个任务分配一个到期时间(expirationTime)。在每一个任务执行的时候除了判断剩余时间,若是当前处理节点已通过期,那么不管如今是否有空闲时间都必须执行改任务。
同时过时时间的大小还表明着任务的优先级。
任务在执行过程当中顺便收集了每一个 FiberNode 的反作用,将有反作用的节点经过 firstEffect、lastEffect、nextEffect 造成一条反作用单链表 AI(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A。
其实最终都是为了收集到这条反作用链表,有了它,在接下来的渲染阶段就经过遍历反作用链完成 DOM 更新。这里须要注意,更新真实 DOM 的这个动做是一鼓作气的,不能中断,否则会形成视觉上的不连贯。
在 Fiber 机制中,最重要的一点就是须要实现挂起和恢复,从实现角度来讲 generator 也能够实现。那么为何官方没有使用 generator 呢?猜想应该是是性能方面的缘由。生成器不只让您在堆栈的中间让步,还必须把每一个函数包装在一个生成器中。一方面增长了许多语法方面的开销,另外还增长了任何现有实现的运行时开销。性能上远没有链表的方式好,并且链表不须要考虑浏览器兼容性。
这个问题其实有点搞事情,若是 Vue 真这么作了是否是就是变相认可 Vue 是在"集成" Angular 和 React 的优势呢?React 有 Fiber,Vue 就必定要有?
二者虽然都依赖 DOM Diff,可是实现上且有区别,DOM Diff 的目的都是收集反作用。Vue 经过 Watcher 实现了依赖收集,自己就是一种很好的优化。因此 Vue 没有采用 Fiber 机制,也无伤大雅。
React Fiber 的出现至关因而在更新过程当中引进了一个中场指挥官,负责掌控更新过程,足球世界里管这叫前腰。抛开带来的性能和效率提高外,这种“化整为零”和任务编排的思想,能够应用到咱们平时的架构设计中。
政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 40 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在平常的业务对接以外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推进并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。
若是你想改变一直被事折腾,但愿开始能折腾事;若是你想改变一直被告诫须要多些想法,却无从破局;若是你想改变你有能力去作成那个结果,却不须要你;若是你想改变你想作成的事须要一个团队去支撑,但没你带人的位置;若是你想改变既定的节奏,将会是“5 年工做时间 3 年工做经验”;若是你想改变原本悟性不错,但老是有那一层窗户纸的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但愿参与到随着业务腾飞的过程,亲手推进一个有着深刻的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我以为咱们该聊聊。任什么时候间,等着你写点什么,发给 ZooTeam@cai-inc.com