一篇关于React虚拟DOM、DIFF算法、FIBER机制的文章......

178 阅读35分钟

前言

大家好,由于最近作者忙于面试和准备面试,于是最近就疏于写文章,其实长期以来一直想写一篇文章聊聊我理解的React的虚拟DOM和diff算法 以及我理解的React Fiber机制,趁着忙里偷闲,终于能够花些时间开始动笔写下这篇文章了!让我们直接开始吧

目录

  1. React的虚拟DOM
  2. React的diff算法
  3. React的旧协调机制
  4. React的Fiber架构
  5. 现代化的React

由于作者的功底有限,主要是通过查看一些大佬的文章和学习一些野文等方式 细细了解 ,所以主要是以浅谈为主,不是很官方和专业 文中有许多不足之处也欢迎大家指出 并且一起讨论🙏🙏🙏

1.React的虚拟DOM

其实作者在刚开始学React就知道了虚拟DOM的相关概念了,但也是仅限于知道,在面试中被问到了并不能说出让面试官满意的答案,很多读者应该跟我一样,对这个知识的理解不够深刻,接下来就聊聊我认为虚拟DOM以及它比较"深一点"的部分吧!

什么是虚拟DOM?从真实DOM开始说起

要搞清楚虚拟DOM之前,一定要先知道什么是真实DOM,真实DOM的定义你是否能够准确地表达出来?它是指:

真实 DOM 即文档对象模型,是浏览器将 HTML 解析后生成的树形结构,映射页面元素及关系,可通过 JS 操作(如增删改查),但直接频繁操作会触发重排重绘,影响性能。

上面这个官方的回答我们能够了解:真实DOM是原生的,在前端的最早期,没有React这个东西,那我们就是通过JS进行真实DOM的改改删删,(例如选择元素:getElementById()querySelector(),修改内容:innerHTMLtextContent

既然有真实DOM?那为什么需要虚拟DOM?

在前端长河的最最古早时代,当时的前端程序员都用createElement()这种方式进行对真实DOM删删改改,完成我们的页面开发。

但是技术是需要发展的,一个时代总有那么一群先驱能够推动技术的进步,他们查看到了这种方式带来的问题:太容易触发重绘重排!

什么是重绘重排?

重绘指的是 当元素的样式发生改变(例如opcaity、bcg)但是不影响当前页面的布局时,浏览器会对受改变的部分进行重新绘制的过程。

重排指的是 当改变页面的结构或者某些样式发生改变时 会影响当前页面的布局时,浏览器需要重新计算布局树,从而进行重新绘制的过程

这样看来,在对于真实DOM的操作中,几乎每次修改样式/增删节点都会触发重绘重排,这是对性能不友好的,我们完全可以用一些手段去优化:减少重绘重流。

而虚拟DOM是一个很好的解决方案!!!可以说正是为了这个目的而生的

正式介绍下虚拟DOM!

虚拟 DOM 是真实 DOM 的内存抽象副本,以 JavaScript 对象形式存在!本质是通过 Diff 算法减少真实 DOM 操作,优化重绘回流,提升性能。

比喻一下它们的关系:可以把真实 DOM比作 “正在施工的实体建筑”,它是最终呈现给用户的实际结构,每动一砖一瓦(修改真实 DOM)都要现场施工,耗时长、影响整体;

虚拟 DOM是建筑的 “设计图纸”,先在图纸上修改、对比(Diff 算法),确定最终要改的部分,再按图纸高效调整实体建筑,减少不必要的施工成本。

看看虚拟DOM的结构吧

虚拟DOM既然要当真实DOM的抽象副本,结构上肯定要和真实DOM相似,我们看看它是如何模仿真实DOM的

先看看真实DOM的结构吧,拿一个最简单的真实DOM树来看

image.png

可以看到,对于每一个真实DOM节点,有以下三部分组成:

  • 节点的类型:head、body、title
  • 节点的属性和文本:charset、href、实际文本
  • 节点的children:当前节点的children部分指向树上的子节点

现在知道了真实DOM的结构,虚拟DOM就可以模仿,让我们看看它的结构:

// 虚拟DOM元素的类,构建实例对象,用来描述DOM
class Element {
    constructor(type, props, children) {
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
// 创建虚拟DOM,返回虚拟节点(object)
function createElement(type, props, children) {
    return new Element(type, props, children);
}
export {
    Element,
    createElement
}

可以看到,虚拟DOM模仿地很成功,看起来就像一个真实DOM,因此,有了如此相似的结构,我们对它的操作也能更好地映射到真实DOM上,意味着在一些场景下,我们不需要每次都操作真实DOM,而是操作虚拟DOM,从而让React根据对虚拟DOM的操作 经过优化算法对 真实DOM进行操作:这也是下文我们要详谈的diff算法。

虚拟DOM之进阶

实际上,上面写的虚拟DOM结构主要是为了帮助大家理解,下面看看React到底是怎么生成虚拟DOM的吧!(这部分小白可以先跳过 有点抽象)

大家可以先去看下作者之前写的一篇关于JSX的文章作为前置知识,再来理解下文:

从JSX说起

大家有没有想过,我们作为开发者,平常写的一大堆组件都是用JSX语法写的,那你是否想过,React到底是怎么将我们写的组件转换成虚拟DOM树?这是我们接下来要探讨的

首先你需要清楚一点:JSX 不能独立运行

  • JSX 的语法(如 <div>Hello</div>)并不是原生 JavaScript 的一部分,浏览器和 JavaScript 引擎无法直接解析它。
  • JSX 最终需要被转换为标准的 JavaScript 函数调用(如 React.createElement()),否则会报语法错误。

要让JSX能够运行的话,是需要先通过 Babel 工具将 JSX 转为 JavaScript,然后再运行JavaScript。

Babel编译

上文我们提到了,Babel这种工具可以将我们开发者写的JSX代码 编译成 js代码,看看它是如何编译的吧!

编译前:

image.png

编译后:

image.png

可以看到:Babel 成功地将原来的JSX语法 转换成了 js 语法,上面是不可运行的,下面是可运行的,因为React帮我们已经写好了React.createElement这个函数!

详谈React.createElement---虚拟DOM的核心

这个函数是React框架帮我们写好了的,通过查看React的源码后,你就能清楚它的作用是什么,这里作者就不给出源码了,直接给出 作者理解后的知识

React.createElement是一个函数,旨在创建虚拟DOM,它接受三个参数type,props,children(仔细看看,它就是我们上文提的虚拟DOM结构呀),返回虚拟DOM节点。

来,接下来给出简化版的createElement

function createElement(type, props, ...children) {
    // 参数的抽象 VDOM 树状结构来定义的 
    // 递归思想
    return {
        type,
        props: {
            ...props,
            children: children.map(child => 
                typeof child === "object"
                ? child
                // 文本结点是叶子结点 
                : createTextElement(child)
            )
        }
    }
}
// 对于叶子节点
function createTextElement(text) {
    return {
        type: "TEXT_ELEMENT",
        props: {
            nodeValue: text,
            children: []
        }
    }
}

OK,有了它的定义之后,你肯定就能知道这里是在干嘛了:那不就是创建虚拟DOM吗!而且注意,有多级嵌套的结果,它也能兼顾哦! image.png

所以总结下来看,React.createElement就是帮我们创建虚拟DOM节点的方法:它创建的结果就是js的对象,也符合我们上面说的:虚拟DOM的本质就是JS对象

它为什么会编译成这样?

Babel工具可以将JSX或者ES6的代码转换浏览器广泛支持的旧版的JavaScript,上面演示的是前者:将JSX编译成包含大量React.createElement的代码

为什么它偏偏就要编译成这种模式呢?

这是因为React规定了它需要编译成这种模式,这么说你可能有点不能理解

这样说吧,Babel工具可以用在前端各个领域,每个领域的一些操作(比如ES6 转成 ES5)都可以使用它,而使用的方式是通过创建或修改 babel.config.json 文件来规定 它里面写的是编译的规则

所以只要你在babel.config.json里面写了你指定的规则 想怎么编译就怎么编译

但React限制了独属于React的编译规则:根据当前的JSX语法的代码,转换为createElement方法包含的js代码。

编译的方式并非唯一可能

所以你知道了可以通过babel.config.json来规定编译方式,所以:

我们可以修改babale.config.json来自定义编译方式!所以,我们可以手写React!像下面这样

写个Didact,模拟了我们的React的createElementrender行为

Didact.createElement和Didact.render

// Didact模拟React
const Didact = {
    createElement,
    render 
}
// 方法createElement返回虚拟DOM节点
function createElement(type, props, ...children) {
    // 这里参数是模拟 真实DOM树 来定义的
    // 递归思想
    return {
        type,
        props: {
            ...props,
            children: children.map(child => 
                typeof child === "object"
                ? child
                // 文本结点是叶子结点
                : createTextElement(child)
            )
        }
    }
}
// 方法createTextElement创建叶子节点
function createTextElement(text) {
    return {
        type: "TEXT_ELEMENT",
        props: {
            nodeValue: text,
            children: []
        }
    }
}
//  方法render用来渲染
function render(element, container) {
    // 不考虑组件
    const dom = element.type === "TEXT_ELEMENT"
    ? document.createTextNode("")
    : document.createElement(element.type);

    const isProperty = key => key !== "children";

    Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
        dom[name] = element.props[name]//setAttribute 简写
    })

    element.props.children.forEach(child => 
        render(child,dom)
    )

    container.appendChild(dom);
}

看上去很复杂对吧,大家可以结合React的特点去理解,由于篇幅原因,这里暂时先不详细展开了,后续作者有时间的话 可以深度解析下 React的这个手写以及其它的一些手写

小小总结一下

经过上面的操作后,我想你能够理解了大概的流程了吧:实际上React框架做的事就是

  1. React框架中集成了Babel工具,能够由React控制编译
  2. 得到编译后的代码,包含了大量的React.createElement
  3. 执行代码:运行React.createElement,从而得到返回的结果,即整颗虚拟DOM树

因此这就是虚拟DOM树的生成过程了,我想看完上述的讲解后 你应该会理解地更深

2. React的diff算法

通过上面的介绍我们知道了:有了虚拟DOM之后,我们只需要对虚拟DOM进行操作,而框架会根据我们对虚拟DOM进行的操作(比如增加、修改、删除、修改属性等),对真实DOM进行操作。

那么问题来了,框架在这个过程中到底做了些什么事情呢?这就是我们接下来要聊的diff算法。

什么是diff算法?

diff 算法是比较新旧虚拟 DOM 树差异的算法,找出需更新的节点,仅将变化应用到真实 DOM,减少操作次数,提升性能。

diff算法什么时候执行呢?

最典型的场景是: 当组件中的state或者props发生更新时:

  1. React会根据更新后的新state和props生成一颗新的虚拟DOM树
  2. React会利用diff算法比对原有的虚拟DOM树和新的虚拟DOM树
  3. 比对后会得到DOM 更新补丁(Patch),你可以简单理解为一个数组(它记录了需更新节点的位置类型和具体操作)
  4. React按照这个更新补丁去更新真实DOM

综上所看,diff算法是在需要比对前后虚拟DOM树时执行的

diff算法的具体内容

到了这个地方,肯定很多大佬都具体了解了,比如“同层比较”、“key比较”等概念我想大家都有所耳目,我这里就以一个具体案例聊聊我对diff算法具体实现的理解:

旧虚拟DOM树:

div (key: 'container')
├─ h2 (key: 'title', text: '水果列表')
└─ ul (key: 'list')
   ├─ li (key: '1')
   │  ├─ span (key: 'name', text: '苹果')
   │  └─ span (key: 'price', text: '¥5')
   └─ li (key: '2')
      ├─ span (key: 'name', text: '香蕉')
      └─ span (key: 'price', text: '¥3')

新虚拟DOM树:

div (key: 'container')
├─ h2 (key: 'title', text: '新鲜水果')  // 文本修改
└─ ul (key: 'list')
   ├─ li (key: '1')
   │  ├─ span (key: 'name', text: '苹果')
   │  └─ span (key: 'price', text: '¥6')  // 价格修改
   ├─ li (key: '3')  // 新增节点
   │  ├─ span (key: 'name', text: '草莓')
   │  └─ span (key: 'price', text: '¥10')
   └─ li (key: '2')
      ├─ span (key: 'name', text: '香蕉')
      └─ span (key: 'price', text: '¥3')

来,让我们开始分析:

  1. 同层比较:旧div (key: 'container')新div (key: 'container')

这两个节点符合:类型相同,props相同,ok,那就继续递归,新旧虚拟DOM树同时递归,走着!

  1. 继续同层比较:旧h2 (key: 'title', text: '水果列表')新h2 (key: 'title', text: '新鲜水果')

这两个节点符合:类型相同,props不相同(text不相同),OK,记录“修改text” 这一操作到更新补丁中,然后,新旧虚拟DOM树继续同时递归,走着!

  1. 继续同层比较:旧ul (key: 'list')新ul (key: 'list')

这两个节点符合:类型相同,props相同,但是它们的children是列表的元素,即不同的li (key: '1'),这个时候,就要对整个ul (key: 'list')采用key比较:

  • key='1' 的 li:子元素 price 文本从 ¥5 变为 ¥6,生成子节点更新补丁
  • key='2' 的 li:所有内容未变更,无需生成补丁
  • 新增 key='3' 的 li:生成节点新增补丁{ type: 'INSERT', vnode: 新li节点 }

列表 diff 的优化点:通过 key 建立映射关系,避免了同位置元素的误判(如 key='2' 的 li 虽位置变化但无需重建)

diff 算法之进阶

大佬们可以去看这篇文章,讲了具体的diff算法实现: 聊聊 Vue 的双端 diff 算法Vue 和 React 都是基于 vdom 的前端框架,组件渲染会返回 vdom,渲 - 掘金

核心观点如下: 传统树对比复杂度 O (n³),前端框架通过 “只同层对比、type 不同不查子节点” 优化到 O (n),同时用 key 标识节点唯一性,减少 DOM 操作。

多节点 diff 有两类核心实现:简单 diff 遍历新节点,按 key 找旧节点复用,通过 lastIndex 判断是否移动,最后删旧节点中未复用的;双端 diff(Vue2 用)用四指针从两端向中间对比,优先匹配头头、尾尾等组合,更少移动节点,最后批量处理新增 / 删除。两者核心都是借 key 复用节点,降低 DOM 操作成本。


3. React的旧协调机制

接下来聊聊React的旧协调机制吧,为什么会称作:“旧”协调机制呢? 因为在React16之后推出了Fiber机制,后续的React18一直采用的是Fiber机制,所以我们聊的这个协调机制确实是“旧的” 但我觉得 作为React的学习者,了解旧协调机制 还是很有必要的。让我们直接开始吧!

在 react16 引入 Fiber 架构之前,react 会采用递归对比虚拟DOM树,找出需要变动的节点,然后同步更新它们,这个过程 react 称为reconcilation(协调)。在reconcilation期间,react 会一直占用浏览器资源,会导致用户触发的事件得不到响应。下面会详细解释,看完我相信你就能懂啦!

1.旧协调机制和浏览器的核心关系

浏览器的主线程是单线程的,负责处理 JavaScript 执行、DOM 操作、布局(Layout)、绘制(Paint)等核心任务,这些任务按 “事件循环”(Event Loop)的顺序依次执行。

React 16 之前的渲染过程完全运行在主线程上,且由于是同步递归执行(无法被中断),当渲染任务耗时较长时,会阻塞浏览器的其他关键任务(如用户输入响应、动画帧更新等),导致页面卡顿。

简单说:栈协调模式下,React 的渲染任务会 “独占” 主线程直到完成,期间浏览器无法处理其他任务

2.旧协调机制的核心流程

栈协调模式的渲染流程可分为 “协调阶段”(Reconciliation)“提交阶段”(Commit) 两大步骤,整个过程与浏览器的交互如下:

1. 触发更新:从 setState 到进入协调

当用户触发更新(如调用setState、父组件传参变化等),React 会标记组件为 “脏组件”,并启动同步的渲染流程。

  • 此时,React 会将更新任务放入主线程的执行队列,等待当前同步任务执行完毕后开始处理。
  • 浏览器此时会暂停其他任务(如动画、输入事件),等待 React 的渲染任务执行。
2. 协调阶段(Reconciliation):递归对比虚拟 DOM(纯 JS 计算,不操作真实 DOM)

协调阶段的核心是 “找出前后虚拟 DOM 的差异”(Diff 算法),这个过程完全在 JavaScript 层面完成,不涉及真实 DOM 操作,也不会触发浏览器渲染。

此阶段仅执行 JS 计算,不会触发浏览器的布局 / 绘制,但会阻塞主线程 —— 如果耗时过长,浏览器无法响应用户输入(如点击、滚动)或更新动画,导致页面 “冻结”。

3. 提交阶段(Commit):应用差异到真实 DOM,触发浏览器渲染

协调阶段完成后,React 进入提交阶段,将协调阶段记录的差异应用到真实 DOM 上。

具体流程:

  • 遍历协调阶段记录的 “更新队列”,执行真实 DOM 操作(如创建、删除节点,修改属性、文本内容等)。
  • DOM 操作会立即触发浏览器的 “重排”(Reflow,计算元素位置和大小)和 “重绘”(Repaint,将像素绘制到屏幕)。
  • 同时,React 会执行组件的生命周期方法(如componentDidMountcomponentDidUpdate),这些方法也运行在主线程上。

注意:DOM 操作直接触发浏览器的渲染流水线(布局→绘制→合成),这部分工作由浏览器主线程完成,会与 React 的代码执行交替进行(但此时 React 的提交阶段已接近尾声)。

4. 完成渲染:主线程释放,浏览器恢复正常任务处理

当提交阶段完成后,React 的渲染任务结束,主线程被释放,浏览器才能继续处理队列中的其他任务(如用户输入、动画帧、定时器等)。

总的来说如下:

在 React 16 之前,当组件的状态或 props 发生变化时,React 会立即开始工作:

  1. 调用 Render:  重新渲染整个组件子树(生成新的虚拟 DOM)。
  2. 进行 Diff:  递归比较新旧两棵虚拟 DOM 树。
  3. 应用更新:  将计算出的差异(Patch)应用到真实 DOM。

这个过程是同步的,并且会一次性完成。如果组件树非常庞大,这个计算过程就会长时间占用 JavaScript 主线程。

栈协调模式的局限性(与浏览器交互的痛点)

由于栈协调是同步、不可中断的递归过程,它与浏览器的交互存在明显缺陷:

  1. 长时间阻塞主线程:复杂组件树的协调阶段可能耗时数百毫秒,期间浏览器无法响应任何用户操作,导致页面卡顿、输入延迟。
  2. 无法利用浏览器的空闲时间:浏览器在帧间隙(如两次重绘之间)有短暂空闲时间,但栈协调模式无法将任务拆分到这些空闲时间执行。
  3. 与动画 / 交互的冲突:如果渲染任务与动画帧(如requestAnimationFrame)在同一帧执行,会导致动画掉帧,视觉体验差。

这种模式的局限性,正是 React 团队引入 Fiber 架构的核心原因 —— 通过将渲染任务拆分为可中断的小单元(Fiber 工作单元),实现与浏览器的 “协作式调度”,避免主线程被长时间阻塞。


4. React的Fiber架构

下面正式开始介绍React Fiber 这个React16之后新推出来的 概念 它是facebook团队精心使用两年多去重构的React新架构!

首先:React 官方在Fiber Architecture相关文档中明确,Fiber 具有双重身份:

1.Fiber作为一种工作单元:

  • 作为 “工作单元” :Fiber 代表一个可拆分、可中断、可恢复的渲染任务(如组件的协调、diff、更新等)。

2.Fiber作为一种数据结构

  • 作为 “数据结构” :Fiber 是对组件的抽象表示,用于存储组件的类型、DOM 信息、任务优先级等数据,同时通过指针连接形成层级关系,支撑任务的拆分和调度。

同样你面试中表达也可以以用提供的两种方式进行表达,下面我也会从这两个方面去阐述Fiber


第一部分:Fiber 可以理解为是一个执行单元

上文提到了:浏览器的渲染过程是一个完整的过程,通过协调栈这种同步机制执行,这必定会带来一些问题,而React开发者在设计Fiber时就想到了:可以将渲染过程这个任务分成许许多多个小任务,这就是我们要说的 Fiber 可以作为一个执行单元

要搞懂 Fiber 作为 “执行单元” 的意义,咱们得先回头看 React 16 之前的问题:栈协调模式下,渲染是 “一竿子插到底” 的同步递归 —— 从根组件开始,一层一层往下遍历组件树、做 Diff,直到所有节点处理完,主线程才会释放。这就像你一次性要搬完一整箱书,中间累了也不能停,别人想让你帮忙递个东西都不行。

而 Fiber 的思路很直接:把 “搬一整箱书” 这个大任务,拆成 “搬一本、歇一下” 的小任务—— 每个小任务就是一个 Fiber 执行单元。每次处理完一个单元,React 就会抬头 “看看表”:浏览器现在还有时间处理下一个吗?如果有,就继续;如果没有(比如要处理用户点击、更新动画),就先把控制权还给浏览器,等浏览器空闲了再接着干。

这就是 “可中断渲染” 的核心,也是 Fiber 和浏览器协作的关键逻辑。

image.png

为什么要那么设计?

咱们开发中肯定遇到过这种场景:一个页面里有个复杂的表格,渲染 1000 行数据,点击 “刷新表格” 后,页面卡了 1 秒多,期间点击按钮、滚动页面都没反应。这就是栈协调模式的锅 ——1000 行数据的 Diff 是一个同步大任务,主线程被占满,浏览器没时间处理其他操作。

而 Fiber 的 “拆分 + 让出控制权” 设计,就能完美解决这个问题:

  • 1000 行数据的 Diff 被拆成 1000 个小任务,每个任务处理一行;
  • 处理完一行,就检查浏览器有没有紧急任务(比如用户刚点了 “筛选” 按钮);
  • 如果有,先处理筛选操作,再回头继续渲染表格;
  • 这样用户感觉不到卡顿,因为浏览器始终能响应他的操作,表格则 “悄悄” 在后台渲染。

requestIdleCallback

Fiber 能做到 “在浏览器空闲时执行任务”,最初的技术灵感来自浏览器的requestIdleCallback API—— 这个 API 的作用就是 “告诉浏览器:我有个不紧急的任务,你啥时候有空了就帮我执行下”。

咱们先看看它的工作原理:

  1. 浏览器每处理完一帧的渲染(包括布局、绘制等)后,会检查是否有剩余时间
  2. 如果有空闲时间,就会调用requestIdleCallback注册的回调函数
  3. 回调函数会收到一个deadline参数,包含timeRemaining()方法,用于查询当前帧剩余的空闲时间

React 利用这一机制实现了工作循环的调度: 举个例子

// 简化版工作循环
function workLoop(deadline) {
  // 有工作单元且当前还有剩余时间
  while (nextUnitOfWork && deadline.timeRemaining() > 0) {
    // 处理一个工作单元,并返回下一个工作单元
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
  // 如果还有未完成的工作,请求下一次空闲时间继续
  if (nextUnitOfWork) {
    requestIdleCallback(workLoop);
  }
}

// 启动工作循环
requestIdleCallback(workLoop);
举个例子1

task1、task2、task3,各任务的时间均小于16ms

let taskQueue = [
    () => {
      console.log('task1 start')
      console.log('task1 end')
    },
    () => {
      console.log('task2 start')
      console.log('task2 end')
    },
    () => {
      console.log('task3 start')
      console.log('task3 end')
    }
  ]
  
  const performUnitWork = () => {
    // 取出第一个队列中的第一个任务并执行
    taskQueue.shift()()
  }
  
  const workloop = (deadline) => {
    console.log(`此帧的剩余时间为: ${deadline.timeRemaining()}`)
    // 如果此帧剩余时间大于0或者已经到了定义的超时时间(上文定义了timeout时间为1000,到达时间时必须强制执行),且当时存在任务,则直接执行这个任务
    // 如果没有剩余时间,则应该放弃执行任务控制权,把执行权交还给浏览器
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskQueue.length > 0) {
      performUnitWork()
    }
  
    // 如果还有未完成的任务,继续调用requestIdleCallback申请下一个时间片
    if (taskQueue.length > 0) {
      window.requestIdleCallback(workloop, { timeout: 1000 })
    }
  }
  
  requestIdleCallback(workloop, { timeout: 1000 })
  

打印结果如下:

image.png

在这个示例中,我们定义了包含三个简单打印任务的队列taskQueue,并通过requestIdleCallbackAPI 在浏览器空闲时间执行这些任务。由于每个任务仅包含简单的打印操作,执行时间极短,浏览器计算出当前帧剩余 15.52ms 的空闲时间,足以一次性完成所有三个任务。因此,这三个任务会在同一帧的空闲时间内连续执行完毕。

举个例子2

在task1、task2、task3中加入睡眠时间,各自执行时间超过16ms:

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

let taskQueue = [
  () => {
    console.log('task1 start')
    sleep(20) // 已经超过一帧的时间(16.6ms),需要把控制权交给浏览器
    console.log('task1 end')
  },
  () => {
    console.log('task2 start')
    sleep(20) // 已经超过一帧的时间(16.6ms),需要把控制权交给浏览器
    console.log('task2 end')
  },
  () => {
    console.log('task3 start')
    sleep(20) // 已经超过一帧的时间(16.6ms),需要把控制权交给浏览器
    console.log('task3 end')
  }
]

打印结果如下:

image.png

此示例对任务做了修改,通过sleep(20)让每个任务的执行时间超过 16.6ms。从执行结果可以看到,浏览器第一帧的空闲时间为 14ms,仅能完成第一个任务。同样地,第二帧和第三帧的空闲时间也仅够各执行一个任务。因此,这三个任务会分别在三个不同的帧中依次完成。

注意:requestIdleCallback只是模拟:

不过需要说明的是,React 后来实现了自己的调度器(Scheduler)取代了原生requestIdleCallback,主要原因是

  • 原生 API 的浏览器兼容性和触发频率不够稳定
  • 原生 API 无法设置任务优先级,而 React 需要更精细的优先级控制

React Sechule

React 的调度系统(Scheduler 包)是 Fiber 并发模式的核心,它实现了一套完整的优先级管理机制,解决了 "谁先执行、何时执行" 的问题。

它实际上非常复杂,这里简单聊聊

Scheduler 的核心能力包括:

  1. 优先级分级:将任务分为多个优先级等级(如 Immediate、UserBlocking、Normal 等),与前面提到的优先级对应

  2. 任务队列管理

    • 为不同优先级维护不同的任务队列
    • 每次执行时先检查最高优先级队列是否有任务
    • 高优先级任务可以 "打断" 正在执行的低优先级任务
  3. 超时控制:每个任务都有过期时间,超过这个时间即使有更高优先级任务,也必须执行该任务,避免任务被永久阻塞

  4. 浏览器协作:结合setTimeoutrequestAnimationFrame等 API,模拟更可靠的 "空闲时间" 检测

当我们调用setState或其他触发更新的 API 时,React 会通过 Scheduler 将更新任务放入相应优先级的队列,并安排执行时机。高优先级任务(如用户输入)会被优先调度,甚至可以中断正在执行的低优先级任务(如列表渲染)。


第二部分:Fiber也可以理解为是一种数据结构

前面咱们聊了 Fiber 作为 “执行单元” 怎么拆分和调度任务,但这些任务的执行、遍历,离不开它背后的数据结构支撑 ——Fiber 节点组成的 “链表树”。

简单说:单个 Fiber 是链表上的一个节点,多个 Fiber 节点通过指针连接,形成一棵 “链表树”(本质是多叉树的链表实现) 。React 就是通过遍历这棵树,来处理每个节点的执行单元(比如 Diff、更新)。

先搞懂:为什么用链表,不用数组?

栈协调模式下,React 用的是 “递归遍历组件树”,本质是依赖函数调用栈(数组结构)。但递归的问题是 “不可中断”—— 一旦进入递归,就必须等到整个树遍历完才能退出。

而链表的优势在于 “可中断、可恢复”:

  • 每个节点都保存了 “下一个要遍历的节点” 的指针;

  • 遍历到一半暂停时,只要记住当前节点的指针,下次就能从这个节点继续遍历,不用重新从头开始。

这就是 Fiber 用链表结构的核心原因 —— 为 “可中断渲染” 提供数据结构层面的支持。

Fiber的节点设计

咱们先看一个简化版的 Fiber 节点结构(源码里的字段更多,但核心就是这些),每个字段都对应着具体的功能,咱们一个个解释:

const FiberNode = {
  // 1. 标识节点类型和身份
  type: any,          // 节点类型:类组件→构造函数,函数组件→函数本身,DOM元素→HTML标签(如'div')
  key: null | string, // 节点的唯一标识,用于Diff时快速定位相同节点(比如列表的key)
  tag: WorkTag,       // 节点的“工作类型”,比如FunctionComponent、ClassComponent、HostComponent(DOM元素)
  
  // 2. 关联真实DOM或组件实例
  stateNode: any,     // 保存与当前Fiber节点关联的“真实对象”:
                      // - DOM元素→真实DOM节点(如<div>对应的DOM对象)
                      // - 类组件→组件实例(this)
                      // - 根节点→ReactRoot对象
  
  // 3. 链表指针:构建节点间的层级关系(核心!)
  child: Fiber | null,  // 指向“第一个子节点”(比如Parent组件的第一个子组件)
  sibling: Fiber | null,// 指向“下一个兄弟节点”(比如Parent组件的第二个子组件,是第一个子组件的sibling)
  return: Fiber | null, // 指向“父节点”(比如子组件的return是Parent组件)
  
  // 4. 状态和Props相关:用于Diff和更新
  pendingProps: any,    // 新的Props(比如父组件传过来的新参数,还没应用到当前节点)
  memoizedProps: any,   // 上一次渲染时用的Props(旧Props),用于和pendingProps对比,判断是否需要更新
  memoizedState: any,   // 上一次渲染时的状态(比如类组件的this.state,函数组件的useState值)
  updateQueue: mixed,   // 状态更新队列:保存当前节点的待处理更新(比如setState触发的更新会放到这里)
  
  // 5. 副作用相关:用于提交阶段
  nextEffect: Fiber | null, // 指向下一个有“副作用”的节点(比如需要更新DOM、执行生命周期的节点)
  effectTag: SideEffectTag,  // 标记当前节点的“副作用类型”(比如更新DOM、删除DOM、执行回调)
};

咱们用一个简单的组件树,来看看这些指针是怎么工作的:

// 组件结构
function Parent() {
  return (
    <div>
      <Child1 />
      <Child2 />
    </div>
  );
}

对应的 Fiber 树结构:

  1. Parent Fiber

    • child → Div Fiber(第一个子节点是 div);
    • sibling → null(没有兄弟节点);
    • return → 根 Fiber 节点;
    • stateNode → Parent 组件的实例(如果是类组件)或 null(如果是函数组件)。
  2. Div Fiber(对应

    ):

    • child → Child1 Fiber(第一个子节点是 Child1);
    • sibling → null;
    • return → Parent Fiber;
    • stateNode → 真实的
      DOM 节点;
    • tag → HostComponent(因为是 DOM 元素)。
  3. Child1 Fiber

    • child → null(没有子节点);
    • sibling → Child2 Fiber(下一个兄弟是 Child2);
    • return → Div Fiber;
    • tag → FunctionComponent(如果是函数组件)。
  4. Child2 Fiber

    • child → null;

    • sibling → null;

    • return → Div Fiber;

    • tag → FunctionComponent。

这样的结构,React 怎么遍历呢?其实是 “深度优先遍历”:

  1. 从根 Fiber 开始,先找它的child(Parent Fiber);

  2. 再找 Parent Fiber 的child(Div Fiber);

  3. 再找 Div Fiber 的child(Child1 Fiber);

  4. Child1 Fiber 没有child,就找它的sibling(Child2 Fiber);

  5. Child2 Fiber 没有childsibling,就通过return回到 Div Fiber;

  6. Div Fiber 没有sibling,通过return回到 Parent Fiber;

  7. Parent Fiber 没有sibling,通过return回到根 Fiber,遍历结束。

这个遍历过程,就是 React 处理每个 Fiber 执行单元的过程 —— 遍历到哪个节点,就处理哪个节点的 Diff、更新任务,而且因为每个节点都有returnsibling指针,即使遍历到一半暂停,下次也能从当前节点继续,不用重新遍历整个树。

核心字段的实际作用:举个 Diff 的例子

咱们以memoizedPropspendingProps为例,看看 Fiber 节点的字段怎么配合工作:

  1. 当组件接收到新的 Props 时,React 会把新 Props 存在 Fiber 节点的pendingProps里;

  2. 处理这个 Fiber 执行单元时,会对比pendingProps(新)和memoizedProps(旧);

  3. 如果两者不一样,说明 Props 变了,需要重新计算组件的虚拟 DOM,做 Diff;

  4. 如果一样,说明 Props 没变化,可以复用之前的结果,跳过 Diff,提升性能;

  5. 处理完后,会把pendingProps赋值给memoizedProps,为下一次更新做准备。

再比如updateQueue:当你调用setState时,React 不会马上更新状态,而是创建一个 “更新对象”,放到当前组件 Fiber 节点的updateQueue里;等遍历到这个节点时,再从updateQueue里取出所有更新对象,计算出新的状态,更新到memoizedState里 —— 这就是 “批量更新” 的底层逻辑之一。

小结:Fiber 的双重身份如何配合工作?

看到这里,咱们可以把 Fiber 的两个身份串起来了:

  1. 执行单元负责 “拆任务、调度任务”:把渲染任务拆成小单元,通过 Scheduler 按优先级调度,在浏览器空闲时执行,避免阻塞主线程;

  2. 数据结构负责 “存任务、遍历任务”:通过 Fiber 节点的指针构建链表树,记录每个节点的 Props、状态、更新队列,支撑任务的中断和恢复(记住当前遍历的节点),以及 Diff、更新的执行。

简单说:Fiber 的执行单元是 “灵魂”,决定了任务怎么跑;数据结构是 “骨架”,决定了任务怎么存、怎么遍历。两者结合,才实现了 React 16 之后的 “可中断渲染” 和 “并发模式”,彻底解决了栈协调模式的性能痛点。

理解了这一点,再看 React 的性能优化(比如React.memouseMemo)、并发特性(比如useDeferredValue),就能明白它们本质上都是在 Fiber 架构的基础上做的优化 —— 要么减少执行单元的数量,要么调整任务的优先级,最终让 React 跑得更快、更流畅。

React的旧协调机制和Fiber对比

对比维度React 16 之前(协调栈)React 16+(Fiber 架构)
遍历方式递归(深度优先,调用栈)链表遍历(Fiber 节点链表,可中断)
执行模型同步阻塞(不可中断)异步可中断(时间切片,requestIdleCallback)
优先级调度无(所有任务平等)有(高优先级任务先执行,如用户输入)
主线程占用长任务阻塞短任务分片(每片 < 5ms),不阻塞

5. 现代化的React

既然这篇文章的标题是《React虚拟DOM、DIFF算法、FIBER机制的文章》,下面就简单介绍现代化 React 对 虚拟DOM、DIFF算法、FIBER机制的处理
在 React 16 重构架构至今,现代化 React 并未抛弃虚拟 DOM、DIFF 算法、FIBER 机制这三大核心,而是围绕 “性能优化” 和 “体验升级”,对三者做了深度整合与迭代,让底层逻辑更适配复杂应用的渲染需求。

1. 对虚拟 DOM:从 “单纯结构模拟” 到 “Fiber 数据载体”

虚拟 DOM 的核心价值 ——“用轻量 JS 对象映射真实 DOM,减少高频 DOM 操作” 始终未变,但现代化 React 让它与 Fiber 架构深度绑定,不再是孤立的 “结构描述”:

  • 如今的虚拟 DOM 信息(组件类型、props、子节点等),会直接嵌入 Fiber 节点中(比如 Fiber 节点的 type 对应组件类型,pendingProps/memoizedProps 对应新旧 props);
  • 不再单独维护 “虚拟 DOM 树”,而是以 Fiber 树为载体,虚拟 DOM 的生成、对比、更新全与 Fiber 任务同步,避免了额外的结构转换开销;
  • 配合并发渲染特性,虚拟 DOM 的更新可随 Fiber 任务 “暂停 / 恢复”—— 比如渲染到一半时优先处理用户输入,后续再回到未完成的虚拟 DOM 对比,不用从头重算。

2. 对 DIFF 算法:从 “递归全量对比” 到 “同级链表遍历”

DIFF 算法的 “同级对比、key 标识唯一性” 核心规则未变,但现代化 React 依托 Fiber 链表结构,优化了对比效率和可控性:

  • 抛弃旧版 “递归遍历虚拟 DOM 树” 的方式,改为 “遍历 Fiber 链表”—— 通过 Fiber 节点的 child/sibling/return 指针,按 “深度优先” 顺序逐个对比同级节点,时间复杂度稳定在 O (n);
  • 强化 key 的作用:在列表渲染等场景中,key 不仅是 “节点唯一性标识”,还会参与 Fiber 节点的复用判断 —— 若 key 匹配且 props 无变化,直接复用 Fiber 节点及对应的真实 DOM,跳过 Diff 计算;
  • Diff 过程与 Fiber 任务绑定,成为 “可中断的小单元”—— 对比完一个 Fiber 节点(比如一个列表项)就是一个任务,可根据浏览器空闲时间决定是否继续,避免 Diff 耗时过长阻塞主线程。

3. 对 FIBER 机制:从 “基础架构” 到 “并发能力基石”

FIBER 机制作为现代化 React 的 “底层骨架”,在原有 “任务拆分、可中断” 的基础上,进一步强化了调度能力和功能适配:

  • 调度更智能:自研的 Scheduler 调度器新增 “优先级分级”(如用户输入为高优,列表渲染为低优),Fiber 任务会按优先级排序执行 —— 高优任务可中断低优任务,执行完后再恢复低优任务,解决 “用户操作卡顿” 问题;

  • 支持双缓存 Fiber 树:维护 “current 树”(当前显示的真实 DOM 对应的 Fiber 树)和 “workInProgress 树”(正在渲染的 Fiber 树),更新时只在 workInProgress 树上计算,避免影响当前 UI,渲染完成后再替换 current 树,实现 “无感知切换”;

  • 适配新特性:成为并发渲染(如 startTransition)、自动批处理、Suspense 等新特性的底层支撑 —— 比如 startTransition 标记的非紧急更新,就是通过降低对应 Fiber 任务的优先级,实现 “不阻塞紧急操作” 的效果。

简言之,现代化 React 对这三者的处理,核心是 “整合与联动”:以 Fiber 机制为骨架,让虚拟 DOM 成为 Fiber 节点的数据载体,让 DIFF 算法成为 Fiber 任务的执行环节,最终实现 “高效、可控、流畅” 的渲染体验。

一些常见的Q&A

介绍了那么多,下面给几个面试中常见的Q&A吧,由于篇幅原因,就不展开答案的详细解析了,大家可以自行AI工具辅助理解详细,这里只是简单抛砖引玉

  1. 列表渲染时加的 key,到底是给 React 的哪个流程用的?如果用 index 当 key,为什么会出现 “数据和 UI 不匹配” 的 bug?

  2. Fiber 节点里的 “memoizedProps” 和 “pendingProps” 有什么区别?什么时候会更新这两个属性?

  3. 虚拟 DOM 的 “.patch(补丁)” 是怎么生成的?它和 DIFF 算法、Fiber 架构的关系是什么?

  4. 为什么 Fiber 架构能解决 “栈溢出” 问题?旧版 React(15 及之前)的 “栈协调” 为什么会导致栈溢出?

  5. React 18 的 “并发渲染(Concurrent Rendering)” 和之前说的 “Concurrent Mode” 有什么区别?它依赖 Fiber 架构的哪些特性?

  6. 当多个组件同时触发更新时,Fiber 的 Scheduler 会怎么调度?高优先级任务会 “打断” 低优先级任务的哪个阶段?


文章末尾:参考文献

这篇文章能够完成,是因为站在了巨人的肩膀上,下面给出这篇文章的 思路来源与参考,感谢这些大佬:

总结

就先讲到这吧,后续会不断更新,随着作者进一步学习理解,也会让这篇文章越来越精进,专业。

有些地方写的有些瑕疵,大佬们莫见怪

2025.9.11 凌晨2.27 文章Demo完成

2025.9.11 中午11.08 修改部分内容