写在最前:绝对是错漏百出的一篇博文,很多内容还没有写demo去验证,特别是浏览器的帧渲染那一块,权当小记。至于为什么想写Fiber,问就是Shopee面试的时候被问惨了,之前实习上班摸鱼的时候看过稀土掘金上一篇感觉很牛逼的文章就在介绍Fiber,但是自己没仔细记录,大概只记得Fiber到底说了什么事,结果就是被人家前端主管问到说不出话。广东流程全部完蛋,失败总是贯穿人生始终😢。
此外,本文内容大量参考了掘金上《React进阶实践指南》中的《调和与Fiber》这篇文章,以及B站上一个讲的很好的老师的视频。
浏览器的帧渲染 & requestIdleCallback
先扯几个无关紧要的概念:
- 并发:并发指的是在同一时间段内处理多个任务,但不一定是同时执行。它的核心概念是多个任务的交替进行,在某个时间段内,系统可以将多个任务放在一起管理,从而提高效率。例如,在单核 CPU 上,通过时间分片的方式,操作系统可以迅速切换任务,使得这些任务看起来像是同时在进行。尽管在任意时刻只会有一个任务在运行,但由于快速切换,用户可能感觉到多个任务同时在进行。
- 并行:并行指的是同时执行多个任务,通常需要多核或多处理器系统支持。在这种情况下,系统可以真正地同时处理多个任务。例如,在一个双核处理器的系统中,两个任务可以真正地同时被不同的核心处理,这样可以显著加快整体的处理速度。
- 进程:进程是计算机中正在执行的程序的实例。它是系统分配资源(如内存、文件句柄和 CPU 时间)的基本单位。每个进程都有自己独立的地址空间和资源,进程之间的资源是不共享的。操作系统为每个进程分配所需的资源,包括内存、文件描述符等。操作系统需要保存和恢复额外的上下文信息(如进程状态、内存映射等),因此切换会较为耗时。进程之间的通信相对复杂,通常使用进程间通信(IPC)机制,如管道、消息队列、共享内存和套接字等。
- 线程:线程是进程的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的资源,如内存和文件句柄。线程在创建和切换时比进程更轻,因为它们共享地址空间和其他资源。同一进程内的线程可以直接访问进程的内存空间和资源,这使得线程之间的通信更高效。上下文切换:线程切换的上下文开销较小,因为不需要切换内存映射等较重的状态。线程之间的通信更为简单,可以直接操作共享内存,但也需要考虑线程安全问题。
上面的东西就当我在放屁,现在开始讲前端。
在大多数现代浏览器中,浏览器会为每一个Tab、插件和其他后台功能分配独立的进程。而每一个Tab里面都有多个线程,不同的线程负责的工作内容各不相同。
但是,浏览器给每个Tab进程分配的,用来处理JS和浏览器内容绘制的主线程只有一个。所以我们常说“JS是单线程的,但是浏览器是多线程的”这甚至可以解释为什么JS要被设计成单线程。负责处理浏览器的用户界面、DOM 操作、事件处理和JS代码执行。所有的渲染和布局工作通常在主线程中执行,而且它是唯一可以直接访问 DOM 的线程。
除了主线程,常见的还有工作线程和渲染线程。工作线程是用来处理异步任务的,比如WebWorker、图像解码、AJAX等,当主线程在解析你的HTML文件等时候,如果碰到如script、link、img之类的标签想要下载其他资源、此时网络请求的发起就是靠的工作线程。至于渲染线程的存在是否与上面所提到的“主线程负责浏览器内容的绘制”是否矛盾,肯定是不矛盾的,因为太耗时的JS代码确实会让页面一直得不到更新,这说明主线程被JS阻塞,浏览器无法执行后序的绘制功能。此处的“渲染线程”应该与GPU相关,即我们常提到的为了优化动画效果而启用的“硬件加速”,或者说“合成层”。AI给出的答案也类似。
接着要说的就是浏览器在每一帧渲染之前都干了些什么事。接下来要说的绝对是这篇文章里最胡说八道的东西,有刻意迎合requestIdleCallback的感觉,但是是我目前能得到的最合理的理解。以后发现错了我再回来修改。
现代浏览器的刷新频率是多为60Hz,即每秒60帧,但实际上,浏览器根据应用运行情况给每一帧“预设”的生命周期是动态的调整的,不一定是16ms(1000ms / 60)。 为什么说是“预设”? 这与requestIdleCallback这个API有关,他的用法如下:
requestIdleCallback((deadline) => {console.log("在浏览器‘空闲’的时候,这个回调会被执行")console.log("当前帧剩下的时间为:", deadline.timeRemaining())
}, { timeout: 1000 })
// 如果超过1s还是没有空闲时间,强制调用回调函数
我们给requestIdleCallback传递的回调函数中的deadline是一个对象,deadline上的timeRemaining被调用时可以获取当前帧剩下了多少时间,这就是我认为为什么每一帧的生命周期都是事先被动态的“预设”好的。
接下来分享一下我对“阻塞”的看法:我始终认为,在每一帧中,浏览器执行的先后顺序是:
- 清空微任务
- 执行一个宏任务
- requestAnimationFrame
- 计算样式 & 布局更新 & 重绘重排
这样的一个线性过程 (虽然肯定有错,没这么简单)。在这一帧内如果上面这个过程执行的耗时没有超过这一帧被预设的“生命周期”,那浏览器就认为主线程是“空闲”的,有时间来执行requestIdleCallback里面的回调。但如果我的JS代码(宏任务+微任务)过分耗时,主线程会一直被阻塞,重绘和重排就一直无法进行,给用户的感觉就是卡顿和掉帧。
Fiber是什么东西
然后分享一下Fiber,这玩意到底有什么用?它是React16引入的新的协调算法,目的是提高React的性能和用户体验。Fiber通过实现更细粒度的任务划分和调度来解决React在更新和渲染过程中的一些性能问题。
对比一下React15的苦日子或许更好理解。在Fiber出现之前,React对VDOM Tree的扫描是从Root节点开始深度遍历,找出需要更新的节点。JS在浏览器里的递归是很可怕的,递归在满足退出条件之前一直无法中断,这意味着主线程可能被JS的执行持续阻塞,无法走到视图更新那一步。随着应用越来越复杂,React这种从Root开始深度遍历的做法会越来越耗时,带来影响就是“卡”。
这里引出一个问题:为什么Vue不需要Fiber?Vue使用双向数据绑定,这意味着模型与视图之间是紧密关联的。任何对模型的更改都会立即反映在视图中,反之亦然。这个特性使得Vue能够以更简单的方式管理视图的更新,不需要复杂的分片更新机制。说白了就是Vue的视图更新的细粒度比React细腻得多,React似乎总是避免不了从某个RootFiber开始深度遍历的命运,但是Vue在组件挂载的时候,通过发布订阅的方法就实现了模型与视图之间的双向数据绑定,当模型被更改,那个整个DOM Tree里哪个DOM应该更新,他捕获得更准确。
所以React顶不住了,搞了个Fiber,这到底是什么东西?从数据结构上看,一个Fiber就是一个VDOM。再加上一些额外的信息。不过在React的更新中确实如此:每个Fiber都是VDOM转化来的。而从功能上看,每个Fiber都是一个小的任务片段,是对整个VDOM Tree更新任务的一种拆分。
既然每个Fiber都是VDOM转化来的,那有VDOM Tree,自然也有Fiber Tree。Fiber Tree的更新可以大致分为下面两个阶段:render(调和)和commit(提交)。值得注意的是,我们常说的diff就是render里面很重要的阶段,或者说白了render就是diff。render的产物是EffectList(副作用链,单向链表),commit的责任就是拿着render在一定的时间内产出的EffectList去执行真实DOM的更新。整颗Fiber Tree完整的render是在多个时间碎片内被完成的,而每完成一次局部的render都会执行一次commit。
Fiber的render
先准备一个demo,这是整篇文章要用到的唯一的例子:
const Component = () => {return (<div id="A_1">Hello World!<div id="B_1"><div id="C_1"></div><div id="C_2"></div></div><div id="B_2"></div></div>)
}
上面提到过,有VDOM Tree就会有Fiber Tree,而在Fiber之前,对VDOM Tree的遍历是深度遍历,准确地说是类似于二叉树的先序遍历,但是每个VDOM的节点可以大于两个,所以VDOM Tree的“先序遍历”应该说是 “根,左到右”。而Fiber Tree的深度遍历顺序是与之前的VDOM Tree一样的。假设我们先给每个Fiber如下的数据结构:
type Fiber = {return: Fiber, // 当前Fiber的父Fiberchild: Fiber, // 当前Fiber的第一个子Fibersibling: Fiber, // 当前Fiber的下一个兄弟Fibertag: TAG_TEXT | TAG_HOST | TAG_ROOT, // 当前Fiber类型:文本 | 原生 | 根stateNode:null | HTMLElement, // 当前Fiber对应的真实DOMalternate: Fiber, // 在上一次更新中当前Fiber的“副本”,用来做新旧两次更新中的diff的对比props: {children: VDOM[], // 与当前Fiber对应的VDOM的children},nextEffect: Fiber, // 指向下一个产生副作用的FiberfirstEffect: Fiber, // 指向第一个产生副作用的FiberlastEffect: Fiber, // 指向最后一个产生副作用的Fiber
}
然后我们写一个函数performUnitWork,它处理当前Fiber,并返回下一个待处理的Fiber:
function performUnitOfWork(currentFiber) {// 处理当前FiberbeginWork(currentFiber);// 然后寻找下一个待处理的Fiber// 如果当前的Fiber有子Fiber,子Fiber就是下一个处理的Fiberif (currentFiber.child) {return currentFiber.child;}// 当前Fiber没有子Fiber,则认为当前Fiber已经处理完毕completeUnitOfWork(currentFiber);// 继续寻找:优先找当前Fiber的兄弟Fiber// 如果没有则找他的父Fiber的下一个兄弟Fiberwhile (currentFiber) {// 如果当前Fiber有兄弟Fiber,则接下来处理它的兄弟Fiberif (currentFiber.sibling) {return currentFiber.sibling;}// 如果当前Fiber已经没有兄弟Fiber,则他的父Fiber被视为已经处理完毕currentFiber = currentFiber.return;completeUnitOfWork(currentFiber);}
}
我们不纠结于上面算法的实现过程,只要知道它实现了一种树的“先序遍历”即可 (用数据结构那门课教的二叉树的根左右递归来写也很容易,但我懒)。需要注意的是,如果在寻找下一个要处理的Fiber的过程中,我们发现当前Fiber已经没有子Fiber,那我们就认为当前Fiber已处理完毕,执行相应操作。考虑我们上面的demo,那我们Fiber Tree的处理流程如下:A1—Hello World—B1—C1—C2—B2。
然后我们实现我们的beginWork,用来处理当前待操作的Fiber:
function beginWork(currentFiber) {switch (currentFiber.tag) {// 根Fiber: 根据当前Fiber创建子Fiber树case TAG_ROOT: {reconcileChildren(currentFiber);break;}// 文本Fiber: 如果当前文本Fiber的真实DOM还不存在,则创建真实DOMcase TAG_TEXT: {!currentFiber.stateNode && (currentFiber.stateNode = createDOM(currentFiber))break;}// 原生DOM节点Fiber: 如果当前Fiber的真实DOM还不存在,则创建真实DOM && 创建子Fiber树case TAG_HOST: {!currentFiber.stateNode && (currentFiber.stateNode = createDOM(currentFiber))reconcileChildren(currentFiber);break;}default:break;}
}
接下来实现reconcileChildren,这是重头戏,他根据传入的Fiber,来把与他对应VDOM的所有子VDOM都转化成Fiber,并把第一个子Fiber与当前Fiber连接。而且diff就发生在这个阶段。diff把处于相同层级,拥有相同标签类型和相同key的Fiber认为“同一节点”,以复用。但在这个例子中我们忽略key,这玩意加进来少说都得多写一百行代码 (主要是太难了我不会)。而且注意我们对reconcileChildren的描述:“把当前Fiber对应VDOM的所有子VDOM都转化成Fiber”,reconcileChildren处理的只是“儿子”这一层,涉及不到“孙子”这一层,这就是对diff中 “相同层级” 的一个说明。
function reconcileChildren(currentFiber) {const newChildren = currentFiber.props.children;let newChildIndex = 0; // 新子Fiber的索引let oldFiber = currentFiber.alternate && currentFiber.alternate.child; // 旧Fiber的第一个子Fiberlet prevSibling = null; // 前一个兄弟Fiberwhile (newChildIndex < newChildren.length || oldFiber) {let newChild = newChildren[newChildIndex]; // 新的VDOMlet newFiber = null;const sameType = oldFiber && newChild && oldFiber.type === newChild.type; // 类型相同let tag = undefined; // 节点类型if (newChild && newChild.type === ELEMENT_TEXT) {/// <div>Hello, world!</div>里面的Hello, world!tag = TAG_TEXT;} else if (newChild && typeof newChild.type === 'string') {// 原生DOM节点tag = TAG_HOST;}// 执行的是更新的操作,tag、type、stateNode是可以复用的if (sameType) {newFiber = {...oldFiber,props: newChild.props,return: currentFiber,alternate: oldFiber,effectTag: UPDATE,nextEffect: null}} else if (!sameType && newChild) {newFiber = {tag,type: newChild.type, // 节点类型: div, span...props: newChild.props,stateNode: null, // 页面内对应的真实DOMreturn: currentFiber, // 父FibereffectTag: PLACEMENT, // 副作用类型: 新增节点nextEffect: null, // 指向下一个副作用, effectList是一个单链表}} else if (!sameType && oldFiber) {oldFiber.effectTag = DELETION;deletions.push(oldFiber); // React全局会维护一个deletions数组,用来记录需要删除的Fiber}if (oldFiber) { oldFiber = oldFiber.sibling }if (newFiber) {if (newChildIndex === 0) {// 第一个子节点currentFiber.child = newFiber;} else {prevSibling.prevSibling = newFiber;}prevSibling = newFiber;}newChildIndex++;}
}
reconcileChildren的关键就是那个while循环,他迭代的是一条链表。始终记住:reconcileChildren处理的只是“儿子”这一层。
然后在render中最后一个需要实现的函数就是completeUnitOfWork,它在performUnitWork(处理当前Fiber并返回下一个待处理的Fiber)中被调用。在performUnitWork中我们规定:“当发现当前Fiber已经没有子Fiber,那我们就认为该Fiber已处理完毕”,需要执行这个Fiber副作用收集。这里的“副作用”是什么东西呢?简单理解就是:当前Fiber处理完后,浏览器在之后的渲染中要做出的有关于这个Fiber的更新。 上面提到过每个Fiber都对应一个VDOM,所以每个Fiber都可能产出自己的Effect,在一个Fiber被处理完后我们要收集他的Effect,以构建EffectList。说到底,EffectList就是render的目标,commit就是拿着EffectList,从表头到表尾,更新真实DOM。
但是有一个很有趣的地方值得注意:Fiber遍历的顺序和EffectList构建的顺序是不一样的。Fiber的遍历是类似于先序遍历的深度优先,他先处理的一直是根节点然后才是子节点,这很好理解:想一下我们平时用React开发的时候,组件的生命周期总是父组件先开始,然后才是子组件。但是EffectList构建的顺序却类似于后序遍历,他是从叶子结点开始的,是一种 “左到右,根” 的顺序。以上面的demo为例,EffectList的构建顺序如下:Hello World—C1—C2—B1—B2—A1。所以我们的completeUnitOfWork就是为了实现一个“后序遍历”的算法,实现的过程不必细究,甚至用数据结构教的算法更好懂。
function completeUnitOfWork(currentFiber) {const returnFiber = currentFiber.return;if (returnFiber) {if (!returnFiber.firstEffect) {returnFiber.firstEffect = currentFiber.firstEffect}if (currentFiber.lastEffect) {if (returnFiber.lastEffect) {returnFiber.lastEffect.nextEffect = currentFiber.firstEffect}returnFiber.firstEffect = currentFiber.lastEffect}if (currentFiber.effectTag) {if (returnFiber.lastEffect) {returnFiber.lastEffect.nextEffect = currentFiber} else {returnFiber.firstEffect = currentFiber}returnFiber.firstEffect = currentFiber}}
}
上面这些就是React的render中一些比较关键的思想。总结起来就是:整个Fiber Tree的render的目的就是收集Effect,但完整的render是在多个时间碎片内完成的。如何分片我们在commit说。
Fiber的commit
如何解决Fiber Tree的遍历带来的阻塞问题才是核心。直接上代码:
let nextUnidOfWork = currentFiber; // 下一个要处理的Fiber
function workLoop(deadline) {// 是否把控制权归还给主线程的渲染let shouldYield = false; while (nextUnitOfWork && !shouldYield) {nextUnitOfWork = performUnitOfWork(nextUnitOfWork);// 或许当前帧渲染剩下的空余时间shouldYield = deadline.timeRemaining() < 1;}if (!nextUnitOfWork) {console.log('render is done')commitRoot();// 在有限的时间内,能处理多少个fiber就处理多少个,反正我要提交了// 我要把控制权归还给主线程的渲染,让它继续渲染下一帧,直到渲染结束,这个过程都控制权都一直在浏览器那里,Js代码无法执行}requestIdleCallback(workLoop, { timeout: 1000 });
}
requestIdleCallback(workLoop, { timeout: 1000 });
上面的代码就很直观了,我们在浏览器每一帧的空闲时间内,能处理多少个Fiber就处理多少个Fiber,而且我们全局维护下一个需要处理的Fiter,保证我们的遍历不用从头开始。commitRoot的作用也很直观,拿着当前不完整的EffectList去更新真实DOM。
Fiber的其他功能
- 任务优先级
(这个应该是每个Effect节点上的额外属性。高优先级的任务,如处理用户输入,动画等,会在EffectList里排靠前的位置;内存清理、日志记录等低优先级的任务靠后。) - 错误边界
(没了解,哈哈。好困五点半了我要抽根烟睡觉了。记录完Fiber,Shopee真的痛的一批啊🤯)