找回密码
 会员注册
查看: 18|回复: 0

reactfiber到底有多细

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
72302
发表于 2024-10-6 14:12:03 | 显示全部楼层 |阅读模式
点击关注“有赞coder”获取更多技术干货哦~作者:坚果部门:业务技术/前端前言Fiber 是对 React 核心算法的重构,facebook 团队使用两年多的时间去重构 React 的核心算法,在 React16 以上的版本中引入了 Fiber 架构,极大的提高了大型react项目的性能,也激发了我对其实现的好奇。在研究源码的过程中,能发现很多比较细的点,有任务单元拆分的细,有任务调度、双缓冲、节点复用等优化的细,都非常值得我们学习,接下来就带大家看看react fiber 到底有多细。一、我们为什么需要react fiberreact在进行组件渲染时,从setState开始到渲染完成整个过程是同步的(“一气呵成”)。如果需要渲染的组件比较庞大,js执行会占据主线程时间较长,会导致页面响应度变差,使得动画、手势交互等事件产生卡顿。为了解决这个问题,React 提供pureComponent,shouldComponentUpdate,useMemo,useCallback让开发者来操心哪些subtree是需要重新渲染的,哪些是不需要重新渲染的。究其本质,是因为 React 采用 jsx 语法过于灵活,不理解开发者写出代码所代表的意义,没有办法做出优化。为什么JS长时间执行会影响交互响应、动画?因为JavaScript在浏览器的主线程上运行,恰好与样式计算、布局以及许多情况下的绘制一起运行。如果JavaScript运行时间过长,就会阻塞这些其他工作,可能导致掉帧。因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。由此react fiber的任务就很清晰了把渲染/更新过程拆分为更小的、可中断的工作单元在浏览器空闲时执行工作循环将所有执行结果汇总patch到真实DOM上二、工作单元如何拆分工作,这是最基础也是最重要的工作。2.1 拆什么,什么不能拆?把渲染/更新过程分为2个阶段(diff + patch):1.diff ~ render/reconciliation2.patch ~ commitdiff的实际工作是对比prevInstance和nextInstance的状态,找出差异及其对应的DOM change。diff本质上是一些计算(遍历、比较),是可拆分的(算一半待会儿接着算) patch阶段把本次更新中的所有DOM change应用到DOM树,是一连串的DOM操作。这些DOM操作虽然看起来也可以拆分(按照change list一段一段做),但这样做一方面可能造成DOM实际状态与维护的内部状态不一致,另外还会影响体验。而且,一般场景下,DOM更新的耗时比起diff及生命周期函数耗时不算什么,拆分的意义不很大所以,render/reconciliation阶段的工作(diff)可以拆分,commit阶段的工作(patch)不可拆分2.2 怎么拆?先凭空乱来几种diff工作拆分方案:按组件结构拆。不好分,无法预估各组件更新的工作量按实际工序拆。比如分为getNextState(), shouldUpdate(), updateState(), checkChildren()再穿插一些生命周期函数按组件拆太粗,显然对大组件不太公平。按工序拆太细,任务太多,频繁调度不划算。那么有没有合适的拆分单位?2.3 Fiber有。react的拆分单位是fiber(fiber tree上的一个节点),实际上就是按虚拟DOM节点拆,因为fiber tree是根据vDOM tree构造出来的,树结构一模一样,只是节点携带的信息有差异。fiber tree上各节点的主要结构如下:// fiber tree节点结构{ // The local state associated with this fiber. stateNode, // Singly Linked List Tree Structure. child, return, sibling, // Effect effectTag, // Singly linked list fast path to the next fiber with side-effects. nextEffect, // The first and last fiber with side-effect within this subtree. This allows // us to reuse a slice of the linked list when we reuse the work done within // this fiber. firstEffect, lastEffect, ...}其中的 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,形成了如下的链表树结构:?而effectTag、nextEffect、firstEffect、lastEffect为effect相关信息,保存当前diff的成果。这些参数共同为后续的工作循环提供了可能,使react可以在执行完每个fiber时停下,根据浏览器的繁忙情况判断是否继续往下执行,因此我们也可以将fiber理解成一个工作单元。至此,react fiber已经准备好了异步渲染的前置工作,接下来看看浏览器为其提供了哪些助攻。三、浏览器能力介绍浏览器能力之前,我们先了解下浏览器渲染的基础知识。3.1 渲染帧我们知道,在浏览器中,页面是一帧一帧绘制出来的,渲染的帧率与设备的刷新率保持一致。一般情况下,设备的屏幕刷新率为 1s 60次,当每秒内绘制的帧数(FPS)超过60时,页面渲染是流畅的;而当 FPS 小于60时,会出现一定程度的卡顿现象。下面来看完整的一帧中,具体做了哪些事情首先需要处理输入事件,能够让用户得到最早的反馈接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充到这时以上的六个阶段都已经完成了,接下来处于空闲阶段(Idle Peroid),可以在这时执行requestIdleCallback里注册的任务(它就是 React Fiber 任务调度实现的基础)3.2 RequestIdleCallbackRequestIdleCallback 是 react Fiber 实现的基础 api 。该方法将在浏览器的空闲时段内调用的函数排队,使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。正常帧任务完成后没超过16ms,说明有多余的空闲时间,此时就会执行requestIdleCallback里注册的任务。可以参考下图来理解requestIdleCallback在每帧中的调用低优先级任务由requestIdleCallback处理;高优先级任务,如动画相关的由requestAnimationFrame处理;requestIdleCallback可以在多个空闲期调用空闲期回调,执行任务;window.requestIdleCallback(callback)的callback中会接收到默认参数 deadline ,其中包含了以下两个属性:timeRamining 返回当前帧还剩多少时间供用户使用didTimeout 返回 callback 任务是否超时requestIdleCallback?方法非常重要,下面分别讲两个例子来理解这个方法,在每个例子中都需要执行多个任务,但是任务的执行时间是不一样的,下面来看浏览器是如何分配时间执行这些任务的:一帧执行直接执行task1、task2、task3,各任务的时间总和小于16ms:const sleep = (delay) => { const start = Date.now(); while (Date.now() - start { console.log("task1 start"); sleep(3); console.log("task1 end"); }, () => { console.log("task2 start"); sleep(3); console.log("task2 end"); }, () => { console.log("task3 start"); sleep(3); console.log("task3 end"); },];const performUnitWork = () => { // 取出第一个队列中的第一个任务并执行 taskQueue.shift()();};const workloop = (deadline) => { console.log(`此帧的剩余时间为: ${deadline.timeRemaining()}`); // 如果此帧剩余时间大于0或者已经到了定义的超时时间(上文定义了timeout时间为1000,到达时间时必须强制执行),且当时存在任务,则直接执行这个任务 // 如果没有剩余时间,则应该放弃执行任务控制权,把执行权交还给浏览器 while ( (deadline.timeRemaining() > 0 || deadline.didTimeout) & taskQueue.length > 0 ) { performUnitWork(); } // 如果还有未完成的任务,继续调用requestIdleCallback申请下一个时间片 if (taskQueue.length > 0) { window.requestIdleCallback(workloop, { timeout: 1000 }); }};requestIdleCallback(workloop, { timeout: 1000 });上面定义了一个任务队列taskQueue,并定义了workloop函数,其中采用window.requestIdleCallback(workloop, { timeout: 1000 })去执行taskQueue中的任务。每个任务中仅仅做了console.log、sleep(3)的工作,时间是非常短的(大约3ms多一点),浏览器计算此帧中还剩余15.5ms,足以一次执行完这三个任务,因此在此帧的空闲时间中,taskQueue中定义的三个任务均执行完毕。打印结果如下:?多帧执行将task1、task2、task3中的睡眠时间提高至10ms:const sleep = (delay) => { const start = Date.now(); while (Date.now() - start { console.log("task1 start"); sleep(10); console.log("task1 end"); }, () => { console.log("task2 start"); sleep(10); console.log("task2 end"); }, () => { console.log("task3 start"); sleep(10); console.log("task3 end"); },];const performUnitWork = () => { taskQueue.shift()();};const workloop = (deadline) => { console.log(`此帧的剩余时间为: ${deadline.timeRemaining()}`); while ( (deadline.timeRemaining() > 0 || deadline.didTimeout) & taskQueue.length > 0 ) { performUnitWork(); } if (taskQueue.length > 0) { window.requestIdleCallback(workloop, { timeout: 1000 }); }};requestIdleCallback(workloop, { timeout: 1000 });每个任务的时间被提高到10ms之后,在执行第1个任务时还能在第一帧剩余的时间里完成,在准备执行第2个任务时,虽然剩余的时间(还剩5ms左右)不够10ms,但由于浏览器并不知道回调函数会执行多久,所以依然还是会在此帧内执行第2个任务(这也会导致下一帧的渲染延迟),到第3个任务时,当前帧肯定是已经没有空余时间了,那么就再次调用requestIdleCallback申请下一个时间片。打印结果如下:?可以明显的看出任务1、2是在第一个帧内完成的,任务3在第二个。可能有人会好奇为什么第二帧的剩余时间和第一帧差那么多,这里可以理解为浏览渲染每帧的开始时间是不受渲染任务影响的,是固定不变16ms为一周期(60hz刷新频率下),也就是说执行第2个任务超时的那几毫秒不会推迟第二帧的开始时间,或者可以理解第2个任务(抢夺)了这第二帧一些时间,这里画了个图,可以帮助大家更好的理解这个问题:由此看来,应该避免在requestIdleCallback中执行过长时间的任务,否则可能会阻塞页面渲染,以及页面交互。当然也不建议在requestIdleCallback?里再操作 DOM,这样会导致页面再次重绘。DOM 操作建议在 rAF 中进行。同时,操作 DOM 所需要的耗时是不确定的,因为会导致重新计算布局和视图的绘制,所以这类操作不具备可预测性。Promise 也不建议在这里面进行,因为 Promise 的回调属性 Event loop 中优先级较高的一种微任务,会在?requestIdleCallback?结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms。OK,?requestIdleCallback?的基本信息也介绍完了,后面开始重点讲讲react fiber是如何搭配requestIdleCallback构建出fiber tree的。四、React fiber执行原理Fiber Tree 的构建过程,实际上也是diff的过程,也就是effect的收集过程,此过程会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 react 统称为副作用(effect),随着所有的节点(工作单元)在帧空闲时间逐个执行完毕,最后产出的结果是effect list,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。4.1 任务调度React fiber的构建的过程并不是一蹴而就的,它以每个fiber作为一个工作单元,进行工作循环,工作循环中每次处理一个任务(工作单元),处理完毕有一次喘息的机会:// Flush asynchronous work until there's a higher priority eventwhile (nextUnitOfWork !== null & !shouldYieldToRenderer()) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork);}shouldYieldToRenderer就是看时间用完了没,没用完的话继续处理下一个任务,用完了就结束,把时间控制权还给主线程,等下一次requestIdleCallback回调再接着做。但如果当前渲染执行很长一段时间后还未结束,那么就不再会喘息,而是一次性把剩余工作全部做完。if (!isYieldy) { // Flush work without yielding while (nextUnitOfWork !== null) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); }}React Fiber的工作调度与浏览器的核心交互流程如下:?4.2 遍历流程Fiber Tree 构建的遍历顺序,它会以旧的fiber tree为蓝本,把每个fiber作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新fiber tree)具体过程如下:从顶点开始遍历如果有子节点,先遍历子节点;如果没有子节点,则看有没有兄弟节点,有则遍历兄弟节点,并把effect向上归并如果没有兄弟节点,则看有没有父兄弟节点,有则遍历父兄弟节点如果没有都没有了,那么遍历结束其实就是一个深度优先的遍历可以先看看继续看看任务调度中?performUnitOfWork大致的实现:function performUnitOfWork(fiber: Fiber, topWork: Fiber) { next = beginWork(current, workInProgress, nextRenderExpirationTime); if (next === null) { // If this doesn't spawn new work, complete the current work. next = completeUnitOfWork(workInProgress); } return next}这一块代码可以看出下一个工作单元是的确定,是由当前工作单元beginWork之后得到的,这也合情合理,毕竟没执行过当前工作单元,也就无法得知有无子节点生成。那我们继续看看beginWork中是如何判断下一个工作单元的。switch (workInProgress.tag) { case HostComponent: { return updateHostComponent(current, workInProgress, renderExpirationTime); } case ClassComponent: { return updateClassComponent( current, workInProgress, Component, resolvedProps, renderExpirationTime ); } case FunctionComponent: { return updateFunctionComponent( current, workInProgress, Component, resolvedProps, renderExpirationTime ); } // ...}可以看到beginWork里根据不同的fiber节点类型执行了不同的函数来获得结果,似乎这一层也不能很清晰的看出来是怎么确定出下一个工作单元的,由于这一块实际逻辑比较复杂,就不再深入展示了,具体实验之后会发现,遍历逻辑大致如下:// 如果存在子节点,那么下一个待处理的就是子节点if (fiber.child) { return fiber.child;}// 没有子节点了,上溯查找兄弟节点let temp = fiber;while (temp) { completeWork(temp); // 到顶层节点了, 退出 if (temp === topWork) { break; } // 找到,下一个要处理的就是兄弟节点 if (temp.sibling) { return temp.sibling; } // 没有, 继续上溯 temp = temp.return;}4.3 Reconciliation了解了遍历流程与任务调度方法之后,接下来就是就是我们熟知的Reconcilation阶段了(为了方便理解,这里不区分Diff和Reconcilation, 两者是同一个东西)。思路和 Fiber 重构之前差别不大,只不过这里不会再递归去比对、而且不会马上提交变更。具体过程如下(以组件节点为例):如果当前节点不需要更新,直接把子节点clone过来,跳到5;要更新的话打个tag更新当前节点状态(props, state, context等)调用shouldComponentUpdate(),false的话,跳到5调用render()获得新的子节点,并为子节点创建fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里)如果没有产生child fiber,该工作单元结束,把effect list归并到return,并把当前节点的sibling作为下一个工作单元;否则把child作为下一个工作单元如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做如果没有下一个工作单元了(回到了workInProgress tree的根节点),第1阶段结束,进入pendingCommit状态实际上是1-6的工作循环,7是出口,工作循环每次只做一件事,做完看要不要喘口气。工作循环结束时,workInProgress tree的根节点身上的effect list就是收集到的所有side effect(因为每做完一个都向上归并)BeginWork再回到beginWork?具体看看它是如何对 Fiber 进行执行的(简化版):function beginWork(fiber: Fiber): Fiber | undefined{ // 宿主节点diff case HostComponent: { return updateHostComponent(current, workInProgress, renderExpirationTime); } // 类组件节点diff case ClassComponent: { return updateClassComponent( current, workInProgress, Component, resolvedProps, renderExpirationTime ); } // 函数组件节点diff case FunctionComponent: { return updateFunctionComponent( current, workInProgress, Component, resolvedProps, renderExpirationTime ); } // ... 其他类型节点,省略}宿主节点比对:function diffHostComponent(fiber: Fiber) { // 新增节点 if (fiber.stateNode == null) { fiber.stateNode = createHostComponent(fiber); } else { updateHostComponent(fiber); } const newChildren = fiber.pendingProps.children; // 比对子节点 diffChildren(fiber, newChildren);}类组件节点比对也差不多:function diffClassComponent(fiber: Fiber) { // 创建组件实例 if (fiber.stateNode == null) { fiber.stateNode = createInstance(fiber); } if (fiber.hasMounted) { // 调用更新前生命周期钩子 applybeforeUpdateHooks(fiber); } else { // 调用挂载前生命周期钩子 applybeforeMountHooks(fiber); } // 渲染新节点 const newChildren = fiber.stateNode.render(); // 比对子节点 diffChildren(fiber, newChildren); fiber.memoizedState = fiber.stateNode.state;}子节点比对:function diffChildren(fiber: Fiber, newChildren: React.ReactNode) { let oldFiber = fiber.alternate ? fiber.alternate.child : null; // 全新节点,直接挂载 if (oldFiber == null) { mountChildFibers(fiber, newChildren); return; } let index = 0; let newFiber = null; // 新子节点 const elements = extraElements(newChildren); // 比对子元素 while (index
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-11 10:58 , Processed in 0.451519 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表