Fiber架构、state与渲染

Fiber架构、state与渲染

1.前言

在使用React的组件的过程中,我们发现,有时候我们需要去维护一些只隶属于组件本身的数据。比如说一个相册组件,我们需要记录当前翻到了第几页,从而能够在"点击下一页"这个动作发生后,重新渲染的过程中,通过当前的页数推断下一步的渲染结果,是翻到最后一页了不再重新渲染新图片,还是渲染下一张图片。而这个页数就是我们需要记录的。

在一般的思维下,我们可能会这样实现:

ts 复制代码
import { images } from './assets';

export default function Gallery() {
    let index = 0;

    function nextPage() : void {
        index = Math.min(index + 1, images.length - 1);
    }

    return (
        <>
            <button onClick={ nextPage }>下一页</button>
            <img src={images[index]} alt={ `第${index}张图片` } />
        </>
    );
}

但是当我们实际去运行代码的时候,就可以发现点击按钮之后并没有触发重新渲染,这是为什么?

原因有二:

  • 缺乏触发机制:在React中,局部变量的改变不会告诉React需要更新UI,所以你狂点下一页什么都不会发生,虽然实际上在这个组件内部,index的值确实在不断增长,但无法触发重新渲染,所以也就体现不出效果。
  • 缺乏持久化能力 :根据我们想要实现的效果,假设我们通过某种手段让Gallery组件触发了重新渲染,在React中,它将会再次调用Gallery()这个组件函数,而结果就是在一开头的let index = 0;处,使得index重新被置为0,则更改也未能生效。

所以显然使用这种方式来存储组件内部状态是不可行的,那么这就是我们今天要讲的,React提供的state方法很好解决了这个问题。


2.Fiber架构

在介绍state之前,我们需要先简要讲一下Fiber架构。

2.1.为什么要有Fiber架构?

这里是一些历史问题,稍微提一下。

大概来说,就是在React 15以及更早之前,React使用的是Stack Reconciler(栈协调器)

我们不细说它的工作方式,只需要知道:它一旦开始,就必须一口气跑完

毫无疑问,任何人都可以看出,在无比复杂的浏览器环境中,谁知道在你渲染的时候用户会不会又去点什么按钮?然后用户看到没反应,急性子就直接把网页关掉了。

  • 痛点:如果组件树太深,JavaScript引擎就会长时间地霸占主线程,然后你知道的。
  • 后果:由于主线程被React占用,浏览器无法处理用户点击、动画刷新,在用户看来,网页就像死机了一样。

2.2.Fiber是什么?

Fiber本质上是React的一种工作单元(Unit of Work)

具体来说,你可以把它理解成一种虚拟DOM节点 ,什么叫虚拟DOM节点?区别于真正的DOM节点,它是一种JavaScript对象,使用的目的是为了减少对真实DOM节点的直接操作,聚焦于真正需求的核心属性,从而降低性能压力。

而Fiber相对于普通的虚拟DOM节点,又多了一个比较牛逼的功能:它可以被打断,也可以被恢复。这也就是说明,在它内部存储了当前的工作状态,它是具有"记忆性的"。

Fiber架构就是对于每个Fiber节点,通过维护它的childslibingreturn三个指针,所组成的单向链表树。

  • child:指向第一个子节点。
  • slibing:指向下一个兄弟节点。
  • return:指向父节点。

这种链表结构,也就是Fiber Tree,取代了之前的梳妆结构,让React可以像遍历链表一样遍历Fiber节点,随时停下来去干别的事,并且可以再回来找"进度条"。


2.3.Fiber的核心:时间分片(Time Slicing)

Fiber架构将渲染过程分成了两个阶段:

2.3.1.第一阶段:Reconciliation(协调阶段 / Render阶段)
  • 特点异步、可中断
  • 工作内容 :React将会在这个阶段对比新旧内容,找出哪些地方需要更新。这里使用到了Diff算法
  • 中断:如果此时用户点击了某个按钮或者执行了什么需要立即处理的操作,React会直接丢下当前的工作,去处理用户交互。等待主线程闲置,再回来处理没完成的工作(或者推倒重来)。
2.3.2.第二阶段:Commit(提交阶段)
  • 特点同步、不可中断
  • 工作内容:当前面第一阶段将所有需要更改的地方都计算完毕时,就会一次性将这些变换应用到真实的DOM上。

2.4.双缓存技术(Double Buffering)

为了提升树的构建与替换速度,React采用了双缓存技术。具体来说,在React内部,维护了两棵Fiber树:

  • current tree:当前屏幕上正在显示的树
  • workInProgress tree:正在内存中构建的树

workInProgress tree 构建完成并且commit到DOM后,React只需要简单地交换一下指针,新的树就变成了 current tree


3.state

3.1.介绍

我们上面提到,由于index是活在组件函数里面的,每次组件函数被调用之后都会重置它的状态,且在实际情况中,我们显然会多次地在重新渲染网页的过程中调用组件函数。

而在这里我们用到的useState就是一种Hook,即钩子函数。什么叫Hook?具体来说,它是在React 16.8引入的一套函数,能够在不写class的情况下,去"勾住"组件的状态(state)和生命周期(lifecycle)

那么我们可以通过什么方法存储数据,从而让组件函数被重新调用后,数据保持不变呢?既然放在组件内部不行,那我们就把持久化存储。如何持久化存储呢?这就用到了我们前面所提到的Fiber节点。

我们先来看看state的用法:

ts 复制代码
import { useState } from 'react';
import { images } from './assets';

export default function Gallery() {
    const [index, setIndex] = useState(0);

    function nextPage() : void {
        setIndex(Math.min(index + 1, images.length - 1));
    }

    return (
        <>
            <button onClick={ nextPage }>下一页</button>
            <img src={ images[index] } alt={ `第${index}张图片` } />
        </>
    )
}

在这里,[index, setIndex]中,index就是我们维护的状态变量,setIndex就是setter函数,useState(0)代表将index初始化为0

当我们调用setIndex(newValue)时,就会发出一个在下一轮渲染时将index的值修改为newValue的异步请求。

我们前面提到,useState是一种Hook,它用于存储组件的状态。那这些Hook又放在哪呢?这就又需要引入我们说过的Fiber节点。

1.Hook住在Fiber里

我们提到Fiber是一个JavaScript对象,它具有记忆性。

当我们初次调用一个状态变量的useState()时,React并不是在函数内部开辟空间,而是找到当前正在执行的那个Fiber节点

对于每个Fiber节点,都有一个属性叫做memorizedState,这个属性不是存一个值,而是存一个链表。

2.第一次渲染:Mount阶段

  • 工作内容 :第一次执行Gallery()时,useState(0)会在对应Fiber节点的memorizedState属性维护的链表上挂下第一个节点(Hook对象)
  • 存储内容 :这个Hook对象里存着baseState: 0和一个空的queue(更新队列)

3.setIndex()执行时

  • 动作 :假设当前的index值为0,则此时我们执行setIndex(1)
  • 发生了什么 :需要注意的是,它并不像我们所预想的那样,立即改变memorizedState链表中对应的index值,而是往这个Hook对象中,我们前面提到的queue里塞入了一个更新任务。
  • 触发 :随后React调度一次新的渲染,告诉浏览器应当开始构建workInProgress tree

4.Update阶段的对号入座

当我们重新进行渲染,执行到Gallery()组件函数,即第二次调用时,React再次运行到const [index, setIndex] = useState(0)时,不会再创建一个新的状态并挂载到链表中,而是去current tree对应的Fiber节点里面寻找到那个Hook链表。

此时再按照顺序找到第一个Hook,把queue里面的更新计算出来,返回新的值给index


我们大概整理一下:每个React组件对应一个Fiber节点,同时每个React组件又有很多状态(Hook),于是Fiber节点在memoizedState属性中维护一个Hook对象链表,将每个Hook依次挂载到上面,维护状态值,以及一个queue代表更新队列。

下面我们来详细拆解一下逻辑。


3.2.queue

3.2.1.介绍

在Fiber节点的memoizedState中,每个Hook对象都有自己的queue

js 复制代码
const queue = {
    pending: null,      // 指向最后一个处理的 update 对象
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderdReducer: reducer,
    lastRenderedState: (initialState)
};

3.2.2.结构

对于这个queue,它并不是简单的数组,而是一个环形链表。

当你调用setState()时,React会创建一个update对象并把它塞进queue.pending

在这里,queue.pending指向的永远是最后加入的那个更新,而queue.pending.next指向的就是第一个更新,这是由它的环形结构决定的。

为什么要这样做呢?这样的话,React只需要维护一个pending指针,就能够同时快速访问到链表的头和尾。而Fiber是内存敏感的,采用这个设计使得空间得到了优化。

并且当queue.pending指向最后一个update时,queue.pending.next就是第一个update,这意味着在进行并发更新时,不需要遍历整个链表就能直接完成插入操作。


3.2.3.工作流程
3.2.3.1.阶段一:入队(Dispatch Phase)

假设用户点了三次按钮,发起了三次setState()请求。

对于第一次,由于queue内部为空,将pending指向update1,同时自己连自己。

对于后续更新,则有:

  • update2.next = update1
  • update1.next = update2
  • queue.pending = update2
  • update3.next = update2
  • update2.next = update3
  • queue.pending = update3
3.2.3.2.阶段二:渲染(Render Phase)

当React开始处理这个Hook的更新时:

1.它首先会拿到queue.pending

2.根据queue.pending.next拿到链表头

3.将环形链表剪断,变成普通的单向线性链表

4.从头开始遍历,根据baseState以及每一步的计算逻辑依次计算每一个update后的值

5.处理优先级(Lanes) :如果某个更新的优先级不够,React将会跳过它,但是把剩下的更新保存在baseQueue中,等到下次渲染再进行计算。


4.调用setState()之后

4.1.update挂载到两棵树的queue

我们知道,React内部维护了两棵Fiber树,一棵是当前在网页上显示出来的,即为current tree,另一棵是正在内存中构建的workInProgress tree(WIP)

当一个组件调用setState()函数,创建Update对象,它会将这个对象发往哪棵树上的queue呢?答案是两棵树都会发送。

假如说我们只挂在current,那正在构建的WIP可能已经处理完这个Hook了新状态就漏掉了。

如果只挂在WIP,我们知道Fiber架构显著的特点就是可中断性,假如说这次渲染因为其他高优先级任务被中断,而且WIP被废弃了,那么下次渲染时,React还是得要从current克隆,没挂在current上面的更新就丢掉了。

所以为了确保这次更新能够及时且安全,我们选择在两棵树上都挂上Update


4.2.指针交换与旧树复用

WIP树在内存中完全构建好(进入Commit阶段)时,React执行root.current = workInProgress;

需要注意的是,交换之后,原来的旧current并没有被销毁,而是作为草稿。

等待下一次更新到来,需要再次构建WIP树时,在这个废弃的旧树的基础上,与current进行对比,进行按需覆盖。

具体如何覆盖?首先我们需要知道一个核心机制:Alternate(替身)

在Fiber架构中,每个Fiber节点都有一个alternate属性

  • current.alternate指向WIP节点
  • WIP.alternate指向current节点

WIP的构建中,如果一个节点发生了变化,React会先检查current.alternate是否已经存在一个旧的WIP节点(也就是上次渲染留下的)

如果有的话,直接把那个旧对象的属性覆盖掉,完成了复用。

如果没有,才去new一个新的Fiber对象。


4.3.WIP树的构建

  • 从Fiber Root开始,逐渐向下。
  • 对每个Fiber节点,先看current树中的节点,看props更新了吗?context变了吗?queue里面有更新吗?如果没有变化,直接复用原来的;否则创建新的Fiber子节点
  • 如果是新生成的Fiber,React将会给它打上Placement(移动)、Update(更新)之类的EffectTag
  • 到达底部之后开始向上回溯,把子节点的补丁一层层向上汇总到根节点。

4.4.beginWork

所谓beginWork,就是一个函数,它接收一个Fiber节点,判断它是否需要更新,以及如果需要,如何生成它的子节点。

它在WIP树上工作,对于每个WIP树上的Fiber节点,它对比current树上对应的节点,决定这个WIP节点应该长什么样。

4.4.1.复用还是更新

在执行具体逻辑前,beginWork将会先对比currentWIP对应节点的属性。

  • Bailout(复用 / 跳过)

    如果propscontext没变,且节点本身没有需要处理的update,React将会调用bailoutOnAlreadyFinishWork,意思就是不执行组件函数,直接克隆旧的子Fiber

  • 进行更新

    如果上述条件不满足,就会进入具体更新逻辑。

  • 具体步骤

    我们在这里以上面的Gallery()组件函数为例,看看它是怎么进行更新的:

    • 执行函数 :首先调用Gallery()
    • 计算状态 :这一步就和前面的queue有紧密的关系,通过更新队列来计算最新的index
    • 返回结果:函数执行完之后,通过新的状态返回新的JSX代码
  • 协调子节点

    此时,我们知道beginWork拿到了新的JSX,那么就会和当前的current树去做一个对比(Diff)

    • 如果是新来的 :创建一个新的Fiber节点,挂到WIP树上,打一个Placement标签
    • 如果是删掉的 :在WIP树中不管了,但是在对应的旧节点上打一个Deletion标记
    • 如果是位置换了 :把旧节点拿回来复用,改个指针,打个Placement标签

4.4.2.分发逻辑:身份识别

我们在4.4.1中所提到了逻辑其实只是beginWork函数针对不同Fiber节点进行不同处理逻辑中的一种。

beginWork将会根据Fiber节点的tag类型不同走不同的分支:

  • FunctionComponent:执行组件函数,处理Hook逻辑
  • HostComponent:处理原生DOM标签
  • HostText:处理文本节点
  • Fragment / ContextProvider:处理对应的特殊组件逻辑

4.4.3.beginWork何时开始?

在React中,从FiberRoot开始执行beginWork的过程,叫做进入Render阶段,它会由以下的几种情况触发:

1.初始渲染(Mount)

当第一次调用root.render(<Gallery />)时,React将会创建FiberRoot

此时React发现这是一片空白,于是从头开始构建。立即调度一个任务,从FiberRoot开始,向下执行beginWork

2.状态更新(Update)

当我们调用setIndex()时,React会创建一个Update对象,把它挂载到对应的Fiber节点的queue

然后从这个Fiber节点开始,向上溯源,直到寻找到FiberRoot,沿途对路径上的所有Fiber节点都打上"有子节点需要更新"的信号(Lanes优先级)

FiberRoot收到信号之后,向浏览器请求一个空闲时间。

等到浏览器有空闲时间时,React将会从FiberRoot开始,依次向下执行beginWork。其中如果某个Fiber节点没有被打上更新标记,浏览器会跳过对应的整个子树。


4.5.completeWork

如果说beginWork是在根据current树的状态去调度发布任务的过程,那么completeWork就是完成这些任务,最终构建出WIP树的关键步骤。

它主要负责两件事:

1.创建或更新DOM节点(针对原生标签)

2.收集副作用(Flags / EffectTag):告诉后面的Commit阶段,哪些节点要增删改。

它什么时候触发?就在beginWork返回null,即代表已经遍历完成之后,开始自底向上回溯。


4.6.Commit阶段

在前面的Render阶段,虽然看上去轰轰烈烈,但实际上对你屏幕上显示什么并没有影响。因为它实际上只是在计算Diff,而Commit阶段才是真正意义上地去操纵真实DOM。而它是同步执行的,无法被中断。

它具有三个主要的子阶段:

  • Before Mutation(渲染前)

    React将会在这里遍历Fiber树,看看有没有类组件实现了getSnapshotBeforeUpdate

    • 核心任务 :触发相关生命周期,调度useEffect
  • Mutation(渲染中)

    在这个阶段,React将会根据Fiber节点的flags执行对应的DOM操作(插入、更新、删除)

    如果是Deletion,还会递归地清理子节点的Ref和卸载相关的生命周期

  • Layout(渲染后)

    此时DOM已经更新完毕,但还没有绘制屏幕,React开始处理布局

    它会执行useLayoutEffect的回调函数,并更新类组件的componentDidMount或者componentDidUpdate。同时将真实的DOM节点挂载到ref.current


4.7.总结

1.触发setState()发起更新请求

2.调度Scheduler算出谁的优先级比较高

3.渲染阶段(Render Phase)

  • beginWork:向下分叉,找差异
  • completeWork:向上收束,建DOM

4.提交阶段(Commit Phase) :一次性把内存里的WIP树映射到真实世界

相关推荐
南村群童欺我老无力.1 小时前
从0到1的项目架构经验总结——HarmonyOS PC开发避坑完全指南
华为·架构·harmonyos
星辰_mya2 小时前
领域驱动设计(DDD)“老中医”治理订单
java·后端·面试·架构
Cosolar2 小时前
大模型量化技术全景深度解析:从FP16到INT4的完整演进与实战落地
人工智能·面试·架构
亚空间仓鼠2 小时前
Docker容器化高可用架构部署方案
docker·容器·架构
2301_780789662 小时前
DDOS防护的常见误区与解决方案
网络·安全·web安全·架构·ddos
Vol火山3 小时前
灵魂的躯体:论企业架构(EA)与 Palantir 本体论在 AI 时代的深度融合
人工智能·架构
AI棒棒牛3 小时前
YOLO26:面向实时目标检测的核心架构改进与性能基准测试
人工智能·深度学习·yolo·目标检测·架构
步步为营DotNet3 小时前
NET 11 中 C# 14 新特性在云原生微服务架构的深度实践
云原生·架构·c#
MATLAB代码顾问3 小时前
【状态空间模型】Mamba:新一代高效序列建模架构
架构