说一下你理解的 React Fiber

一、React Fiber 为什么出现

在 React15 以前,React 的组件更新创建虚拟 DOM 和 Diff 的过程是同步并且不可中断的

如果需要更新组件树层级非常深的话,在 Diff 的过程会非常占用浏览器的线程,而浏览器执行 JS 的线程和渲染真实 DOM 的线程是互斥的具体可以看一下这篇文章),也就是同一时间内,浏览器要么在执行 JS 的代码运算,要么在渲染页面,如果 JS 的代码运行时间过长则会造成页面卡顿

二、React Fiber 是什么

基于以上原因 React 团队在 React16 之后就改写了整个架构,将原来数组结构的虚拟DOM,改成叫 Fiber 的一种数据结构 ,基于这种 Fiber 的数据结构可以实现由原来同步的不可中断的更新过程变成异步的可中断的更新

对于 React Fiber 是什么,从架构角度来看,官方的解释是:React Fiber 是对核心算法的一次重新实现

从编码角度来看,Fiber 是 React 内部定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的虚拟 DOM。

React Fiber 架构的核心是"可中断"、"可恢复"、"优先级"

React Fiber 主要通过 FiberNode 的一些属性去保存组件相关的一些信息:

ts 复制代码
function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  /** 作为静态数据结构的属性 */
  this.tag = tag;  // 组件类型,如 Function/Class
  this.key = key;  // 唯一值,通常会在列表中使用
  this.elementType = null;
  this.type = null;  // 元素类型,字符串或类或函数,如"div"/Class/ComponentFn
  this.stateNode = null;  // 指向真实 DOM 对象

  // 靠以下属性连成一个树结构的数据,也就是 Fiber 链表
  this.return = null;  // 指向父级 Fiber 节点
  this.child = null;   // 指向子 Fiber 节点的第一个
  this.sibling = null; // 指向兄弟 Fiber 节点
  this.index = 0;

  this.ref = null;

  // 作为动态的工作单元的属性
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  this.effectTag = NoEffect;
  this.nextEffect = null;

  this.firstEffect = null;
  this.lastEffect = null;

  // 调度优先级相关
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 指向该fiber在另一次更新时对应的fiber
  this.alternate = null;
}

比如:

jsx 复制代码
function App() {
  return (
    <div className="app">
      <span>Hello</span>, World!
    </div>
  )
}

形成的 Fiber 树为:

三、React Fiber 做了什么

之前,递归渲染 vdom,然后 diff 下来做 patch(补丁) 的渲染,整个渲染和 diff 是递归进行的:

现在 ,是先把 vdom 转为 fiber(reconcile 调和的过程),因为 fiber 是链表结构,可以打断,空闲时调度(requestIdleCallback)就行,最后,全部转换完之后,再一次性 render,这个过程叫 commit 阶段

React16 的架构分为三层:

  • Scheduler(调度器):调度任务的优先级,高优先级的任务优先进入 Reconciler。
  • Reconciler(协调器):负责找出变化的组件。
  • Renderer(渲染器):负责将变化的组件渲染到页面上。

在 React16 版本中,主要做了以下的操作:

  • 做了时间分片 ,拆分了多个任务,并且为每个任务增加了优先级 ,优先级高的任务可以中断低优先级的任务。然后再重新执行优先级低的任务。
  • 增加了异步任务 ,调用 requestIdleCallback api,在浏览器空闲的时候执行
  • 使用了双缓存 Fiber 树,DOM diff树变成了链表,一个 DOM 对应两个 fiber,对应两个队列,这都是为找到被中断的任务,重新执行。

任务优先级

  • NoPriority:无优先级
  • ImmediatePriority:立即执行
  • UserBlockingPriority:用户阻塞优先级,不执行可能会导致用户交互阻塞
  • NormalPriority:普通优先级
  • LowPriority:低优先级
  • IdlePriority:空闲优先级

requestIdleCallback

requestIdleCallback 是一个高级的调度方法,用于在浏览器空闲时执行任务。

它会在浏览器的主事件循环空闲时执行指定的回调函数,以避免阻塞用户交互和其他高优先级任务。
requestIdleCallback 的回调函数将提供一个 IdleDeadline 参数,可以用于判断剩余的空闲时间,并根据需要,执行任务的片段。

js 复制代码
function performTask(deadline) {
  while (deadline.timeRemaining() > 0) {
    // 在空闲时间内执行你的操作
  }
  // 如果任务没有完成,继续请求下一次空闲回调
  requestIdleCallback(performTask);
}
// 启动空闲回调
requestIdleCallback(performTask);

// 停止空闲回调
var idleCallbackId = requestIdleCallback(performTask);
cancelIdleCallback(idleCallbackId);

双缓存 Fiber 树

在 React 中,最多会同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树称为 current Fiber 树,正在内存中构建的 Fiber 树称为 workInProgress Fiber 树

current Fiber 树 中的 Fiber 节点被称为 current fiberworkInProgress Fiber 树 中的 Fiber 节点被称为 workInProgress fiber,它们之间通过 alternate 属性连接。

workInProgress Fiber 树 构建完成交给 Renderer 渲染在页面上后,应用根节点的 current 指针指向 workInProgress Fiber 树,此时 workInProgress Fiber 树 就变成 current Fiber 树

每次状态更新都会产生新的 workInProgress Fiber 树,通过 currentworkInProgress 的替换,完成 DOM 更新。

双缓存 Fiber 树在 mount 阶段的构建流程

jsx 复制代码
function App() {
  const [num, setNum] = useState(0);
  return <p onClick={() => setNum(num + 1)}>{num}</p>;
}

ReactDOM.render(<App />, document.getElementById("root"));
  1. 首次执行 ReactDOM.render 会创建 fiberRootNode(源码中叫 fiberRoot)和 rootFiberfiberRootNode 是整个应用的根节点(只有一个),rootFiber<App /> 所在组件树的根节点。
    由于是首屏渲染,页面中还没有挂载任何 DOM,所以 fiberRootNode.current 指向的rootFiber没有任何子 Fiber节点(即current Fiber 树为空)。
  1. 接下来进入render阶段,根据组件返回的 JSX,在内存中依次创建Fiber 节点并连接在一起构建Fiber树,被称为workInProgress Fiber 树
    workInProgress Fiber 树 的创建可以复用current Fiber 树对应的节点数据。 在下面的 diff 算法中会说明如何判断是否可复用。
  1. 将已经构建完的workInProgress Fiber 树在 commit 阶段渲染到页面。使得workInProgress Fiber 树变为current Fiber 树

双缓存 Fiber 树在 update 阶段的更新流程

  1. 点击 p 标签触发状态更新,会开启一次新的 render 阶段,并构建一棵新的 workInProgress Fiber 树
  1. 将已经构建完的workInProgress Fiber 树在 commit 阶段渲染到页面。使得workInProgress Fiber 树变为current Fiber 树

React Diff 算法

一个DOM 节点在某一时刻最多会有4个节点和它相关:

  1. current Fiber ,如果该DOM 节点已经在页面上,current Fiber代表该DOM 节点对应的 Fiber 节点。
  2. workInProgress Fiber ,如果该DOM 节点将在本次更新中渲染到页面上,那么workInProgress Fiber代表该DOM 节点对应的Fiber 节点
  3. DOM 节点本身
  4. JSX 对象 ,即类组件或函数组件返回的结果,JSX 对象中包含描述DOM 节点的信息。

Diff 算法的本质是对比 1 和 4,生成 2。

为了降低算法复杂度,React 的 Diff 算法会预设三个限制:

  1. 只对同级元素进行 diff。如果一个 DOM 节点在前后两次更新时跨越了节点,那么 React 不会复用它。

  2. 两个不同类型的元素会产生不同的树 。如果元素从div变成p,那么 React 会销毁div及其子孙节点,并新建p及其子孙节点。

  3. 可通过key来表示哪些子元素在不同的渲染情况下能保持稳定。

    jsx 复制代码
    // 更新前
    <p key="hello">hello</p>
    <div key="world">world</div>
    
    // 更新后
    <div key="world">world</div>
    <p key="hello">hello</p>

    如果没有key,则符合 2 的限定。
    但如果我们使用key指明了节点前后的对应关系后,React 知道key="hello"p标签在更新后还存在,那么DOM 节点可复用,只是需要交换一下顺序。


从同级的数量类型可将 Diff 分为两类:

  1. newChild 类型为 object、number、string,代表同级只有一个节点;
  2. newChild 类型为 Array,代表同级有多个节点。

单节点 Diff

如何判断 DOM 节点是否可复用?
  • key 不同

    jsx 复制代码
    // 更新前
    <ul>
      <li key="one">one</li>
      <li key="two">two</li>
      <li key="three">two</li>
    </ul>
    
    // 更新后
    <ul>
      <p key="two">three</p>
    </ul>

    key="one"的 li 标签和 key="two"的 p 标签,仅表示遍历到的该 fiber不能被 p 复用,后面还有兄弟 fiber 没有遍历到,所以只需要标记删除该 fiber 节点。

  • key 相同,type 不同

    jsx 复制代码
    // 更新前
    <ul>
      <li key="two">two</li>
      <li key="three">two</li>
    </ul>
    
    // 更新后
    <ul>
      <p key="two">three</p>
    </ul>

    key="two"的 li 标签和 key="two"的 p 标签,type 不同,表示唯一的可能性不能复用了,那么后续的兄弟 fiber 也没机会了,所以都可以标记清楚。

多节点 diff

针对多节点的更新,会有以下三种情况:

  1. 节点更新

    jsx 复制代码
    // 更新前
    <ul>
      <li key="0" className="before">0</li>
      <li key="1">1</li>
    </ul>
    
    // 更新后 - 情况1 节点属性发生变化
    <ul>
      <li key="0" className="after">0</li>
      <li key="1">1</li>
    </ul>
    
    // 更新后 - 情况2 节点类型更新
    <ul>
      <li key="0">0</li>
      <li key="1">1</li>
    </ul>
  2. 节点新增或减少

    jsx 复制代码
    // 更新前
    <ul>
      <li key="0">0</li>
      <li key="1">1</li>
    </ul>
    
    // 更新后 - 情况1 新增节点
    <ul>
      <li key="0">0</li>
      <li key="1">1</li>
      <li key="2">2</li>
    </ul>
    
    // 更新后 - 情况2 删除节点
    <ul>
      <li key="1">1</li>
    </ul>
  3. 节点位置变化

    jsx 复制代码
    // 更新前
    <ul>
      <li key="0">0</li>
      <li key="1">1</li>
    </ul>
    
    // 更新后
    <ul>
      <li key="1">1</li>
      <li key="0">0</li>
    </ul>

多节点 diff更新过程如下
第一轮遍历:

  1. let i = 0,遍历newChildren,将newChildren[0]oldFiber比较,判断oldFiber是否可复用;
  2. 如果可复用,i++,继续比较newChildren[i]oldFiber.sibling,如果可复用,则继续遍历;
  3. 如果不可复用:
    • key 不同导致不可复用,立刻跳出整个遍历,第一轮遍历结束
    • key 相同而 type 不同导致不可复用,将oldFiber标记删除,继续遍历。
  4. 如果 newChildren 遍历完(i === newChildren.length - 1)或者oldFiber遍历完(oldFiber.sibling === null),跳出遍历,第一轮遍历结束

从上述步骤 3 中跳出,此时newChildren没有遍历完,oldFiber也没有遍历完,如下:

jsx 复制代码
// 更新前
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
            
// 更新后
<li key="0">0</li>
<li key="2">1</li>
<li key="1">2</li>

`遍历到 key === 2 时,发现更新前后 key 不同,不可复用,跳出第一轮遍历;`
`此时 oldFiber 剩下 key = 1、key = 2 未遍历,newChildren 剩下 key = 2、key = 1 未遍历`

从上述步骤 4 中跳出,可能newChildren遍历完,或者oldFiber遍历完,或者两个都遍历完,如下:

jsx 复制代码
// 更新前
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>

// 更新后 - 情况1 newChildren 和 oldFiber 都遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>

// 更新后 - 情况2 newChildren 未遍历完,oldFiber 遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>

// 更新后 - 情况3 newChildren 遍历完,oldFiber 未遍历完
<li key="0" className="aa">0</li>

第二轮遍历:

  • newChildren 和 oldFiber 都遍历完:

    在第一轮遍历结束后更新组件,Diff 结束;

  • newChildren 没遍历完,oldFiber 遍历完:

    表示有新增节点,只需要将剩下的 newChildren 生成 workInProgress Fiber,并依次标记新增(Placement);

  • newChildren 遍历完,oldFiber 没遍历完:

    表示有节点被删除,只需要遍历剩下的 oldFiber,依次标记删除(Deletion);

  • newChildren 和 oldFiber 都没遍历完:

    表示有节点在本次更新中改变了位置。 声明一个变量:

    js 复制代码
    let lastPlacedIndex = 0;  // 表示最后一个可复用的节点在 oldFiber 中的位置索引

四、面试题

key 的作用

在 diff 算法中通过 key 和 type 判断 DOM 节点是否可复用。

key 的值不能是 index 或 random。


React diff 算法为什么不支持双指针?

虽然 JSX 对象的 newChildren 为数组类型,同层级的 fiber 节点之间是通过 sibling 指针链接成的单链表,不支持双指针遍历。

newChildren[0]fiber 比较,newChildren[1]fiber.sibling 比较。

所以,React diff 算法不支持双指针。

如有问题,欢迎指正~

相关推荐
大前端爱好者1 小时前
React 19 新特性详解
前端
随云6321 小时前
WebGL编程指南之着色器语言GLSL ES(入门GLSL ES这篇就够了)
前端·webgl
无知的小菜鸡1 小时前
路由:ReactRouter
react.js
J老熊2 小时前
Spring Cloud Netflix Eureka 注册中心讲解和案例示范
java·后端·spring·spring cloud·面试·eureka·系统架构
我爱学Python!2 小时前
面试问我LLM中的RAG,秒过!!!
人工智能·面试·llm·prompt·ai大模型·rag·大模型应用
OLDERHARD2 小时前
Java - LeetCode面试经典150题 - 矩阵 (四)
java·leetcode·面试
寻找09之夏2 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
银氨溶液3 小时前
MySql数据引擎InnoDB引起的锁问题
数据库·mysql·面试·求职
多多米10053 小时前
初学Vue(2)
前端·javascript·vue.js
柏箱3 小时前
PHP基本语法总结
开发语言·前端·html·php