最新公告
  • 欢迎您光临网站无忧模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • react fiber 到底有多细

    正文概述 掘金(果洞)   2021-04-15   514

    前言

    Fiber 是对 React 核心算法的重构,facebook 团队使用两年多的时间去重构 React 的核心算法,在 React16 以上的版本中引入了 Fiber 架构,极大的提高了大型react项目的性能,在研究源码的过程中,发现其中运用了任务调度、双缓冲、等设计思想,非常值得我们学习。

    我们为什么需要react fiber

    react在进行组件渲染时,从setState开始到渲染完成整个过程是同步的(“一气呵成”)。如果需要渲染的组件比较庞大,js执行会占据主线程时间较长,会导致页面响应度变差,使得动画、手势交互等事件产生卡顿。

    为了解决这个问题,React 提供pureComponent,shouldComponentUpdate,useMemo,useCallback让开发者来操心哪些subtree是需要重新渲染的,哪些是不需要重新渲染的。究其本质,是因为 React 采用 jsx 语法过于灵活,不理解开发者写出代码所代表的意义,没有办法做出优化。

    因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。

    由此react fiber的任务就很清晰了

    1. 把渲染/更新过程拆分为更小的、可中断的工作单元
    2. 在浏览器空闲时执行工作循环
    3. 将所有执行结果汇总patch到真实DOM上

    工作单元

    如何拆分工作,这是最基础也是最重要的工作。

    拆什么,什么不能拆?

    把渲染/更新过程分为2个阶段(diff + patch):

    1.diff ~ render/reconciliation
    2.patch ~ commit
    

    diff的实际工作是对比prevInstancenextInstance的状态,找出差异及其对应的DOM change。diff本质上是一些计算(遍历、比较),是可拆分的(算一半待会儿接着算)

    patch阶段把本次更新中的所有DOM change应用到DOM树,是一连串的DOM操作。这些DOM操作虽然看起来也可以拆分(按照change list一段一段做),但这样做一方面可能造成DOM实际状态与维护的内部状态不一致,另外还会影响体验。而且,一般场景下,DOM更新的耗时比起diff及生命周期函数耗时不算什么,拆分的意义不很大

    所以,render/reconciliation阶段的工作(diff)可以拆分,commit阶段的工作(patch)不可拆分

    怎么拆?

    先凭空乱来几种diff工作拆分方案:

    • 按组件结构拆。不好分,无法预估各组件更新的工作量
    • 按实际工序拆。比如分为getNextState(), shouldUpdate(), updateState(), checkChildren()再穿插一些生命周期函数

    按组件拆太粗,显然对大组件不太公平。按工序拆太细,任务太多,频繁调度不划算。那么有没有合适的拆分单位?

    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(父节点)等属性,形成了如下的链表树结构:

    react fiber 到底有多细

    effectTag、nextEffect、firstEffect、lastEffect为effect相关信息,保存当前diff的成果。这些参数共同为后续的工作循环提供了可能,使react可以在执行完每个fiber时停下,根据浏览器的繁忙情况判断是否继续往下执行,因此我们也可以将fiber理解成一个工作单元

    至此,react fiber已经准备好了异步渲染的前置工作,接下来看看浏览器为其提供了哪些助攻。

    浏览器能力

    介绍浏览器能力之前,我们先了解下浏览器渲染的基础知识。

    渲染帧

    我们知道,在浏览器中,页面是一帧一帧绘制出来的,渲染的帧率与设备的刷新率保持一致。一般情况下,设备的屏幕刷新率为 1s 60次,当每秒内绘制的帧数(FPS)超过60时,页面渲染是流畅的;而当 FPS 小于60时,会出现一定程度的卡顿现象。下面来看完整的一帧中,具体做了哪些事情

    react fiber 到底有多细

    1. 首先需要处理输入事件,能够让用户得到最早的反馈
    2. 接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调
    3. 接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等
    4. 接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调
    5. 紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示
    6. 接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充

    到这时以上的六个阶段都已经完成了,接下来处于空闲阶段(Idle Peroid),可以在这时执行requestIdleCallback 里注册的任务(它就是 React Fiber 实现的基础)

    RequestIdleCallback

    RequestIdleCallback 是 react Fiber 实现的基础 api 。该方法将在浏览器的空闲时段内调用的函数排队,使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。正常帧任务完成后没超过16ms,说明有多余的空闲时间,此时就会执行requestIdleCallback里注册的任务。

    可以参考下图来理解requestIdleCallback在每帧中的调用

    ![image-20210413093218261](/Users/newman/Library/Application Support/typora-user-images/image-20210413093218261.png)

    1. 低优先级任务由requestIdleCallback处理;
    2. 高优先级任务,如动画相关的由requestAnimationFrame处理;
    3. 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 <= delay) {}
    };
    
    const taskQueue = [
      () => {
        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.logsleep(3)的工作,时间是非常短的(大约3ms多一点),浏览器计算此帧中还剩余15.5ms,足以一次执行完这三个任务,因此在此帧的空闲时间中,taskQueue中定义的三个任务均执行完毕。打印结果如下:

    ![image-20210413172443949](/Users/newman/Library/Application Support/typora-user-images/image-20210413172443949.png)

    多帧执行

    将task1、task2、task3中的睡眠时间提高至10ms:

    const sleep = (delay) => {
      const start = Date.now();
      while (Date.now() - start <= delay) {}
    };
    
    const taskQueue = [
      () => {
        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申请下一个时间片。打印结果如下:

    ![image-20210413172858532](/Users/newman/Library/Application Support/typora-user-images/image-20210413172858532.png)

    可以明显的看出任务1、2是在第一个帧内完成的,任务3在第二个。可能有人会好奇为什么第二帧的剩余时间和第一帧差那么多,这里可以理解为浏览渲染每帧的开始时间是不受渲染任务影响的,是固定不变16ms为一周期(60hz刷新频率下),也就是说执行第2个任务超时的那几毫秒不会推迟第二帧的开始时间,这里画了个图,可以帮助大家更好的理解这个问题:

    react fiber 到底有多细

    由此看来,应该避免在requestIdleCallback中执行过长时间的任务,否则可能会阻塞页面渲染,以及页面交互。当然也不建议在 requestIdleCallback 里再操作 DOM,这样会导致页面再次重绘。DOM 操作建议在 rAF 中进行。同时,操作 DOM 所需要的耗时是不确定的,因为会导致重新计算布局和视图的绘制,所以这类操作不具备可预测性。

    OK, requestIdleCallback 的基本信息也介绍完了,后面开始重点讲讲react fiber是如何搭配requestIdleCallback构建出fiber tree的。

    React fiber执行原理

    Fiber Tree 的构建过程,实际上也是diff的过程,也就是effect的收集过程,此过程会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 react 统称为副作用(effect),随着所有的节点(工作单元)在帧空闲时间逐个执行完毕,最后产出的结果是effect list,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。

    遍历流程

    首先我们需要大致了解下Fiber Tree 构建的遍历顺序,它会以旧的fiber tree为蓝本,把每个fiber作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新fiber tree)

    react fiber 到底有多细

    具体过程如下:

    1. 从顶点开始遍历
    2. 如果有子节点,先遍历子节点;
    3. 如果没有子节点,则看有没有兄弟节点,有则遍历兄弟节点,并把effect向上归并
    4. 如果没有兄弟节点,则看有没有父兄弟节点,有则遍历父兄弟节点
    5. 如果没有都没有了,那么遍历结束

    可以看看 performUnitOfWork 的实现,它其实就是一个深度优先的遍历:

    /**
     * @params fiber 当前需要处理的节点
     * @params topWork 本次更新的根节点
     */ 
    function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
      // 对该节点进行处理
      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;
      }
    }
    

    任务调度

    React fiber的遍历的过程并不是一蹴而就的,它以每个fiber作为一个工作单元,进行工作循环,工作循环中每次处理一个任务(工作单元),处理完毕有一次喘息的机会:

    // Flush asynchronous work until the deadline runs out of time.
    while (nextUnitOfWork !== null && !shouldYield()) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
    

    shouldYield就是看时间用完了没(idleDeadline.timeRemaining()),没用完的话继续处理下一个任务,用完了就结束,把时间控制权还给主线程,等下一次requestIdleCallback回调再接着做:

    // If there's work left over, schedule a new callback.
    if (nextFlushedExpirationTime !== NoWork) {
      scheduleCallbackWithExpiration(nextFlushedExpirationTime);
    }
    

    也就是说,(不考虑突发事件的)正常调度是由工作循环来完成的,基本规则是:每个工作单元结束检查是否还有时间做下一个,没时间了就先“挂起”

    React Fiber的工作调度与浏览器的核心交互流程如下:

    react fiber 到底有多细

    Reconciliation

    了解了遍历流程与任务调度方法之后,接下来就是就是我们熟知的Reconcilation阶段了(为了方便理解,这里不区分Diff和Reconcilation, 两者是同一个东西)。思路和 Fiber 重构之前差别不大, 只不过这里不会再递归去比对、而且不会马上提交变更

    具体过程如下(以组件节点为例):

    1. 如果当前节点不需要更新,直接把子节点clone过来,跳到5;要更新的话打个tag
    2. 更新当前节点状态(props, state, context等)
    3. 调用shouldComponentUpdate()false的话,跳到5
    4. 调用render()获得新的子节点,并为子节点创建fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里)
    5. 如果没有产生child fiber,该工作单元结束,把effect list归并到return,并把当前节点的sibling作为下一个工作单元;否则把child作为下一个工作单元
    6. 如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做
    7. 如果没有下一个工作单元了(回到了workInProgress tree的根节点),第1阶段结束,进入pendingCommit状态

    实际上是1-6的工作循环,7是出口,工作循环每次只做一件事,做完看要不要喘口气。工作循环结束时,workInProgress tree的根节点身上的effect list就是收集到的所有side effect(因为每做完一个都向上归并)

    BeginWork

    现在可以具体看看beginWork 是如何对 Fiber 进行比对的:

    function beginWork(fiber: Fiber): Fiber | undefined{
        if (fiber.tag === WorkTag.HostComponent) {
          // 宿主节点diff
          diffHostComponent(fiber)
        } elseif (fiber.tag === WorkTag.ClassComponent) {
          // 类组件节点diff
          diffClassComponent(fiber)
        } elseif (fiber.tag === WorkTag.FunctionComponent) {
          // 函数组件节点diff
          diffFunctionalComponent(fiber)
        } else {
          // ... 其他类型节点,省略
        }
      }
    

    宿主节点比对:

    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 < elements.length || oldFiber != null) {
        const prevFiber = newFiber;
        const element = elements[index];
        const sameType = isSameType(element, oldFiber);
        if (sameType) {
          newFiber = cloneFiber(oldFiber, element);
          // 更新关系
          newFiber.alternate = oldFiber;
          // 打上Tag
          newFiber.effectTag = UPDATE;
          newFiber.return = fiber;
        }
    
        // 新节点
        if (element && !sameType) {
          newFiber = createFiber(element);
          newFiber.effectTag = PLACEMENT;
          newFiber.return = fiber;
        }
    
        // 删除旧节点
        if (oldFiber && !sameType) {
          oldFiber.effectTag = DELETION;
          oldFiber.nextEffect = fiber.nextEffect;
          fiber.nextEffect = oldFiber;
        }
    
        if (oldFiber) {
          oldFiber = oldFiber.sibling;
        }
    
        if (index == 0) {
          fiber.child = newFiber;
        } else if (prevFiber && element) {
          prevFiber.sibling = newFiber;
        }
    
        index++;
      }
    }
    

    上面的代码很粗糙地还原了 Reconciliation 的过程, 但是对于我们理解React的基本原理已经足够了.

    这里引用一下Youtube: Lin Clark presentation in ReactConf 2017 的Slide,来还原 Reconciliation 的过程。

    react fiber 到底有多细

    上图是 Reconciliation 完成后的状态,左边是旧树,右边是WIP树。对于需要变更的节点,都打上了'标签'。在提交阶段,React 就会将这些打上标签的节点应用变更。

    双缓冲技术

    双缓冲技术(double buffering),就像redux里的nextListeners,以fiber tree为主,workInProgress tree为辅

    双缓冲具体指的是workInProgress tree构造完毕,得到的就是新的fiber tree,然后喜新厌旧(把current指针指向workInProgress tree,丢掉旧的fiber tree)就好了

    这样做的好处:

    • 能够复用内部对象(fiber)
    • 节省内存分配、GC的时间开销

    每个fiber上都有个alternate属性,也指向一个fiber,创建workInProgress节点时优先取alternate,没有的话就创建一个:

    let workInProgress = current.alternate;
    if (workInProgress === null) {
      //...这里很有意思
      workInProgress.alternate = current;
      current.alternate = workInProgress;
    } else {
      // We already have an alternate.
      // Reset the effect tag.
      workInProgress.effectTag = NoEffect;
    
      // The effect list is no longer valid.
      workInProgress.nextEffect = null;
      workInProgress.firstEffect = null;
      workInProgress.lastEffect = null;
    }
    

    如注释指出的,fiber与workInProgress互相持有引用,“喜新厌旧”之后,旧fiber就作为新fiber更新的预留空间,达到复用fiber实例的目的

    副作用的收集和提交

    接下来就是将所有打了 Effect 标记的节点串联起来,这个可以在completeWork中做, 例如:

    function completeWork(fiber) {
      const parent = fiber.return;
    
      // 到达顶端
      if (parent == null || fiber === topWork) {
        pendingCommit = fiber;
        return;
      }
    
      if (fiber.effectTag != null) {
        if (parent.nextEffect) {
          parent.nextEffect.nextEffect = fiber;
        } else {
          parent.nextEffect = fiber;
        }
      } else if (fiber.nextEffect) {
        parent.nextEffect = fiber.nextEffect;
      }
    }
    

    将所有副作用提交了

    function commitAllWork(fiber) {
      let next = fiber;
      while (next) {
        if (fiber.effectTag) {
          // 提交,偷一下懒,这里就不展开了
          commitWork(fiber);
        }
        next = fiber.nextEffect;
      }
    
      // 清理现场
      pendingCommit = nextUnitOfWork = topWork = null;
    }
    

    总结来说,就是通过每个节点更新结束时向上归并effect list来收集任务结果,reconciliation结束后,根节点的effect list里会记录包括DOM change在内的所有side effect,最后把所有副作用应用到真实DOM上。

    如何中断/断点恢复

    中断:检查当前正在处理的工作单元,保存当前成果(firstEffect, lastEffect),修改tag标记一下,迅速收尾并再开一个requestIdleCallback,下次有机会再做

    断点恢复:下次再处理到该工作单元时,看tag是被打断的任务,接着做未完成的部分或者重做

    总结

    其实稍一细想,从Stack reconciler到Fiber reconciler,源码层面就是干了一件递归改循环的事情(当然,实际做的事情远不止递归改循环,但这是第一步)

    总之,源码变化很大,如果对Fiber思路没有预先了解的话,看源码会比较艰难,感兴趣的朋友可以结合 react 源码继续研究。


    下载网 » react fiber 到底有多细

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元