React中的fiber树

356 阅读18分钟

引言

正如官方文档所说的,React是一个渲染用户界面的库,而React应用中,界面视图对应了React中的fiber树(自v16以来),一切操作围绕fiber树,一切特性建立其上。那么,要深入理解React的各个特性就需要先理解fiber树。

fiber

fiber的设计

fiber树由多个fiber节点构成,所以要先谈fiber。Andrew Clark首次正式公开提出了fiber的设计,以下两段都总结自他的文章。

React Fiber的目标就是增强对动画,布局,交互的适配能力,进一步说就是赋予React调度能力。fiber的首要特性是增量式渲染,也就是把整个渲染工作打成多个工作单元。

其他重要特性则包括工作的中断,撤销和恢复能力和优先级的调度能力。

这里的关键是增量式渲染

增量式渲染

先解释渲染。React应用了虚拟dom,fiber就是React的虚拟dom。渲染是获得虚拟dom树的过程,一个fiber的渲染就是获得它的子节点,全体fiber的渲染获得整棵树。fiber是有类型的,典型的类型包括funtiuon,class(就是你写的组件),二者各自的渲染就是函数的执行和render函数的执行。

再说增量式,基本上就是化整为零(总量并未减少),与全量式相对应。这里的关键是化整为零的部分之和要等于整体。一棵自然意义上的树,被切割成多个部分后再重新组装,得到的就不是原来那棵活着的树了。相反,一棵积木式的树,即使被拆成多个单元,经过组合,还是能够恢复为同一棵树。

每个fiber可以作为独立的渲染工作单元,就像积木中的元素。当所有的fiber被处理完成后,整棵fiber树就生成了。

增量式示例

下面有一个简单的对比示例,它是用纯Js实现的,展示了全量和增量的差异。先做一个简要的功能描述,这个应用会生成一个固定长度,元素从0-9随机生成的数组,随后会对这个数组各元素求和(刻意让求和变慢,方便做展示),在求和完成后把结果渲染出来。这个应用包含上中下三部分:

  • 一个纯文本。
  • 一个操作组,左边是一个生成数组然后求和的按钮,右边是一个模式切换单选钮。
  • 渲染内容,包括生成的数组,以及作为结果的和。

第一个示例是全量式,第二个是增量式+可中断,这里用文本的选定代表用户交互操作(点击,滚动同理)。流程都是点击按钮开始计算和渲染,然后立刻尝试选取文本框。两种模式展示出了现象差异。

全量式.gif

增量式.gif

在全量式示例中,文本选定的结果在渲染结果完成后才出现,也就是说,从开始渲染到渲染完成之前用户的交互是得不到响应的。

而在增量式+可中断示例中,文本选定几乎“即时”(其实是在两个加和的间隙)响应了,并且渲染过程也随后完成了(实际上全程都是可响应用户交互事件的)。

这是一个简陋的模拟,你可以把数组看作fiber树,每个数组元素是一个fiber,每一次加和是一个独立的加和工作单元(与每个独立的fiber渲染工作单元对应),在全部元素参与加和后(对应于整个fiber树渲染完成),作为真实dom被渲染到视图中(fiber树到真实dom的转换)。

这一切是怎么做到的呢?实现的代码也并不多:


let sum = 0;
let isIncremental = false;  // 点击模式切换按钮会改变这个指
let isAsync = true;   // 默认增量式下启用异步
const arrayDom = document.querySelector("#array");
const toggleDom = document.querySelector("#toggle");
const sumDom = document.querySelector("#sum");

function performSumIncremental(workArray, index) {
  const nextIndex = index + 1;
  const isFinished = workArray.length < nextIndex;

  if (isFinished) {
    commit();
    return;
  }

  function performUnitOfSum(n) {
    sleep(15);
    sum += n;
  }

  performUnitOfSum(workArray[index]);

  isAsync
    ? setTimeout(() => performSumIncremental(workArray, nextIndex))
    : performSumIncremental(workArray, nextIndex);
}

function performSum(workArray) {
  let currentSum = 0;
  workArray.forEach((num) => {
    sleep(15);
    currentSum += num;
  });
  sum = currentSum;

  commit();
}

function commit() {
  sumDom.innerHTML = `${sum}`;
  sum = 0;
}

function render() {
  const array = spawnArray(150);

  arrayDom.innerHTML = JSON.stringify(array);
  sumDom.innerHTML = "";

  setTimeout(() =>
    isIncremental ? performSumIncremental(array, 0) : performSum(array)
  );
}

// <button class="button" onclick="render()">生成数组,求和</button>

在这里,全量式下,求和是通过forEach迭代求得总和,一个函数完成。

增量式采用的是递归的方式,每次只处理一个元素,可中断情形以异步方式执行每个工作单元,不可中断情形以同步方式执行每个工作单元。

我制作了一张图片,便于理解。

屏幕截图 2024-11-17 193401.png

从图中其实也能很容易理解到,增量式渲染不是细粒度更新,实际上总工作量是没有减少的,渲染所需的总时间是增加的。

增量式的关键是化整为零。在加和示例中,因为部分和加总等于总体和,所以工作可以单元化。在fiber上,单元化体现为,fiber具有指向其他fiber的链接,全部单个fiber的生成就是fiber树的生成。

可中断的关键是异步。事实上Js也是刻意设计为单线程的,异步执行的方式。在两个异步工作单元间隙里,浏览器可以处理用户的交互事件,从而实现响应。在全量式下,只能同步执行导致阻塞,没有额外的线程可以处理用户交互事件。增量式则可同步可异步。

在示例中用到的异步方式为setTimeout,而React中可中断更新用到的是messageChannelonmessage回调。React不采用setTimeout和微任务的理由:

  • 对于setTimeout,即便不指定延迟或者指定0秒延迟,默认还是会有几毫秒的延迟,这是额外的开销,对应在上面的时序图里的工作间隔。
  • 对于微任务,Js的每一轮事件循环只会处理一个宏任务,但是会清空整个微任务队列。这意味着微任务的循环调用还是会导致阻塞,React的不可中断更新所使用的就是微任务。

此外React处理工作单元也并不是对每个fiber都单独异步处理(在加和示例中是这样),但基本原理还是异步,在以后讨论并发特性的文章里会有详细讨论。

那么工作的恢复呢?可以总结为两点:

  • 记录中断点
    • 在加和示例中,是作为函数实参的数组索引。
    • 在React中,是全局的workInProgress指针。
  • 有序的执行
    • 在加和示例中,索引加一。
    • 在React中,找到下一个fiber,一般是当前fiber的child或sibling。

fiber的生成

具体而言,fiber是一个Js对象,是从你写的jsx代码经过一系列转换得到的。对于React,人们很容易分不清组件,Element,虚拟dom,fiber,真实dom。

虚拟dom与真实dom对应,前者是UI的一种Js表现形式,后者是视图里的各个元素。vue也应用了虚拟dom,但是不同于fiber,React的虚拟dom就是fiber。

组件就是复合的funtion,class组件或者原生组件,它们在被jsx调用的时候就生成了Element。

在渲染阶段,React会根据Element生成对应的fiber。

屏幕截图 2024-11-17 024827.png

上面是这种转换的一个简略图,示例中的组件是原生组件。

fiber的结构

作为一个Js对象,fiber的属性包含了一个组件的信息,如组件的输入和输出。具体的实细节有可能随时间调整,所以下面会介绍一些关键属性。

key,type

key和type对于fiber的意义与它们对于React Element的意义是相同的。在React根据Element创建fiber时,这两个属性是直接复制的。

key的作用就是为diff服务的。

type是对一个组件的描述。如果是人们写的复合组件,type对应的是相应的函数和类的引用;如果是原生组件,type是'div','button'这样的字符串;如果是Suspense,这样的功能组件,或者是memo,lazy包裹的组件,type就是各自对应的标识,数据类型各不相同。

对于不同的type的fiber,渲染逻辑是不同的。实际上,memo,Suspense本身不包含功能逻辑,在渲染阶段React会根据标识执行不同的逻辑。

child,sibling,return

这些属性都指向其它的fiber,正是各个fiber以及它们之间的指向关系,构成了整棵fiber树。

function Parent() {
  return (
    <>
      <Child1 />
      <Child2 />
    </>
  )
}

child属性指向了第一个子节点,sibling指向后面的兄弟节点,return则指向父节点。在上面的示例中Parent的child属性指向Child1对应的fiber,Child1的sibling属性指向Child2对应的fiber,Child1和Child2的return都指向Parent对应的fiber。

fiber之间的相互链接使得fiber树的渲染可以按序进行,即便这个过程中断了,在任何一个节点都能按预定的正确顺序恢复。

pendingProps,memoizedProps

props是函数组件或者类组件的props。penddingProps是在fiber渲染之初设定,在渲染结束后就转化为memoizedProps。如果penddingProps与memoizedProps相等(比较引用地址),就可以跳过渲染。

React每次渲染都是全量的,即便是增量式渲染,也只是把大的渲染整体拆成以单个fiber为单位进行的部分之和。但是显然你会发现,在一个组件setState更新时,它的父级以及兄弟组件并不会渲染,这是因为它们在更新中penddingProps与memoizedProps相等。同样,更新的组件以下的所有子组件,如果没有memo,也会出乎意料的渲染,这是因为他们的penddingProps与memoizedProps不相等。通过props的生成,可以解释这个现象。

如果你足够细心的话,应该能够发现,组件接收到的是props,但是在调用的时候,传入的是一个个的prop,这是怎么回事?因为props是React createElement生成的,而且是以字面量{}生成的,每次生成的引用地址都不一样。触发setState的组件会渲染,执行函数。与此对应,函数返回的jsx就是createElement调用。环环相扣,后续组件都会渲染。

memoizedState

这个属性记录了组件对应的第一个hook,后者指向下一个hook,形成一个链表。

const [state, setState] = useState setState在返回时通过bind函数绑定了fiber,所以调用的时候能够找到对应的fiber。

alternate

对于正在渲染的fiber,alternate属性指向了上次渲染时的fiber,后者也有一个alternate属性指回。

在penddingProps与memoizedProps相等时,fiber可以跳过渲染。可是渲染是为了获得子代的信息,跳过了又怎么获得呢?答案是从alternate上获得,只要把alternate的子代信息给fiber的子代就可以实现复用。alternate属性相当于提供了缓存功能,实际上,通过alternate,React从某种意义上缓存了所有组件,无论你是否使用了相应缓存。

lanes

一个二进制数,每一位代表了一个优先级,同一个fiber上可能同时有多个优先级的更新。通过lanes可以实现调度。

每一次更新,React会指定优先级最高的lane,在处理每一个fiber时,会比对当前更新的lane与fiber的lanes,在这个fiber上只执行相匹配的lane。

stateNode

对不同type的fiber,有不同的意义:

  • 原生组件对应了dom
  • 复合组件为null
  • 根/rootFiber对应了应用的根/root

fiber树

无论是挂载还是更新,React总是会遍历生成整棵新fiber树,这棵树最终转化为界面视图 。这并不是说凡是更新每个函数组件都会执行,相反,通过复制当前的fiber节点(与视图中的真实dom对应)的子fiber信息,可以在不执行函数的同时,获得新的子fiber。

考虑这样一个简单的应用:

function App() {
    return (
        <div>
            2024
            <p>Hello world</p>
        </div>
    )
}

createRoot(document.getElementById("root")).render(<App />);

它的fiber树是这样的(不包含root):

屏幕截图 2024-11-15 005030.png

可以看到,fiber几乎与Element一一对应,实际上,不考虑rootFiber的话,fiber就是在工作中由Element生成的。

由jsx生成的Element没有对父元素的指针,一个Element也只是以props.children指向子元素数组,而fiber之间的关系则紧密得多,事实上fiber的数据结构也远远更复杂。

下面介绍fiber树的生成,也就是挂载与更新。

fiber树的生成

不管是函数组件还是类组件,只有组件经过渲染我们才能知道它的内容是什么,函数要被执行才能得到返回值,所以fiber树的形成需要一个按顺序进行的,一系列组件渲染的过程。这也是不管首次渲染还是后续更新,React都要从root遍历整个fiber树的原因。

Work

生成fiber树要调用一系列函数,核心逻辑在performUnitOfWork

function workLoopSync() {
  // Perform work without checking if we need to yield between fiber.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

一个fiber的处理工作包含了fiber对应组件的渲染,子fiber的diff,标记副作用(dom的插入,删除,更新等)等。在整棵fiber树生成后, 副作用会被执行,这是Work之外的。对于原生组件而言,副作用是dom的变化,对开发人员写的复合组件而言,就是useEffect的回调函数和生命周期钩子。

workInProgress

生成新的fiber树是在workInProgress上完成的。之前在介绍fiber的结构时,指出了fiber有atlternate这个属性。在React里,内存中一直会存在两颗fiber树:其一是与现在视图对应的(或者说上次更新的)current树,其二是正要生成的(或者说等待更新的)workInProgress树。它们各有一个atlternate属性指向对方。

两颗树的设计就是为了实现fiber的复用,或者说类似于缓存,不然生成fiber树所需要的工作代价太大。如果复用得当的话,React甚至也能有类似于细粒度更新的效果,可以有相当好的性能。当然,真正的细粒度更新不会遍历不需要更新的节点,与React的设计不同。

在不可中断更新情形下,workInProgress在工作开始时指向了root.current.atlternate,也就是workInProgress树的根节点。随着工作的进行,这个指针会指向不同的fiber。

performUnitOfWork

只保留主干逻辑的话,performUnitOfWork的内容是这样的:

function performUnitOfWork(unitOfWork):  {
  const current = unitOfWork.alternate;

  let next = beginWork(current, unitOfWork, entangledRenderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // 如果不会产生新的工作,就完成当前工作
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

beginWork中会发生组件的渲染,fiber的生成,diff等等。completeUnitOfWork中会调用completeWork,切换workInProgresscompleteWork主要是处理props,在挂载时还要创建dom(但还没有插入到页面中)。

从Element到Fiber

fiber是由Element生成的,而整棵fiber树的生成是一个个fiber递归生成的过程。在解释fiber时展示过一个简略图,接下来会有更详细的介绍。这种转化就如下图所示,以函数组件为例:

function Parent() {
  return (
    <>
      <Child1 />
      <Child2 />
    </>
  )
}

屏幕截图 2024-11-17 031043.png

在处理Parent组件对应的fiber时,会完成子代Element到子代Fiber的转化,Parent Fiber本身也是这样生成的。当然,如果父级组件是原生组件,那么父级的Element会有props.children直接指向子Element数组,而函数组件当然要在函数执行后才能得到children。

fiber工作次序

因为fiber之间的紧密联系,React可以使用深度优先遍历的方式处理fiber树,在这种稳定的顺序下,每一个fiber都可以知道在自己工作完成后,下一个工作单元是哪个fiber,配合全局的workInProgress指针,可以在任何中断的地方恢复工作。

performUnitOfWork(workInProgress)这样的代码,容易使人联想成每一轮循环传入一个fiber,然后这个fiber处理完毕,进入下一个循环。但实际上,传入的fiber其实只是这一次工作的开始,在一轮循环中未必有fiber被处理完成,也有可能处理多个fiber。

下面展示了整个工作的次序,通过图片示例,你可以看到深度优先遍历的次序:

function App() {
  return (
    <>
      <h1>
        <p>震惊!</p>
        <p>懂王胜选</p>
      </h1>
      2024
    </>
  );
}

屏幕截图 2024-11-15 231114.png 对于任意一个节点,深度优先遍历处理的下一个节点首先是child,如果没有child,就处理sibling。

挂载

通过createRoot可以创建React的root,在root调用render方法时,带来应用的首次渲染,也就是fiber树的挂载,例如:

function App() {
  const [text, greet] = useState("Hellow world!");
  return <h1 onClick={() => greet("Hi!")}>{text}</h1>;
}

createRoot(document.getElementById("root")).render(<App />)

在调用createRoot(document.getElementById("root")).render(<App />)后,React做的事情包括:

  1. 初始化一系列React信息,创建rootFiber,在root下,以current指向根fiberrootFiber
  2. 开始一次更新,创建workInProgress,第一个节点复用current树rootFiber。循环执行performUnitOfWork相关逻辑,凭借<App />这样的jsx得到的Element信息依次获得整棵workInProgress树。基本上是将Element转化成包含更多信息的fiber。
  3. 将得到的fiber转化为界面视图。
  4. root的current指向workInProgress树rootFiber

以图形的方式展现是这样的:

fiberMount.png

更新

对于上面的例子,如果我们点击了Hellow world!,那么会因为调用setState触发更新,React做的事情包括:

  1. 初始化workInProgress,指向workInProgress树rootFiber,循环执行performUnitOfWork相关逻辑生成新的fiber树,执行顺序与挂载时一致。这时workInProgressfiber与currentfiber互相以alternate属性指向对方。
  2. 将得到的fiber树转化为界面视图。
  3. root的current指向workInProgress树rootFiber

以图形的方式展现是这样的:

屏幕截图 2024-11-16 035431.png

图形中略过了workInProgress树的生成的中间阶段。

与挂载相比,更新的区别是不需要初始化信息,另外在生成fiber时也可以复用currentfiber的信息。这些都是就fiber而言的,当然dom也并不需要重新创建(除非更新带来了新的dom)。

对于函数组件与类组件对应的fiber,在恰当的情形,可以复用currentfiber的子代信息,使组件跳过函数执行(在上述例子里没有体现)。恰当的情形,指的是:

  • 没有更新,这个组件没有setState。
  • 没有context的更新。
  • props引用地址没有变化。

最后一点取决于Child组件有没有形如<Child/>的jsx调用。一般而言,如果父组件渲染了,那么函数会重新返回,相应地,jsx调用了。所以触发seteState的组件以下的所有组件默认都会渲染,而上层和同层组件不会渲染。React提供了由开发者决定是否复用currentfiber的api,也就是memo。

在组件渲染后,对子代的diff过程也可以实现fiber的复用,实际上,如果组件跳过了渲染,完全不需要diff。

fiber树的特点

节点单元化

fiber树中的每一个fiber节点都可以作为独立的工作单元,这意味着整个fiber树的渲染工作可以被拆解成多个部分。这些部分既可以以同步的方式依次处理,也可以以异步的方式处理,让中断和调度React的更新成为可能。

React对每一个fiber的大体处理逻辑是相同的,而且从任何一个节点出发,都能按既有的顺序处理后续节点。

在应用并发特性时,对fiber树的处理是这样的:

function workLoopConcurrent() {
  // 执行工作直到调度器通知中断
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

下面是React调度器调度更新回调函数的层级图(react-reconciler v0.29.0截至文章发布最新代码):

屏幕截图 2024-11-18 020119.png performWorkOnRootViaSchedulerTask在调度器中会被异步循环调用。即便workLoopConcurrent被中断,还是能够恢复执行。只要所有的workInProgress fiber被处理,无论它们是以同步还是异步的方式,对fiber树的处理工作完成。

全量更新

无论是挂载还是更新,生成一颗fiber树都需要自root遍历整棵树。即便应用了缓存,相应的节点还是被遍历到。这种全量更新是与细粒度更新相对应的。

可以说,如果没有缓存,渲染fiber树的开销是巨大的。

双缓存

在React应用的运行期间,内存中始终存在两颗fiber树:workInProgress树current树。需要从两个层次理解这一点:

  • 更新时,不会在current树上生成新fiber树,树不是只有一颗。
  • 在更新完成之后,两棵树仍然存在,因为它们各自链接向对方。

如果只是出于调度的需要,没有必要始终保留两棵树,可以仅仅是在更新的时候创建第二棵树。双缓存就是为了提升渲染性能,在组件不需要渲染的时候,复用之前的信息,跳过函数的执行。通过保留两棵树,React实际上缓存了所有组件,无论你是否应用了这些资源。

与React缓存相关的api,如memo,并未真正缓存组件,它仅仅是赋予开发者应用React自身缓存的能力。