解析 React 性能利器 — Fiber

avatar
公众号「 微医大前端技术 」

武晓慧,微医云服务团队前端开发工程师,喜欢健身,旅游。

什么是刷新率?

大部分显示器屏幕都有固定的刷新率(比如最新的一般在 60Hz),所以浏览器更新最好是在 60fps。如果在两次硬件刷新之间浏览器进行两次重绘是没有意义的只会消耗性能。 浏览器会利用这个间隔 16ms(一帧)适当地对绘制进行节流,如果在 16ms 内做了太多事情,会阻塞渲染,造成页面卡顿, 因此 16ms 就成为页面渲染优化的一个关键时间。


一帧做了哪些事情

  • events: 点击事件、键盘事件、滚动事件等
  • macro: 宏任务,如 setTimeout
  • micro: 微任务,如 Promise
  • rAFrequestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

  • Layout: CSS 计算,页面布局
  • Paint: 页面绘制
  • rIC: requestIdleCallback

window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序

一个帧内要做这么多事情…… 如果 js 执行时间过长超过 16ms,就会 block 住,那么就会丢掉一次帧的绘制

宏任务的执行总在微任务之后,但是与其他的顺序不太确定

TIPS:协调的概念:比较虚拟 DOM 树,找出需要变更的节点,更新,称为协调(Reconcliation)


React16 之前的协调

  • 特点:
    • 递归调用,通过 React DOM 树级关系构成的栈递归
    • 在 virtualDOM 的比对过程中,发现一个 instance 有更新,会立即执行 DOM 操作。
    • 同步更新,没发打断
  • 代码示例

有下面这样一个 Component 组件,用他来模拟 DOM Diff 过程:

const Component = (
  <div id="A1">
    <div id="B1">
      <div id="C1"></div>
      <div id="C2"></div>
    </div>
    <div id="B2"></div>
  </div>
)

Diff 过程:

上面定义的 Component 组件会首先通过 Babel 转成 React.CreateElement 生成 ReactElement,也就是我们口中的虚拟 DOM(virtualDOM),如下类似 root 的结构(下面里面属性做了很多简化,只展示了结构)。

let root = {
  key: 'A1',
  children: [
    {
      key: 'B1',
      children: [
        {
          key: 'C1',
          children: [],
        },
        {
          key: 'C2',
          children: [],
        }
      ],
    },
    {
      key: 'B2',
      children: [],
    }
  ],
};

// 深度优先遍历
function walk(vdom) {
  doWork(vdom);
  vdom.children.forEach(child => {
    walk(child);
  })
}
// 更新操作
function doWork(vdom) {
  console.log(vdom.key);
}

walk(root);

缺点:

根据上面代码会发现,如果有大量更新或者有很深的组件结构树,执行 diff 操作的执行栈会越来越深并不能及时释放,那么 js 将一直占用主线程,一直要等到整棵 virtualDOM 树计算完成之后,才能把执行权交给渲染引擎,这就会导致用户的交互操作以及页面动画得不到响应,就会有明显感觉卡顿(掉帧),影响用户体验。

  • 解决:

把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会,所以 React 在 15 版本更新 16 版本时候推出了 Fiber 协调的概念。


Fiber 概念

Fiber 是对 React 核心算法的重构,2 年重构的产物就是 Fiber Reconciler

核心目标:扩大其适用性,包括动画,布局和手势。

  • 把可中断的工作拆分成小任务
  • 对正在做的工作调整优先次序、重做、复用上次(做了一半的)成果
  • 在父子任务之间从容切换(yield back and forth),以支持 React 执行过程中的布局刷新
  • 支持 render() 返回多个元素
  • 更好地支持 error boundary

每一个 Virtual DOM 节点内部都会生成对应的 Fiber。


Fiber 前置知识

怎么中断一个任务:实现一个类似于 Fiber 可中断的 workLoop。


function sleep(delay) {
  for (let start = Date.now(); Date.now() - start <= delay;) {}
}

// 每一个子项可以认为是一个 fiber
const works = [
  () => {
    console.log('第一个任务开始');
    sleep(20);
    console.log('第一个任务结束');
  },
  () => {
    console.log('第 2 个任务开始');
    sleep(20);
    console.log('第 2 个任务结束');
  },
  () => {
    console.log('第 3 个任务开始');
    sleep(20);
    console.log('第 3 个任务结束');
  },
];

window.requestIdleCallback(workLoop, { timeout: 1000});

function workLoop(deadLine) {
  console.log('本帧的剩余时间剩', parseInt(deadLine.timeRemaining()));
  /*
  * deadLine {
  *   timeRemaining(), 返回此帧还剩下多少 ms 供用户使用
  *   didTimeout 返回 cb 任务是否超时
  * }
  */
  while ((deadLine.timeRemaining() > 0 || deadLine.didTimeout) && works.length > 0) { // 对象 两个属性 timeRemaining()
    performUnitOfWord();
  }

  if (works.length > 0) {
    window.requestIdleCallback(workLoop, { timeout: 1000});
  }
}

function performUnitOfWord() {
  works.shift()(); // 取出第一个元素执行
}
  • 单链表
    • 存储数据的数据结构
    • 数据以节点的形式表示,每个节点的构成:元素 + 指针(后续元素存储位置),元素就是存储数据的存储单元
    • 单链表是 Fiber 中很重要的一个数据结构,很多异步更新逻辑都是通过单链表结构来实现的(setState 中的 UpdateQueue 更新链表也是基于单链表结构)

模拟一个类似 React 中 setState 批量更新的逻辑。

/**
  Fiber 很多地方用到链表(单链表),尾指针没有指向
 */

class Update {
  constructor(payload, nextUpdate) {
    this.payload = payload;
    this.nextUpdate = nextUpdate; // 下一个节点的指针
  }
}

class UpdateQueue {
  constructor(payload) {
    this.baseState = null; // 原状态
    this.firstUpdate = null; // 第一次更新
    this.lastUpdate = null; // 最后一次更新
  }

  enqueueUpdate(update) {
    if (this.firstUpdate === null) {
      this.firstUpdate = this.lastUpdate =update;
    } else {
      this.lastUpdate.nextUpdate = update;
      this.lastUpdate = update;
    }
  }
  // 获取老状态,遍历链表,进行更新
  forceUpdate() {
    let currentState = this.baseState || {}; // 初始状态
    let currentUpdate = this.firstUpdate;
    while (currentUpdate) {
      let nextState = typeof currentUpdate.payload === 'function'
                      ? currentUpdate.payload(currentState)
                      : currentUpdate.payload;
      currentState = {
        ...currentState,
        ...nextState,
      }; // 使用当前更新得到最新的状态
      currentUpdate = currentUpdate.nextUpdate; // 找下一个节点
    }
    this.firstUpdate = this.lastUpdate = null; // 更新结束清空链表
    this.baseState = currentState;
    return currentState;
  }
}
// 链表可以中断和恢复
// 每次 setState 都会通过一个链表保存起来,最后合并
// enqueueUpdate 可以类比为 setState 操作
let queue = new UpdateQueue();
queue.enqueueUpdate(new Update({ name: '微医集团' }));
queue.enqueueUpdate(new Update({ number: 0 }));
queue.enqueueUpdate(new Update(state => ({ number: state.number + 1 })));
queue.enqueueUpdate(new Update(state => ({ number: state.number + 1 })));
console.log(queue)
queue.forceUpdate();

思考:为什么 setState 在合成事件中会是异步去更新的? 解释:我们通过伪代码发现,每次的 setState 并没有对 UpdataQueue 中的 state 做任何更新,只是把每次需要更新的值(或函数),放到了 UpdataQueue 的链表上面,在执行 forceUpdate 的时候再做统一处理,处理完之后更新 state,所以没有执行 forceUpdate 之前,我们拿到的 state 都不是我们预期想要的 state。


React 中的 Fiber

  1. Fiber 的两个执行阶段
    • 协调 Reconcile(render):对 virtualDOM 操作阶段,对应到新的调度算法中,就是通过 Diff Fiber Tree 找出要做的更新工作生成 Fiber 树。这是一个 js 计算过程,计算结果可以被缓存,计算过程可以被打断,也可以恢复执行。 所以,React 介绍 Fiber Reconciler 调度算法时,有提到新算法具有可拆分、可中断任务的新特性,就是因为这部分的工作是一个纯 js 计算过程,所以是可以被缓存、被打断和恢复的
    • 提交更新 commit: 渲染阶段,拿到更新工作,提交更新并调用对应渲染模块(React-DOM)进行渲染。为了防止页面抖动,该 过程是同步且不能被打断
  2. React 中定义一个组件用来创建 Fiber。
const Component = (
  <div id="A1">
    A1
    <div id="B1">
      B1
      <div id="C1">C1</div>
      <div id="C2">C2</div>
    </div>
    <div id="B2">B2</div>
  </div>
)
  1. 上面定义的 Component 是一个组件,babel 解析时候会默认调用 React.createElement()方法,最终生成下面代码所示这样的 virtualDOM 结构并传给 ReactDOM.render()方法进行调度。
{
  "type":"div",
  "key":null,
  "ref":null,
  "props": {
    "id":"A1",
    "children":[
      "A1",
      {
        "type":"div",
        "key":null,
        "ref":null,
        "props":{
          "id":"B1",
          "children":[
            "B1",
            {
              "type":"div",
              "key":null,
              "ref":null,
              "props":{
                  "id":"C1",
                  "children":"C1"
              },
              "_owner":null,
              "_store":{

              }
            },
            {
              "type":"div",
              "key":null,
              "ref":null,
              "props":{
                  "id":"C2",
                  "children":"C2"
              },
              "_owner":null,
              "_store":{

              }
            }
        ]
      },
      "_owner":null,
      "_store":{

      }
    },
    {
      "type":"div",
      "key":null,
      "ref":null,
      "props":{
          "id":"B2",
          "children":"B2"
      },
      "_owner":null,
      "_store":{

      }
    }
    ]
  },
  "_owner":null,
  "_store":{

  }
}
  1. render 方法会接受 Virtual DOM,为每个 Virtual DOM 创建 Fiber(render 阶段),并且按照一定关系连接接起来。
  2. fiber 结构
class FiberNode {
  constructor(tag, pendingProps, key, mode) {
    // 实例属性
    this.tag = tag; // 标记不同组件类型,如 classComponent,functionComponent
    this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key,也就是最终 ReactElement 上的
    this.elementType = null; // createElement 的第一个参数,ReactElement 上的 type
    this.type = null; // 表示 fiber 的真实类型 ,elementType 基本一样
    this.stateNode = null; // 实例对象,比如 class 组件 new 完后就挂载在这个属性上面,如果是 RootFiber,那么它上面挂的是 FiberRoot

    // fiber
    this.return = null; // 父节点,指向上一个 fiber
    this.child = null; // 子节点,指向自身下面的第一个 fiber
    this.sibling = null; // 兄弟组件, 指向一个兄弟节点
    
    this.index = 0; //  一般如果没有兄弟节点的话是 0 当某个父节点下的子节点是数组类型的时候会给每个子节点一个 index,index 和 key 要一起做 diff

    this.ref = null; // reactElement 上的 ref 属性

    this.pendingProps = pendingProps; // 新的 props
    this.memoizedProps = null; // 旧的 props
    this.updateQueue = null; // fiber 上的更新队列 执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新
    this.memoizedState = null; // 对应 memoizedProps,上次渲染的 state,相当于当前的 state,理解成 prev 和 next 的关系

    this.mode = mode; // 表示当前组件下的子组件的渲染方式

    // effects

    this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新
    this.nextEffect = null; // 指向下个需要更新的 fiber
  
    this.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个
    this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个

    this.expirationTime = NoWork; // 过期时间,代表任务在未来的哪个时间点应该被完成
    this.childExpirationTime = NoWork; // child 过期时间

    this.alternate = null; // current 树和 workInprogress 树之间的相互引用
  }
}

Fiber 有很多属性,所有子节点 Fiber 的连接接是通过 child,return,siblint 链接起来,alternate 连接的是每一次更新的状态,用来对比每次状态更新以及缓存,我们使用节点的 id 来标识每个 Fiber 组件,转换为 Fiber 最终会生成如下图所示的结构,也是类似于 virtualDOM 结构的,构建的顺序是先 child => sibling => return,如果当前节点没有 child 了,这个节点就会完成。

Fiber 树

  • 收集依赖

收集依赖是在生成 Fiber 过程 (render 阶段) 中同时完成的,按照每个节点完成的顺序来构建链表,每个有了 Fiber 的组件通过自己的 nextEffect 指向下一个需要更新的组件,每一个父节点都有 firstEffect 和 lastEffect 来连接自己子节点的第一次更新和最后一次更新,最终会生成下图这样的更新链表。

副作用链表(更新链表)

  • 提交更新 commit

全部节点创建完 Fiber 之后,会进入 commit 阶段,会从 root 的 fistEffect(所有节点的第一个副作用阶段)开始更新,然后找 firstEffect 的 nextEffect 节点,以此类推,一气呵成全部更新完,然后清空更新链表,完成此次更新,这个过程不可打断。


总结

以上是 React 大概工作流程,主要以首次更新全部节点需要创建 Fiber 来讨论,后续会更新:基于 Fiber 的 diff、React 中合成事件、各种类型组件 (类组件,Function 组件)、hooks、事件优先级(expirationTime) 在内部如何调度相关。