深入理解React fiber 和 diff 算法

2,048 阅读8分钟

一. 为什么引入Fiber 架构

  1. 没有 fiber 的 react 主要有什么性能问题?

页面元素很多,且需要频繁刷新的场景下,react 15 会出现掉帧的现象。就是页面的渲染不是很流畅,有点卡顿的感觉。这是因为 react15 是全量渲染的,不可中断的。如果某次渲染的时间过长,就会造成掉帧的现象。

  1. 针对这个问题,react 团队引入了 fiber 的概念,渲染效果会流畅很多。
  2. 解决方案:
  • 利用浏览器空闲时间执行任务采用window下的requestIdleCallbackAPI,拒绝长时间占用主线程
  • 放弃递归只采用循环,因为循环可以被中断
  • 任务拆分,将任务拆分成一个个小任务
  1. react 15 渲染页面的方式,存在哪些问题:

将元素渲染到页面,如果界面节点多,层次很深,递归渲染比较耗时。

而且 js 是单线程的,ui线程和js线程互斥

image.png

虚拟dom:

const vDom = {
  type: "div",
  props: {
    id: "0",
    children: [
      {
        type: "span",
        children: 111,
      },
    ],
  },
};
const render = (element, container) => {
  // 创建dom节点
  let dom = document.createElement(element.type);
  // 添加属性
  const props = Object.keys(element.props);
  props.forEach((e) => {
    if (e !== "children") {
      dom[e] = element.props[e];
    }
  });
  // 处理子元素
  if (Array.isArray(element.props.children)) {
    // 是数组,那就继续递归
    element.props.children.forEach((c) => render(c, dom));
  } else {
    // 是文本节点就设置文本
    dom.innerHTML = element.props.children;
  }
  // 将当前加工好的dom节点添加到父容器节点中
  container.appendChild(dom);
};

render(vDom, document.getElementById("root"));

0

image.png

image.png

二. 什么是 Fiber

  1. fiber 其实是一种数据结构,可以用一个纯 js 对象来表示。一种基于浏览器的单线程调度算法
  2. fiber 是一个执行单元,每次执行完一个执行单元,react 就会检查还剩多少时间,如果没有时间就将控制权让出去。一种将 recocilation (递归 diff),拆分成无数个小任务的算法;它随时能够停止,恢复。停止恢复的时机取决于当前的一帧(16ms)内,还有没有足够的时间允许计算。
  3. fiber 借助单链表数据结构将 diff 算法的递归遍历( O(n^3) 时间复杂度)变为循环遍历(O(n^1) 时间复杂度)。React 16之前 ,reconcilation 算法实际上是递归,想要中断递归是很困难的,React 16 开始使用了循环来代替之前的递归.

0

image.png

const fiber = {
stateNode,// dom节点实例
child,// 当前节点所关联的子节点
sibling,// 当前节点所关联的兄弟节点
return// 当前节点所关联的父节点
}

fiber 对象:
{
  type. 节点类型(元素,文本,组件)
  props 节点属性
  stateNode 节点DOM对象 | 组件实例对象
  tag 节点标记 
  effects 数组,存储需要更改的Fiber对象
  effectTag 当前Fiber要被执行的操作 (新增,删除,修改)
  parent 当前Fiber的父级Fiber
  child 当前Fiber的子级Fiber 注意 一个节点只能有一个子级 其他的子级是这个子级的兄弟fiber
  sibling 当前fiber的下一个兄弟Fiber
  alternate Fiber备份 fiber对比时使用
}

 数据结构-fiber 属性:
 type Fiber = {
 /**********************实例相关************************/
 // 标记不同的组件类型
 tag: workTag,
 // 组件类型 div、span 组件构造函数
 type: any,
 // 实例对象,如类组件的实例,原生dom实例,而function组件没有实例,因此该属性为空
 stateNode: any,
 /**********************构建fiber树相关************************/
 // 指向自己的父级fiber对象
 return: Fiber | null,
 // 指向自己的第一个子级fiber对象
 child: Fiber | null,
 // 指向自己的下一个兄弟Fiber对象
 sibling: Fiber | null,
 // 在fiber树更新过程中,每个Fiber 都有一个与其对应的 Fiber
 // 我们称其为 current<==> workInProgress,在渲染完成后他们会交换位置
 // alternate指向当前Fiber 在 workInProgress树中的对应fiber
 alternate: Fiber | null,
/**********************状态数据相关************************/
// 即将更新的props
pendingProps: any,
// 旧的props
memoizedProps: any,
// 旧的state
memoizedState: any,
/**********************副作用相关************************/
// 该Fiber对应的组件产生的状态更新会存放在这个队列里面
updateQueue: UpdateQueue<any> | null,
// 用来记录当前Fiber要执行的DOM操作
effectTag: SideEffectTag,
// 子树中第一个side effect
firstEffect: Fiber | null,
// 单链表用来快速查找下一个side effect 
nextEffect: Fiber | null,
// 子树中最后一个side effect
lastEffect: Fiber | null,
// 任务的过期时间
expirationTime: ExpirationTime,
// 当前组件及子组件处于何种渲染模式
mode: TypeOfMode,
}

三. Fiber 关键特性

  1. 增量渲染:把里面的渲染任务拆分,均匀到每一帧里面执行,(不是一口气渲染完,走走停停,有时间就渲染,没时间就暂停)
  2. 暂停,终止,复用渲染任务(没时间了就把控制权让回浏览器)
  3. 不同更新的优先级:可以在更新的时候赋予不同优先级,比如事件交互响应,页面渲染等。键盘的输入事件,希望立马得到反馈的这种属于高优先级,网络请求属于低优先级。可以根据优先级进行插队。
  4. 并发方面的新的基础能力:暂停高消耗,非紧急的组件渲染,聚焦在更加紧迫的任务中,比如说:UI 渲染优先,避免白屏 卡顿等现象

四. Fiber 工作流程

  1. 首先react 会先像浏览器发起 调度请求
  2. 浏览器会根据当前情况,做事件的处理、js执行、布局绘制、然后在空闲的时间再去执行react 的任务。(fiber 就是里面一个一个的任务)
  3. 在执行react任务的过程中,先判断是否存在下一个任务单元,存在的话执行当前任务单元,执行完毕之后判断是否还有时间,如果有就继续执行下一个任务单元。如果所有的任务都执行完 就会把时间交给浏览器。如果没有时间的话,就将控制权交给浏览器,由他在空闲时间再进行调度
  4. 在执行的过程中某个任务时间过长怎么办呢,由于浏览器是单线程的,所以页面就会有卡顿的现象

浏览器更新调度的执行过程:

image.png 五. Fiber 结构示意图

首先图中的关系是

  1. A1 根节点,相当于爷爷。通过 child 可以找到 B1
  2. B1和B2是兄弟,相当于儿子。B1 通过sibling 可以找到B2,通过 return 可以找到 A1。通过child 可以找到 C1。
  3. C1 和 C2 是兄弟,相当于孙子。C1 通过sibling 可以找到C2,通过return 可以找到B1

image.png

六. Fiber的执行阶段

  1. Reconciliation 协调render阶段:可中断

可以认为是diff 阶段,这个阶段可以被终止,在这个阶段会找出所有的节点变更。

比如节点的更新,删除,属性变更等。这些变更称之为副作用。

  1. Commit 提交阶段 不可中断

将上一阶段计算出来的需要处理的副作用(effect)一次性执行完。

这个阶段必须同步执行,不能被打断。

DOM初始渲染:virtualDOM -> 构建fiber对象(Fiber) -> 所有fiber对象存储在一个数组中(Fiber[])把fiber对象要执行的操作映射到真实DOM中 -> DOM

DOM更新操作:newFiber vs oldFiber -> Fiber[] -> DOM

遍历规则:深度优先

开始:从父节点开始 依次向下找

完成:从子节点开始,依次向上找

image.png 七. 帧的概念

image.png

image.png

八. window.requestAnimationFrame

image.png

image.png

九. window.requestIdleCallback 有浏览器兼容问题

image.png

image.png

十. MessageChannel 消息通道

window.requestIdleCallback 有浏览器兼容问题,所以react 模拟了一个 这个方法

image.png

image.png

十一. diff 算法 blog.csdn.net/qq_39207948…

拿老的 Fiber 链表(树节点)和新的 JSX 虚拟dom 节点做对比,生成新的 Fiber 树,这个过程就是diff

  1. 判断是否存在旧有的fiber节点,如果不存在说明没必要diff,直接走fiber新建挂载逻辑。
  2. 有child说明有旧有fiber,那就对比key,如果不相等,直接运行deleteChild(returnFiber, child),也就是从div节点的旧有父节点上,将整个div都删除掉,div的子节点都不需要比了,这也验证了react的逐级比较,父不同,子一律都不比较视为不同。
  3. 若key相同,那就比较新旧fiber的type(标签类型),如果type不相同,跟key不相同一样,调用了deletseRemainingChildren(returnFiber, child)方法,直接从div的旧有父节点上将自己整个删除。
  4. 若key type都相同,那只能说明是props变了,因此调用var _existing3 = useFiber(child, element.props)方法,根据新的props来更新旧有的div fiber节点

对比流程:

【1】第一轮循环

A=A 能复用,更新A就可以

B!==C key 不一样,则马上跳出第一轮循环

【2】第二轮循环

map 的key 就是元素的key , 值就是老的fiber 节点

let map = {'B':B,'C':C,'D':D,'E':E,'F':F}

继续遍历新的节点

这次遍历从新的节点开始 拿C 节点去map 里找到有没有key 为C 的fiber 节点,并且type 也相同,

就说明只是位置变了,老节点可以复用(fiber和dom 元素都可以复用),

把C 标记为 更新 ,另外会从 map 中删除C, 同理 E 也标记为更新

map = { 'D':D,'F':F}; 还没有被复用的fiber 节点 ,等新的JSX数组遍历完成之后,把map 里的fiber 节点 D F全部标志为 删除

总结:

我们了解到react的diff是同层比较,最先比较key,如果key不相同,那么不用比较剩余节点直接删除,这也强调了key的重要性,其次会比较元素的type以及props。而且这个比较过程其实是拿旧的fiber与新的虚拟dom在比,而不是fiber与fiber或者虚拟dom与虚拟dom比较,其实也不难理解,如果key与type都相同,那说明这个fiber只用做简单的替换,而不是完整重新创建,站在性能角度这确实更有优势。