前端面试复习系列之react-fiber

383 阅读5分钟

fiber出现的缘由

摘录官方文档react哲学中所提出的我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。

这句话中最重要的一点就是快速响应,快到悄悄的刷新了界面你却毫无感知,那么怎么个快法才算毫无感知呢?摘录自百度百科在游戏过程中一般人能接受的最低FPS约为30Hz,基本流畅等级则需要>60Hz,即浏览器刷新页面的频率大于60hz才能实现,根据hz单位的定义1Hz代表每秒钟周期震动1次,60Hz代表每秒周期震动60次,刷新一次页面的时间控制在1000ms/60hz≈16.6ms以内,这16.6ms中还包括浏览器绘制页面的时间,因为js执行和浏览器绘制页面是互斥的,所以js执行的时间加上浏览器绘制页面时间小于16.6ms才能实现无感知刷新。

所以,一旦js跑得比较久了,超过了基本流畅等级所定义的时间阈值(远远<16.6ms),就会出现卡顿,然而恰恰前端框架又是一个依靠js执行来实现dom增删改的框架。

反观在react15中,架构可以分为渲染器Renderer协调器Reconcilers两大层,前者渲染器用于管理一棵 React 树,使其根据底层平台进行不同的调用,后者实现 React 的 “diffing” 算法中我们做出的设计决策以保证组件满足更新具有可预测性,以及在繁杂业务下依然保持应用的高性能性,但是react15中的Reconciler会递归更新子组件,并且不会中断,一旦this.setState、this.forceUpdate触发了更新,层级很深的时候,Reconciler会先触发第一个child更新交给Renderer渲染,接着触发第二个更新交给Renderer渲染,然后一直反复递归交给Renderer渲染,这样就会导致在阈值内一个state值无法一次性完成所有的更新,有可能在用户界面的某一帧里就会出现未更新的state值,由于某一帧里面出现了未更新的state,看上去就像是卡顿了一样,并且如果中间的某一步出现了某一种神秘力量导致了异步,也会出现未更新的现象。所以官方的大佬们就折腾出了Concurrent 模式,在渲染器和协调器之间又加入了一个调度器Scheduler,用于调度任务的优先级,fiber则是实现Concurrent模式的基本,其中描述节点的数据结构也是叫做fiber。

fiber是如何开始运行的

或许你还听说过Thread、coroutine以及Fiber(我就不瞎翻译了,名词在不同环境或者不同人的理解下都是不同的意思,可以自己搜搜看),react中实现的fiber可以粗俗的理解为,同线程下具有高关联性的可调度任务单元,并且任意一个单元都可以主动yield,完全用户态,没有额外语法糖(区别于Generator

大佬们设计了一个类似浏览器requestIdleCallback功能的Scheduler,功能就是当浏览器有空闲时间的时候运行咱们的js代码,顺便还可以调度任务,其中一个方法就叫做shouldYield,看看伪代码:

import { shouldYield } from './SchedulerWithReactIntegration.js';
let nextUnitOfWork = null
let rootFiberNode = null
React.render = function(element, container){
  rootFiberNode = {
    dom: container,
    props: {
      children: [element],
    },
  };
  nextUnitOfWork = rootFiberNode;
}
function workLoop(){
  while(nextUnitOfWork && !shouldYield()){
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }
  requestIdleCallback(workLoop)
}
function performUnitOfWork(fiber) {
  // 一顿diff算法骚操作
  return nextFiber
}
requestIdleCallback(workLoop)
React.render(···)

省略掉babel编译jsx的过程,入口差不多就是这样,performUnitOfWork方法会将根结点作为起始结点,将起始结点的dom添加到视图中,然后创建子fiber结点,最后返回下一个工作结点,循环往复,不断生成新的工作单元,performUnitOfWork中的这顿骚操作就是“diffing”,那么具体是如何循环fiber树的呢?

为了查找结点更容易,每个fiber结点只会链接parent/uncle、son、sibling,也就是父节点、子节点、右侧兄弟结点,从root出发,首先查找son发现div1,继续查找div1的son,发现div3,继续查找div3的son,发现没有,转而查找div3的右侧sibling,发现span,便接着查找span的son,发现没有,并且span的右侧sibling也没有,说明已经到达fiber树的最尾部,那么就回到作为parent/uncle结点的div2,查找右侧sibling,直到回溯到了root,那么就算完成了所有的render。

performUnitOfWork细节

function performUnitOfWork(fiber) {
  // 创建dom,并且记录在dom字段中
  !fiber.dom && (fiber.dom = React.createDom(fiber))
  // TODO:协调器,这里会将fiber和fiber镜像进行协调,也就是diff
  const elements = fiber.props.children
  reconcileChildren(fiber, elements)
  /* 这里就是上图所示的查找规则,
  *     有son => son,
  *     没有就 => sibling,
  *     再没有就 => parent/uncle,
  *     最后到达root完事儿
  */
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

commit中的diffing

如果我们在performUnitOfWork中边执行边添加dom,这就回到了react15递归同步更新的问题,别忘了咱们入口处还有一个workLoop函数,这个函数知道何时更新浏览器不会卡,so:

···
// fiber镜像,缓存上一次commit的fiber树,用于diff比对,有人说这叫做双缓存
let fiberImage = null
React.render = function(element, container){
  rootFiberNode = {
    dom: container,
    props: {
      children: [element],
    },
    // 挂载镜像
    alternate: fiberImage
  };
  nextUnitOfWork = rootFiberNode;
}
···
function workLoop(){
  ···
  // 当完成所有work后,才提交整个fiber树给dom更新,这样咱们就不会再卡了
  if(!nextUnitOfWork && rootFiberNode){
    commitWork(rootFiberNode.child)
    // 缓存镜像
    fiberImage = rootFiberNode
    rootFiberNode = null
  }
  ···
}
···
function commitWork(fiber){
  if(!fiber) return
  // TODO:真正操作dom的地方
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

找到正确commit时机的同时,还记录了每次commit时的fiber树镜像,这样下一次commit的时候,咱们就可以知道当前的fiber树和上一棵fiber树的不同,从而进行diff,就像这样做:

// 实现协调器,规则是:
//    1.如果新旧结点type相同,那么只更新props
//    2.如果新旧结点type不同,并且是一个新元素,那么需要添加
//    3.如果新旧结点type不同,并且这是一个老元素,那么需要删除这个结点
//    tips:这里有个知识点,为什么数组遍历需要添加key参数
function reconcileChildren(parentFiber, children){
  let index = 0
  // 缓存fiber兄弟结点
  let prevSibling = null
  // 找到镜像
  let fiberImage = parentFiber.alternate && parentFiber.alternate.child
  while (index < children.length || fiberImage != null) {
    const element = children[index]
    let newFiber = null
    const isSameType = fiberImage && element && fiberImage.type === element.type
    // 规则1.
    if(isSameType){
      newFiber = {
        type: fiberImage.type,
        props: element.props,
        dom: fiberImage.dom,
        parent: parentFiber,
        alternate: fiberImage, // 记录镜像
        effectTag: "UPDATE",
      }
    }
    // 规则2.
    if(element && !isSameType){
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: parentFiber,
        alternate: null,
        effectTag: "ADD",
      };
    }
    // 规则3.
    if(fiberImage && !isSameType){
      // TODO: delete
    }
    // 遍历结束记得要切换镜像到兄弟镜像
    if (fiberImage) {
      fiberImage = fiberImage.sibling;
    }
    // 第一个children作为child,同级其他结点作为siblings
    index === 0 ? (fiber.child = newFiber) : (prevSibling.sibling = newFiber)
    // 缓存当前fiber
    prevSibling = newFiber
    index++
  }

}

diff过程中,为每一个fiber都打上了对应的effectTag,对应着相应的dom操作,完善一下收尾工作,就像这样:

let deletions = null
React.render = function(element, container){
  rootFiberNode = {
    dom: container,
    props: {
      children: [element],
    },
  };
  deletions = []
  nextUnitOfWork = rootFiberNode;
}
function workLoop(){
  ···
  if(!nextUnitOfWork && rootFiberNode){
    // 删除
    deletions.forEach(commitWork)
    commitWork(rootFiberNode.child)
    // 缓存镜像
    fiberImage = rootFiberNode
    rootFiberNode = null
  }
  ···
}
···
function reconcileChildren(parentFiber, children){
  ···
  // 规则3.
  if(fiberImage && !isSameType){
    fiberImage.effectTag = "DELETE"
    deletions.push(oldFiber)
  }
  ···
}
···
function commitWork(fiber){
  if(!fiber) return
  // TODO:真正操作dom的地方
  const domParent = fiber.parent.dom
  if (
    fiber.effectTag === "ADD" &&
    fiber.dom != null
  ) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === "DELETE") {
    domParent.removeChild(fiber.dom)
  } else if (
    fiber.effectTag === "UPDATE" &&
    fiber.dom != null
  ) {
    // 这里会比对新旧结点的属性,卸载掉新结点中没有的事件监听,添加新监听,以及其他一些乱七八糟的dom操作
    updateDom(···)
  }
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

核心的东西差不多就是酱紫,有错误的地方请看官们多多指点,一起进步

参考: