React源码系列四:React Fiber 架构

2,737 阅读12分钟

1. 为什么使用Fiber

如果使用过React15的人,可能大致了解,其实15版本是基于Stack Reconcilation。它是递归、同步的方式。栈的优点在于用少量的代码就可以实现diff功能。并且非常容易理解。但是它也带来了严重的性能问题。接下来,我们了解一下原因。

1.1 浏览器渲染中的部分线程


  • GUI渲染线程: 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
  • JS引擎线程: 负责处理Javascript脚本程序。GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
  • 事件触发线程: 归属于浏览器而不是JS引擎,用来控制事件循环(Event Loop)。
  • 定时触发器线程: 传说中的setInterval与setTimeout所在线程, 通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
  • 异步http请求线程: 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

如上图所示,浏览器中其实有很多很多线程,但是JS是单线程的,只要其中某一个线程阻塞,那么其他线程就会长时间得不到执行。因此各个线程是互斥的。假如JS是多线程,我们想想会发生什么事情?当线程A修改Dom1的数据, 线程2删除Dom1的数据,浏览器就会产生矛盾,到底以哪个为准呢?所以这也就注定了JS需要是单线程运行的。

1.2 为何要优化Stack Reconcilation(栈调和器)

Stack Reconcilation(栈调和器)在进行计算的时候会阻塞整个线程,整个渲染过程必须是连续不断完成。而其他的任务(例如动画等)就会被阻塞,长时间地霸占CPU资源,这会产生明显感觉到卡顿。因此Stack Reconcilation既不能暂停渲染任务,也不能切分任务,更不能有效地平衡渲染和动画的执行顺序等等,这些也就注定了Stack Reconcilation被替代,而这也就是React 16引入Fiber架构的原因。
下面我借鉴荒山的文章的图片呈现对比:

  • React 15 使用递归对比VirtualDom树,需要将变动的节点找出并且更新,不能间断。这个过程被称为调和Reconcilation,在Reconcilation期间,浏览器一直霸占浏览器资源,导致用户触发的事件得不到响应,并且会导致掉帧,造成明显的卡顿。

1.3 为何要使用Fiber Reconcilation(纤维调和器)

在分析之前,我们可以先用以下例子来体验分片执行的好处。例子是通过不同方式生成1万个节点。

  • 一气呵成不中断
  • 分100次,每隔40毫秒插入节点
<!doctype html>
<html lang="en">
 <head>
<script>
function randomHexColor(){
    return "#" + ("0000"+ (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
// 2选一: 生成一个10000个dom节点,一气呵成
setTimeout(function() {
    var k = 0;
    var root = document.getElementById("root");
    for(var i = 0; i < 10000; i++){
        k += new Date - 0 ;
        var el = document.createElement("div");
        el.innerHTML = k;
        root.appendChild(el);
        el.style.cssText = `background:${randomHexColor()};height:40px`;
    }
}, 1000);

// 选一: 生成10000个节点,分批次插入,每次插入100个节点,共插入100次。
setTimeout(function () {
	var root = document.getElementById("root");
    function loop(n) {
        var k = 0;
        for (var i = 0; i < 100; i++) {
            k += new Date - 0;
            var el = document.createElement("div");
            el.innerHTML = k;
            root.appendChild(el);
            el.style.cssText = `background:${randomHexColor()};height:40px`;
        }
        if (n) {
            setTimeout(function () {
                loop(n - 1);
            }, 40);
        }
    }
    loop(100);
}, 1000);

  </script>
 </head>
 <body>
  <div id="root"></div>
 </body>
</html>


一气呵成的插入,明显感觉掉帧,普遍fps为1529ms

分时分片插入,让浏览器有个喘息的机会,普遍1fps为17ms。

通过上面的对比,可知与其一次性操作大量 DOM 节点相比, 分批延时对DOM进行操作可以得到更好的用户体验。其最终原因,还是因为浏览器的主线程是需要处理GUI描绘,事件处理,JS执行,资源加载,布局等。同一个时间段又只能处理一件事情。假如有足够的时间,给浏览器一点喘息的机会,浏览器还会对我们代码进行编译优化(JIT)及进行热代码优化,一些DOM操作,内部也会对reflow进行处理。reflow是一个性能黑洞,很可能让页面的大多数元素进行重新布局。

React 16采用Fiber Reconcilation。为了给用户造成一种应用很快的假象,不能让一个进程一直霸占着资源,因此配合合理的调度策略进行分配CPU资源,利用Fiber架构,让Reconcilation过程变为可被中断,适时让出CPU执行权,让浏览器及时响应用户交互,从而提高浏览器的响应速率。再次借鉴荒山 的文章的图片呈现React16版本的高性能对比图。

  • 同步模式,React16虽然实现了异步,但是并没有对外开放,可以使用实验版本进行一探究竟,应该在17版本性能会有质的提升

  • 优化后的 Concurrent 模式

2. 什么是Fiber

Fiber也称为协程或者纤程,它和线程是不一样的,并不拥有并发或者并行能力,它只是一种控制流程的让出机制

2.1 React主动出让机制

React Fiber: 和协程概念一致,React Fiber将任务划分为一个个不同优先级的工作单,配合调度策略,当渲染时出现高优先级任务,渲染过程可以被中断,将控制权交还给浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。
浏览器没有进程概念,任务的界限模糊,也不具备中断/恢复的条件,并且也没有抢占机制,我们无法中断一个正在执行的程序。因此,只能采取控制权出让机制。专业名词叫合作式调度(Cooperative Scheduling)
这是一种契约调度,要求我们的程序与浏览器紧密结合,相互信任。由浏览器给我们分配执行时间片,我们按照按照约定的时间内完成任务后,又将控制权返还给浏览器,一切都听从浏览器的指挥调度。

2.2 浏览器调度执行任务

React采用时间分片的策略,将任务划分为不同等级的工作单位,利用浏览器的空闲时间进行任务的执行,以保证UI操作的流畅。在浏览器中,主要由以下两个api可实现高优先级和低优先级的执行:

  • requestAnimationFrame: 执行高优先级任务
  • requestIdleCallback: 执行低优先级任务

函数详细链接

2.2.1 requestAnimationFrame

requestAnimationFrame 会在每一帧的开始阶段执行,一般用于绘制复杂的动画。属于高优先级任务。因为是每一帧开始时执行,我们可以模拟简单的时间分片调度。

  • 参数:
// 创建1000个任务
const tasks = Array.from({length: 1000}, ()=> () => { console.log('task');});

// 没20ms执行一次一个分片任务
const doTask = (index = 0) => {
    const start = Date.now();
    let i = index;
    let end;
    do {
        // 执行任务
        tasks[i++]();
        // 获取当前任务执行结束时间
        end = Date.now();
    } while( i < tasks.length && end - start < 20);
    
    console.log('----------', '20ms分界线');
    // 设置新的分片任务
    if (i < tasks.length) {
        // 调用requestAnimationFrame执行分片任务
        requestAnimationFrame(doTask.bind(null, i));
    }
};

// 第一次调用任务调度器
requestAnimationFrame(doTask.bind(null, 0));

// 执行结果:
// 347 task
// ---------- 20ms分界线
// 393 task
// ---------- 20ms分界线
// 260 task
// ---------- 20ms分界线

上面我们通过requestAnimationFrame简单地实现了每隔20ms执行一次任务的分时分片功能,但是加入我们的任务不到20ms或者超过20ms,那么执行时间就会出现过多或不够的问题。此时,我们就需要使用requestIdleCallback。

2.2.2 requestIdleCallback

当我们关注用户体验,不希望一些不重要的任务(例如统计上报)导致用户页面卡顿,可以使用requestIdleCallback。该函数属于低优先级任务,不确定回调一定执行。
我们看到的网页,是浏览器一帧一帧绘制的,通常FPS为60是比较流畅的。FPS为个位数,就能感知明显卡顿。浏览器再每一帧做了什么事情呢?

我们可以看到,在每一帧浏览器处理了交互、js执行、requestAnimationFrame调用、布局、绘制等等。假如浏览器再16ms (1000ms/60pfs)执行的任务不多,就有空闲时间执行requestIdleCallback的回调。

当页面没什么更新,其实浏览器是空闲状态,这时会有很长时间执行requestIdleCallback。但是当浏览器处于繁忙状态,也可能会出现requestIdleCallback回调一直无法执行。这其实也不是我们希望看到的。还好requestIdleCallback有第二个参数timeout,过期后就会执行。但是加入真在过期才知晓,用户也就能明显感知卡顿现象了。

// 创建1000个任务
const tasks = Array.from({length: 1000}, ()=> () => { console.log('task');});

// 没20ms执行一次一个分片任务
const doTask = (index = 0, idleDeadline) => {
    let i = index;
    do {
        // 执行任务
        tasks[i++]();
    } while( i < tasks.length && idleDeadline.timeRemaining() > 0); // 任务执行时间未过期
    
    console.log('----------', '20ms分界线');
    // 过期了,需要干什么
    if (idleDeadline.didTimeout) {
        console.log('过期啦', i);
    }
    // 设置新的分片任务
    if (i < tasks.length) {
        // 调用requestIdleCallback执行分片任务
        requestIdleCallback(doTask.bind(null, i), { timeout: 1000 });
    }
};

// 第一次调用任务调度器
requestIdleCallback(doTask.bind(null, 0), { timeout: 1000 }); // 1000ms过期时间,如果任务未执行,则立即执行
不建议在requestIdleCallback中进行DOM操作,因为这可能导致样式重新计算或重新布局(比如操作DOM后马上调用 getBoundingClientRect),这些时间很难预估的,很有可能导致回调执行超时,从而掉帧。

2.3 执行单元

Fiber另一种解读叫纤维:一种数据结构或者执行单元。将其视为执行单元,每完成一个执行单元,React会检查剩余时间,没有时间就将控制权让出来。
前面说到,当有更新时,会往updateQueue队列中插入任务。例如setState执行更新组件,会将任务加入队列中,等待更新的任务通过requestIdleCallback进行调用。

updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});

// 1. performWork 会拿到一个Deadline,表示剩余时间
function performWork(deadline) {

  // 2️. 循环取出updateQueue中的任务
  while (updateQueue.length > 0 && deadline.timeRemaining() > ENOUGH_TIME) {
    workLoop(deadline);
  }

  // 3️. 如果在本次执行中,未能将所有任务执行完毕,那就再请求浏览器调度
  if (updateQueue.length > 0) {
    requestIdleCallback(performWork);
  }
}

performWork: 分段执行的任务片段。只要有空闲时间就调用执行。
workLoop: 从updateQueue中获取更新任务进行执行,每执行一个执行单元,检测是否还有剩余时间,有就进入下一个执行单元,无剩余时间则保存现场,等下次拥有执行权再恢复。

// 保存当前的处理现场
let nextUnitOfWork: Fiber | undefined // 保存下一个需要处理的工作单元
let topWork: Fiber | undefined        // 保存第一个工作单元

function workLoop(deadline: IdleDeadline) { // deadline: 过期时间
  // updateQueue中获取下一个或者恢复上一次中断的执行单元
  if (nextUnitOfWork == null) {
    nextUnitOfWork = topWork = getNextUnitOfWork();
  }

  // 每执行完一个执行单元,检查一次剩余时间
  // 如果被中断,下一次执行还是从 nextUnitOfWork 开始处理
  while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {
    // 下文我们再看performUnitOfWork
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork, topWork);
  }

  // 提交工作
  if (pendingCommit) {
    commitAllWork(pendingCommit);
  }
}


3. React中的Fiber结构

上面我们提到,React15Stack Reconcilation,而React16使用的是Fiber Reconcilation。Fiber采用链表结构,每一个VirtualDom节点,对应一个fiber节点。

/**
 * FiberNode构造函数
 * @param tag 用于标记fiber节点的类型
 * @param pendingProps 表示待处理的props数据
 * @param key 用于唯一标识一个fiber节点
 * @param mode 表示fiber节点的模式
 */
function FiberNode(tag: WorkTag, pendingProps: mixed,key: null | string,mode: TypeOfMode) {
  // Instance
  this.tag = tag; // 用于标记fiber节点的类型。 FunctionComponent | HostRoot | HostPortal....
  this.key = key; // 用于唯一标识一个fiber节点
  this.elementType = null; // ReactElement.type,也就是我们调用`createElement`的第一个参数
  this.type = null; // 步组件resolved之后返回的内容,一般是`function`|`class`|module 类型组件名
  this.stateNode = null; // 对于rootFiber节点而言,挂在fiterRoot. 对于child fiber,挂在对应的组件实例

  // 以下属性创建单链表树结构
  // return属性始终指向父节点
  // child属性始终指向第一个子节点
  // sibling属性始终指向第一个兄弟节点
  this.return = null;
  this.child = null;
  this.sibling = null;
  ....
}

一个节点对应一个fiber,多个fiber节点之间建立连接,然后会生成一个fiber tree。fiber tree就是单链表树,而构建这个tree的关键属性就是return,child, sibling

  • Return: 每一个fiber都有值,指向父节点
  • child: 指向当前节点的第一个子节点
  • sibling: 指向当前节点的第一个兄弟节点
  • stateNode: 每隔fiber对象对应的组件或者dom
<div class="root">
    <h1 class="title">React header</h1>
    <div class="parent">
        <span>child1</span>
        <p>child2</p>
        <button>child3</button>
    </div>
</div>

3.1 中断恢复机制

有了链表结构,我们就能够来处理React树的节点啦。结合上面浏览器调度部分和workLoop,克制其实FiberNode就是我们的工作单元。performUnitOfWork负责对FiberNode进行操作,进行深度遍历,返回下一个工作单元FiberNode

/**
 * @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;
  }
}

结合workLoop, 因为是链表结构,即使流程被中断,我们保存了上一个执行单元,能够准确地从上次未处理完成的FiberNode继续执行,恢复工作流程。

3.2 Reconciliation(协调阶段) 和 Commit(提交阶段)

React除了Fiber工作单元的拆分,还有阶段拆分,也是非常重要的改造。在Stack Reconcilaition中是一遍进行diff,一遍进行commit。而Fiber Reconcilication是分为两个阶段:
协调阶段: diff阶段,可被中断,找出所有节点变更(节点增加、删除、修改、props修改等),这些被称为副作用Effect。以下生命周期会被调用(

  • constructor,
  • static getDerivedStateFromProps,
  • shouldComponentUpdate, render
    Commit阶段:将上一阶段计算的副作用Effect一次执行更新,该阶段同步不可打断。以下什么周期执行
  • getSnapshotBeforeUpdate() 严格来说,这个是在进入 commit 阶段前调用
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount
    协调阶段执行的工作时不会对用于造成任何可见的变更,改阶段可被中断、可恢复,可让出执行控制权。生命周期函数也可能被多次执行。而Commit阶段是不能被中断执行的。因为要正确地处理各种副作用变更(包括DOM变更),因为有副作用,所以必须保证按次序只调用一次。

4 结束

对于调和阶段和commit阶段,后续再进行详细的学习。学到这里,终于对Fiber架构有一个清楚的认识了,该部分并没有通过阅读源码来学习,而是从别人的总结中获得,因为理解了Fiber架构,才能更好地进行分析后续的代码。特别参考以下的文章:

  1. React源码系列一:React相关API
  2. React源码系列二:React Render之 FiberRoot
  3. React源码系列三:React.Render流程 二 之更新
  4. React源码系列四:React Fiber 架构
  5. React源码系列五:React Scheduler调度原理第一篇
  6. [React源码系列五:React Scheduler调度原理第二篇]....