React的Fiber是什么? Vue为什么不需要Fiber ?

在React的早期版本,React渲染组件的时候必须递归的处理虚拟DOM树,这就意味着要等到整颗树都处理完毕后才会把控制权返回给浏览器,这就导致长时间的阻塞页面渲染,导致页面掉帧,Fiber就是为了解决这个问题出现的, 所以Fiber就是为了将大计算量的任务分片处理出现的,但同时Fiber也是React通向并发渲染的地基,分片是最能被看见的收益

Fiber为React带来的新活力

Fiber为React引入了在处理大任务的更多强大特性。在Fiber架构下,计算虚拟DOM和将虚拟DOM映射入真实DOM两步操作分为render阶段和commit阶段。

优先级调度

在render阶段React允许你中断任务,并且这个时候如果有高优先级别的任务要处理,React会调度暂停当前任务,立即执行高优先级别的任务,这就是React提供的优先级调度能力

中断

其中低优先级的任务被暂停的,这就是React提供的可中断能力

可恢复

对于被高优先级的任务,React是直接丢弃,对于时间切片的任务,使用nextUnitOfWork保存执行节点,在下一次执行恢复执行

双缓冲

在React的计算中,它不是去修改已有的虚拟dom树,而是从需要更新的组件开始(严格讲其实是从根开始,只是React通过优化手段把上面剪掉了,所以实际上是可以从订阅了状态的组件开始),重新计算一套虚拟DOM, 我们就得到了两套虚拟DOM, 一个是旧虚拟DOM, 这个虚拟DOM被映射在浏览器的界面上,可以说是current树,而我们在render阶段创建的新的虚拟dom树,可以说是work-in-progress树,这是React可随意中断恢复的基础。

特性 说明
可中断 render阶段的工作可以随时暂停(主动暂停或被高优先级任务被动调度中断),浏览器主线程不会被长时间占用
可恢复 暂停后可以在下一帧从断点继续,不丢失进度
优先级调度 紧急更新可以打断低优先级更新
双缓冲 内存中维护两颗树,current树和work-in-progress树,commit节点切换两颗树

代码演示

render阶段

下面是一个完整的演示例子用于展示React的Fiber架构, 首先我们创建一个虚拟DOM

js 复制代码
const App = createElement("div", null, 
                         createElement("h1", null, "Hello Fiber"),
                         createElement("p", { onClick: () => alert("clicked") }, "Click me")); 

其中的createElement是一个用于创建虚拟DOM的函数,作用和用法其实相当于vue的h函数。

javascript 复制代码
    function createElement(type, props, ...children) {
        return {
          type,
          props: {
            ...props,
            children: children.flat().map((item) => typeof item === "object" ? item : createTextElement(item))
          }
        }
      }

    function createTextElement(text) {
      return {
        type: "TEXT_ELEMENT",
        props: {
          nodeValue: text
        }
      }
    }

最后我们得到的虚拟dom是这样的

json 复制代码
{
  "type": "div",
  "props": {},
  "children": [
    {
      "type": "h1",
      "props": {},
      "children": [
        {
          "type": "TEXT_ELEMENT",
          "props": {
            "nodeValue": "Hello Fiber"
          }
        }
      ]
    },
    {
      "type": "p",
      "props": {},
      "children": [
        {
          "type": "TEXT_ELEMENT",
          "props": {
            "nodeValue": "Click me"
          }
        }
      ]
    }
  ]
}

我们现在进行任务调度,生成Fiber根节点,Fiber节点是虚拟DOM的增强版本,用于树的遍历

yaml 复制代码
    let wipRoot = null;
    let nextUnitOfWork = null;
    function render(vnode, container) {
      wipRoot = {
        type: "Root",
        dom: container,
        props: {
          children: [vnode]
        },
        parent: null,
        child: null,
        sibling: null,
        effectTag: null
      };

      nextUnitOfWork = wipRoot;
    }

写一个workLoop ,流程是先处理render阶段,然后每帧的空闲时间处理一个Fiber节点,然后在render完成后开启commit阶段

scss 复制代码
    function workLoop(deadline) {
      while (nextUnitOfWork && deadline.timeRemaining() > 1) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        //处理一个Fiber节点
      }

      if (!nextUnitOfWork && wipRoot) {
        commitRoot(); //开启commit阶段
      }

      requestIdleCallback(workLoop);
    }

    requestIdleCallback(workLoop);

在我们的render阶段,核心就是performUnitOfWork, 这个函数的目标就是创建真实dom, 然后把子组件转成一个Fiber链表, 逐个处理

lua 复制代码
function performUnitOfWork (fiber) {
  if (!fiber.dom && fiber.type !== "Root") {
    fiber.dom = createDom(fiber);
  }
}

这里的createDom用于创建真实DOM, 根据我们上述的虚拟节点dom的数据结构,可以很轻松的创建出有相应的属性和事件处理函数的dom节点

ini 复制代码
    function createDom(fiber) {
      const dom = fiber.type === "TEXT_ELEMENT" ? 
        document.createTextNode(fiber.props.nodeValue) : 
        document.createElement(fiber.type);

      updateDom(dom, {}, fiber.props);//附加上对应的属性和事件监听器
      return dom;
    }

      const isEvent = (key) => key.startsWith("on");
      const isProp = (key) => !isEvent(key) && key !== "children";
      const getEventName = (key) => key.split("on")[1].toLowerCase();

      function updateDom(dom, source, target) {
        const sourceKeys = Object.keys(source);
        const targetKeys = Object.keys(target);

        const { event: sourceEvent = [], prop: sourceProp = [] } = Object.groupBy(sourceKeys, (key) => {
          return isEvent(key) ? "event" : isProp(key) ? "prop" : "none";
        });

        const { event: targetEvent = [], prop: targetProp = [] } = Object.groupBy(targetKeys, (key) => {
          return isEvent(key) ? "event" : isProp(key) ? "prop" : "none";
        });


        sourceEvent.forEach((originName) => {
          const eventName = getEventName(originName);
          dom.removeEventListener(eventName, source[originName]);
        });
        sourceProp.forEach((key) => {
          dom[key] = "";
        });
        targetEvent.forEach((originName) => {
          const eventName = getEventName(originName);
          dom.addEventListener(eventName, target[originName]);
        });
        targetProp.forEach((key) => {
          dom[key] = target[key];
        });
      }

结束创建DOM的工作,回到我们的performUnitOfWork,,接下来我们需要把Fiber节点的子虚拟DOM转成一个工作流链表, 方便中断和恢复,树的结构是可以做的,但是没有链表合适,

ini 复制代码
function performUnitOfWork (fiber) {
  ...
      reconcileChildren(fiber, fiber.props.children);
  ...
}
    function reconcileChildren(fiber, childs) {
        if (!(childs && Array.isArray(childs))) return null;
        let prev;
        childs.forEach((child, index) => {
          //创建一个Fiber节点
          const newFiber = {
            type: child.type,
            props: child.props,
            dom: null,
            parent: fiber,
            sibling: null,
            child: null,
            effectTag: "PLACEMENT"
          };
          if (index === 0) {
            fiber.child = newFiber;
          } else {
            prev.sibling = newFiber;
          }
          prev = newFiber;
        });

      }
      

把当前节点的链表组件完毕后,继续组装下一个Fiber节点, 回到performUnitOfWork

ini 复制代码
function performUnitOfWork(fiber) {
      ....
  
      if (fiber.child) return fiber.child;
      let nextFiber = fiber;
      while (nextFiber) {
        if (nextFiber.sibling) return nextFiber.sibling;
        nextFiber = nextFiber.parent;
      }
}

这里performUnitOfWork 最后返回下一个要处理的Fiber节点,由workLoop在下一次空闲的时候继续帮我们处理下一个Fiber节点。正是因为遍历被改写成了"沿 child → sibling → parent 指针走的循环",进度才能用 nextUnitOfWork 一个变量记住,从而做到随时中断、随时恢复:

直到最后全部组装完毕,我们查看一下我们在render阶段得到的最终Fiber树是什么样的

kotlin 复制代码
JSON.stringify(wipRoot, (key, val) => {
    if (key === "dom" || key === "parent") return ;
    return val;
});

Fiber树的最终结果如下

json 复制代码
{
  "type": "Root",
  "props": {
    "children": [
      {
        "type": "div",
        "props": {
          "children": [
            {
              "type": "h1",
              "props": {
                "children": [
                  {
                    "type": "TEXT_ELEMENT",
                    "props": {
                      "nodeValue": "Hello Fiber"
                    }
                  }
                ]
              }
            },
            {
              "type": "p",
              "props": {
                "children": [
                  {
                    "type": "TEXT_ELEMENT",
                    "props": {
                      "nodeValue": "Click me"
                    }
                  }
                ]
              }
            }
          ]
        }
      }
    ]
  },
  "sibling": null,
  "child": {
    "type": "div",
    "props": {
      "children": [
        {
          "type": "h1",
          "props": {
            "children": [
              {
                "type": "TEXT_ELEMENT",
                "props": {
                  "nodeValue": "Hello Fiber"
                }
              }
            ]
          }
        },
        {
          "type": "p",
          "props": {
            "children": [
              {
                "type": "TEXT_ELEMENT",
                "props": {
                  "nodeValue": "Click me"
                }
              }
            ]
          }
        }
      ]
    },
    "sibling": null,
    "child": {
      "type": "h1",
      "props": {
        "children": [
          {
            "type": "TEXT_ELEMENT",
            "props": {
              "nodeValue": "Hello Fiber"
            }
          }
        ]
      },
      "sibling": {
        "type": "p",
        "props": {
          "children": [
            {
              "type": "TEXT_ELEMENT",
              "props": {
                "nodeValue": "Click me"
              }
            }
          ]
        },
        "sibling": null,
        "child": {
          "type": "TEXT_ELEMENT",
          "props": {
            "nodeValue": "Click me"
          },
          "sibling": null,
          "child": null,
          "effectTag": "PLACEMENT"
        },
        "effectTag": "PLACEMENT"
      },
      "child": {
        "type": "TEXT_ELEMENT",
        "props": {
          "nodeValue": "Hello Fiber"
        },
        "sibling": null,
        "child": null,
        "effectTag": "PLACEMENT"
      },
      "effectTag": "PLACEMENT"
    },
    "effectTag": "PLACEMENT"
  },
  "effectTag": null
}

可以看到props里的children用于存储虚拟dom, 而child和sibling,parent(因为会导致循环引用,所以这里过滤掉了,没有展示出来)是存储Fiber节点,在children可以看到有很多重复对象,实际上是共用同一套引用对象,effectTag是一个二进制掩码,可以组合表示多种要处理的副作用,我们这里简化了,就处理单一副作用,用的是字符串枚举, "PLACEMENT", 也没有Deletion,

commit阶段

这里进入commit阶段,在commit阶段,主要是映射虚拟dom到真实dom

ini 复制代码
    function commitRoot() {
      commitWork(wipRoot.child);
      wipRoot = null;
    }

    function commitWork(fiber) {
      if (!fiber) return;

      if (fiber.effectTag === "PLACEMENT" && fiber.dom) {
        let parent = fiber.parent;

        while (parent && !parent.dom) {
          parent = parent.parent;
        }

        parent.dom.appendChild(fiber.dom);
      }

      commitWork(fiber.child);
      commitWork(fiber.sibling);
    }

可以看到commit是一个同步递归的任务,而且是没有办法被打断的,

完整代码及演示结果查看如下

增强版本

上述代码只是演示了一个最小Fiber架构代码,并没有展现出我们Fiber架构中优先级调度中断恢复双缓冲等功能,下面我们逐步引入这几大代码。

双缓冲

在React中的Fiber架构中,每个节点通过alternate字段指向另一颗树的对应节点,这样在render阶段,我们操作wip(work in progress)树,不影响到屏幕上的current树, 在commit阶段新旧虚拟DOM树交换的时候, 也不需要遍历旧虚拟dom树进行节点更新, 只需要交换新旧虚拟dom树就可以了,在关键的diff对比节点,可以通过新旧节点的对比,判断复用/更新/删除

javascript 复制代码
    let currentRoot = null;
    function render (element, dom) {
        wipRoot = {
            dom,
            props: {...},
            ...
            alternate: currentRoot
        }
    }
    

      let deletions = [];
      function reconcileChildren(wipFiber, elements) {
        let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
        let preFiber = null;

        for (let i = 0, len  = elements.length; i < len || oldFiber; i++) {
          const element = elements[i];
          const sameType = oldFiber && element && oldFiber.type === element.type;
          let newFiber;

          if (sameType) {
            newFiber = {
              type: element.type,
              props: element.props,
              dom: oldFiber.dom,
              parent: wipFiber,
              child: null,
              sibling: null,
              alternate: oldFiber,
              effectTag: "UPDATE"
            }
          }

          if (!sameType && element) {
            newFiber = {
              type: element.type,
              props: element.props,
              dom: null,
              parent: wipFiber,
              child: null,
              sibling: null,
              alternate: oldFiber,
              effectTag: "PLACEMENT"
            }
          }

          if (!sameType && oldFiber) {
            oldFiber.effectTag = "DELETION";
            deletions.push(oldFiber);
          }

          if (oldFiber) oldFiber = oldFiber.sibling;


          if (newFiber) {
            if (i == 0) wipFiber.child = newFiber
            else preFiber.sibling = newFiber
            preFiber = newFiber;
          }
        }
      }

然后在commit阶段我们只需要交换currentRoot和wipRoot两颗树,然后把currentRoot树重新映射到真实dom树中即可

ini 复制代码
    function commitRoot () {
      deletions.forEach(commitWork);
      commitWork(wipRoot.child);
      currentRoot = wipRoot;
      deletions = [];
    } 


    function commitWork (fiber) {
      if (!fiber) return ;
      let domParentFiber = fiber.parent;
      while (domParentFiber && !domParentFiber.dom) domParentFiber = domParentFiber.parent;
      const domParent = domParentFiber.dom;

      if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) 
        domParent.appendChild(fiber.dom);
      else if(fiber.effectTag === "UPDATE" && fiber.dom != null)
        updateDom(fiber.dom, fiber.alternate.props, fiber.props);
      else if (fiber.effectTag === "DELETION") {
        fiber.dom.remove();//这里简化只处理当前节点,不处理后代
        return false;
      }

      commitWork(fiber.child);
      commitWork(fiber.sibling)
    }

在上述代码中,可以看到Fiber提供的current树和wip树,在render阶段我们进行wip构建,在commit阶段则交换current树和wip,一次性的同步映射到真实dom上,这也就避免了React在current树做diff和修改,导致组件可能只完成一半(尤其是是高优先级任务插队的时候,低优先级的渲染可能就执行一半就中断了),页面呈现新旧UI混合的半成品状态,即"渲染撕裂";双缓冲树的体系下,React先展示旧内容,然后默默构建wip树,完成后一次性映射到DOM上,就没有渲染一半的说法了,就是一次完整的渲染

优先级调度

React的调度,是为了快速响应用户交互而实现的,防止长任务执行过程中页面点击事件被阻塞, 用户感觉页面"卡死了",下面展示一个最小调度逻辑

ini 复制代码
function scheduleWork(priority) {
  if (priority < wipPriority) {
    wipRoot = {
      type: "Root",
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    };
    wipPriority = priority;
    deletions = [];
    nextUnitOfWork = wipRoot;
  }
}

在这个代码中,要是有高优先级别的任务调用了scheduleWork, 则我们全量从根节点开始构建,React则在背后使用其它手段使得避免全量构建,

在这里直接丢弃了之前的nextUnitOfWork

中断/恢复

中断/恢复功能其实在基础版本就体现出来了,通过requestIdleCallback将任务分片,每次都断在nextUnitOfWork,然后中断,下一次又在nextUnitOfWork这个Fiber节点上恢复任务,这是不丢弃的,在调度部分,则是直接把之前的工作丢弃了

为什么Vue不需要Fiber ?

vue得益于它的响应式系统, 所有的状态改变可以精准溯源到对应的组件,在组件级别做更新就可以了,所以任务被拆分的小,没有一次性要构建整个虚拟DOM的需求,所以就没有使用Fiber机制进行任务切片的需求,相比之下, React的响应式机制是没有办法做到vue这样的细粒度的,只能去执行一遍组件函数,然后全量的构建看看哪些地方是变化的,通过各种优化手段把没变的分支剪掉, 避免全量构建虚拟dom。

相关推荐
yingyima1 小时前
正则表达式分组与捕获:凌晨3点服务器报警的解决方案
前端
swipe2 小时前
从 0 到 1 理解 React 虚拟列表:定高、不定高与 Canvas 版本完整拆解
前端·javascript·面试
铁皮饭盒3 小时前
Bun执行python代码
前端·javascript·后端
hunterandroid3 小时前
Service 与前台服务:让任务在后台持续运行
前端
米饭同学i3 小时前
深扒 LobsterAI 官网前端动效实现方案:从交互细节到代码实践
前端
前端啊3 小时前
告别 el-table 打印难题,vue3-print-el-table 来了!
前端·vue.js
JarvanMo3 小时前
AI时代跨平台还有必要吗?
前端
Patrick_Wilson4 小时前
幂等到底是什么?从前端视角讲透 SQL、HTTP 与 POST 接口的幂等设计
前端·后端·架构
凌览4 小时前
一人公司别再上 Jenkins,真不值
前端·后端