前端小魔女

V1

2022/06/19阅读:21主题:蔷薇紫

React_Fiber机制

人生乃是一场狂欢,快乐才是生活的真正目标,这背后的哲学就是,活在当下,热爱生活。

大家好,我是柒八九

今天,又双叒叕 yòu shuāng ruò zhuó开辟了一个新的领域--前端框架

这是继

  1. JS基础&原理
  2. JS算法
  3. 前端工程化
  4. 浏览器知识体系
  5. Css
  6. 网络通信

这些模块,又新增的知识体系。说起前端框架,大家肯定第一时间会联想到Vue/React,其实前端框架范围很广,它不应该被局限在Vue/React等主流库,还有很多在某些领域大放异彩的库和框架。例如

  • Lit
    • Google出的针对于WebComponent开发框架
  • Svelte
    • RollupJs 的作者编写的编译型框架
    • 不再依赖Vritual-DOM进行页面构建

所以,我们在这个系列中,不仅仅会讲大家在工作中接触比较多的框架Vue/React,还有带着大家一起去探索前端其他领域比较新奇,并在后续工作中有用武之地的技术方案。


而,今天我们先简单描述一下React-Fiber的实现原理。

天不早了,我们干点正事哇。


这里给大家贴一个很早之前,画的关于Fiber的结构图。 (如果,看不清,可私聊,索要原图)

文章概要

  1. 背景介绍
  2. React 元素 React ElementFiber 节点 Fiber Node
  3. 副作用 Side-effects
  4. Fiber 树的根节点
  5. 渲染算法

前言

React 是一个用于构建用户界面JavaScript 库。

它的核心跟踪组件状态的变化并将更新的状态投射到屏幕上。

React 中,我们把这个过程称为调和 Reconciliation。我们调用 setState 方法,框架会检查状态state属性props是否发生了变化,并在用户界面上重新显示一个组件。

React的文档对该机制提供了一个很好的概述:React元素的作用,生命周期方法和渲染方法,以及应用于组件子代的 diffing 算法。从渲染方法返回的不可变immutableReact元素树通常被称为虚拟DOM Virtual DOM 。这个术语有助于在早期向人们解释React,但它也造成了混乱,在React文档中已不再使用。在这篇文章中,我将坚持称它为React元素树 Tree of React elements

除了React元素树,该框架有一棵内部实例树(组件、DOM节点等),用来保持状态

16版开始,React推出了一个新的内部实例树的实现,以及管理它的算法,代号为Fiber

请注意,这篇文章是关于 React 内部如何工作的,可能下面讲的东西,不会在实际工作中产生任何帮助。

正如亚里士多德把知识分为三类

  • 第一类是经验,会做但不知道为什么这么做是对的;
  • 第二类是知其然又知其所以然的技术,它来源于经验,是通过对经验的总结和归纳所形成的一般化理论;
  • 第三类是没有用的、自己为自己而存在的知识,就是科学

而我们平时在开发过程中,能够熟练使用React来构建UI,这是一种经验,而我们却很少对React内部实现原理深入了解,说明我们还未达到对React这个技术的更深层次的掌握。只有,对技术细节有一定的了解,才可以在后续的技术升级或者技术改造中游刃有余。更甚者,能够自己撸一个低级版的 UI 库。


1. 背景介绍

这里有一个简单的应用程序,该例子将贯穿整篇文章。我们有一个按钮,可以简单地增加屏幕上显示的数字。

对应的代码如下:(这里我们用Component实现)

class ClickCounter extends React.Component {
    constructor(props) {
        super(props);
        this.state = {count0};
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        this.setState((state) => {
            return {count: state.count + 1};
        });
    }


    render() {
        return [
            <button key="1" onClick={this.handleClick}>
              更新数字
            </button>
,
            <span key="2">
              {this.state.count}
            </span>
        ]
    }
}

这是一个简单的组件,从渲染方法(render)中返回两个子元素 buttonspan。一旦你点击了按钮,组件的状态就会在处理程序中被更新。这反过来又会导致 span 元素的文本更新。

调和过程中,React 会执行各种操作。例如,以下是 React 在我们构建的应用中,在第一次渲染状态更新后所执行的操作。

  • 更新 ClickCounter 的状态中的 count 属性
  • 检索和比较 ClickCounter 的子元素和它们的props
  • 更新 span 元素的props

调和过程中还有其他操作,如调用生命周期方法或更新ref所有这些操作在 Fiber 架构中都被统称为 工作 Work工作的类型通常取决于React元素的类型。例如,对于一个类组件React 需要创建一个实例,而对于一个函数组件,它不需要这样做。

如你所知,我们在 React 中有许多种类的元素。

  • 类组件(React.Component)
  • 函数组件
  • 宿主组件(DOM节点)
  • Portals (将子节点渲染成存在于父组件的DOM层次之外的DOM节点)

React 元素的类型是由 createElement 函数的第一个参数定义的。这个函数一般在render方法中使用,用于创建一个元素。而在React开发中,我们一般都使用JSX语法来定义元素(而JSXcreateElement的语法糖),JSX 标签的第一部分决定了React元素的类型。例如,

  • 以大写字母开头表示JSX标签是指一个React组件 <ClickCounter>
  • 以小写字母开头表示宿主组件或者自定义组件 <button>/<p-test>

关于JSX,可以参考官网的,它有详细的解释。

在我们开始探索Fiber算法之前,首先让我们熟悉一下React内部使用的数据结构


2. 从 React 元素 React ElementFiber 节点 Fiber Node

React 中的每个组件都是一个UI表示

这里是我们的 ClickCounter 组件的模板。

<button key="1" onClick={this.onClick}>
  更新数字
</button>
<span key="2">
  {this.state.count}
</
span>

React 元素 React Element

一旦模板通过JSX编译器 JSX compiler,你最终会得到一堆React元素这就是真正从 React 组件的渲染方法中返回的东西,而不是HTML

如果不需要使用 JSX语法,ClickCounter 组件的渲染方法可以重写如下方式。

class ClickCounter {
    ...
    render() {
        return [
            React.createElement(
                'button',
                {
                    key'1',
                    onClickthis.onClick
                },
                '更新数字'
            ),
            React.createElement(
                'span',
                {
                    key'2'
                },
                this.state.count
            )
        ]
    }
}

render方法中对 React.createElement的调用将创建这样的两个数据结构

[
    {
        $$typeofSymbol(react.element),
        type'button',
        key"1",
        props: {
            children'更新数字',
            onClick() => { ... }
        }
    },
    {
        $$typeofSymbol(react.element),
        type'span',
        key"2",
        props: {
            children0
        }
    }
]

你可以看到 React 给这些对象添加了$$typeof属性,可以标识它们是React元素。然后还有描述元素的属性 typekeyprops,这些值取自你传递给React.createElement函数的内容。

ClickCounterReact元素没有任何propskey

{
    $$typeofSymbol(react.element),
    keynull,
    props: {},
    refnull,
    type: ClickCounter
}

Fiber 节点 Fiber Node

调和过程中,从render方法返回的每个React元素的数据都被合并到Fiber节点的树中。

与React元素不同,fiber不会在每次渲染时重新创建。这些是可变的数据结构 mutable data structures,持有组件状态和 DOM信息

我们之前介绍过,根据React元素的类型,React需要执行不同的操作。在我们的示例应用程序中

  • 对于类组件ClickCounter,它调用生命周期方法和渲染方法
  • 而对于span宿主组件(DOM节点),它执行DOM变异。

因此,每个React元素都被转换为相应类型的Fiber节点,描述需要完成的工作。

可以把fiber看作是一个数据结构,它代表了一些要做的工作,或者说,一个工作单位

Fiber的架构还提供了一种方便的方式来跟踪、安排、暂停和中止工作。

当一个React元素第一次被转换成一个Fiber节点时,React 使用该元素的数据在 createFiberFromTypeAndProps 函数中创建一个fiber。在随后的更新中,React重用Fiber节点,只是使用来自相应 React元素 的数据更新必要的属性。如果相应的React元素不再从渲染方法中返回,React可能还需要根据关键props在层次结构中移动节点或删除它。

因为React为每个React元素创建了一个fiber节点,由于我们有一个由元素组成的element 树,所以我们也将有一个由fiber节点组成的fiber树。在我们的示例应用程序中,它看起来像这样。

所有的Fiber节点都是通过childsiblingreturn属性构建成链表连接起来的。


Current Tree 和 workInProgress Tree

在第一次渲染之后,React 最终会有一个 Fiber 树,它反映了用来渲染 UI 的应用程序的状态。这个树通常被称为当前树 Current Tree

当React开始状态更新时,它建立了一个所谓的workInProgress 树 workInProgress Tree,反映了未来将被刷新到屏幕上的状态。

所有的工作都在workInProgress树fiber 上进行。当React穿过current树时,对于每个现有的fiber节点,它创建一个备用节点,构成 workInProgress树。这个节点是使用render方法返回的React元素的数据创建的。一旦更新处理完毕,所有相关的工作都完成了,React 就会有一个备用的树,准备刷新到屏幕上。一旦这个workInProgress树被渲染到屏幕上,它就成为current

React 的核心原则之一是一致性React 总是一次性地更新DOM--它不会显示部分结果workInProgress树作为一个用户不可见的草稿draft,这样 React 可以先处理所有的组件,然后将它们的变化刷新到屏幕上

在源代码中,你会看到很多函数从current树WorkInProgress树中获取fiber节点。下面是一个这样的函数的签名。

function updateHostComponent(
            current, 
            workInProgress, 
            
{...}

每个fiber节点通过alternate属性保存着对另一棵树上的对应节点的引用。
current树的一个节点指向workInProgress树的节点,反之亦然。


3. 副作用 Side-effects

可以把React中的组件看作是一个使用state和props来计算UI表现的函数

每一个操作,如DOM的突变调用生命周期方法,都应该被视为一个副作用,或者简单地说,是一个效果effect

从React组件中执行过数据获取事件订阅手动改变DOM。我们称这些操作为 "副作用"(或简称 "效果"),因为它们会影响其他组件,而且不能在渲染过程中进行。

你可以看到大多数stateprops的更新都会导致副作用的产生。由于应用效果是一种工作类型fiber节点是一种方便的机制,除了更新之外,还可以跟踪效果每个fiber节点都可以有与之相关的效果。它们被编码在 effectTag 字段中

所以Fiber中的效果基本上定义了更新处理后需要对实例进行的工作

  • 对于宿主组件(DOM元素),工作包括添加更新删除元素。
  • 对于类组件,React 可能需要更新Refs并调用 componentDidMountcomponentDidUpdate 生命周期方法。

效果清单 Effects list

React处理更新的速度非常快,为了达到这种性能水平,它采用了一些有趣的技术。其中之一是建立一个带有效果的fiber节点的线性列表,以便快速迭代。迭代线性列表要比树形快得多,而且不需要在没有副作用的节点上花费时间。

这个列表的目的是标记有DOM更新或其他与之相关的效果的节点。这个列表是 workInProgress 树的一个子集,并且使用 nextEffect 属性链接,而不是currentworkInProgress 树中使用的 child 属性。

Dan Abramov对效果清单做了一个比喻。React 应用想象成一棵圣诞树,用 "圣诞灯 "把所有有效果的节点绑在一起。为了形象化这一点,让我们想象有下面的fiber节点树,并且做一些操作,c2插入到DOM中,d2c1改变了属性b2 触发了一个生命周期方法。效果列表将它们联系在一起,这样React就可以在以后跳过其他节点。

从上图中可以看到带有效果的节点是如何连接在一起的。当访问这些节点时,React 使用 firstEffect 指针来计算列表的开始位置,用 nextEffect将拥有效果的节点连接起来。 所以上图可以表示为这样的一个线性列表。


4. Fiber 树的根节点

每个React应用程序都有一个或多个DOM元素,作为容器。

在我们的例子中,它是ID为容器的div元素。

const domContainer = document.querySelector('#container');
ReactDOM.render(
            React.createElement(ClickCounter), 
            domContainer
        );

React为每个容器创建一个fiber-root对象。你可以使用DOM元素的引用来访问它。

const fiberRoot = query('#container')
                  ._reactRootContainer
                  ._internalRoot

这个fiber-root是React保存对fiber树的引用的地方。它被存储在fiber-rootcurrent属性中。

const hostRootFiberNode = fiberRoot.current

fiber树以一种特殊类型的fiber节点开始,它就是 HostRoot。它是在内部创建的,作为最上面的组件的父节点。通过 stateNode 属性,可以从 HostRoot fiber节点访问到 FiberRoot

fiberRoot.current.stateNode === fiberRoot; // true

你可以通过fiberRoot访问最上面的 HostRoot fiber节点来访问fiber树

你可以从一个组件实例中获得一个单独的fiber节点

compInstance._reactInternalFiber

Fiber-Node的数据结构

现在让我们来看看为 ClickCounter 组件创建的fiber节点的结构。

{
    stateNodenew ClickCounter,
    type: ClickCounter,
    alternatenull,
    keynull,
    updateQueuenull,
    memoizedState: {count0},
    pendingProps: {},
    memoizedProps: {},
    tag1,
    effectTag0,
    nextEffectnull
}

span DOM 元素的fiber节点的结构。

{
    stateNodenew HTMLSpanElement,
    type"span",
    alternatenull,
    key"2",
    updateQueuenull,
    memoizedStatenull,
    pendingProps: {children0},
    memoizedProps: {children0},
    tag5,
    effectTag0,
    nextEffectnull
}

stateNode

保存对与fiber节点相关的组件、DOM节点或其他React元素类型的类实例的引用

这个属性是用来保存与 fiber 相关的本地状态

type

定义了与该fiber相关的函数或类

  • 对于类组件,它指向构造函数
  • 对于DOM元素,它指定了HTML标签

使用这个字段来了解一个fiber节点与什么元素有关

tag

定义了fiber的类型
它在调和算法中被用来确定需要做什么工作。

如前所述,工作根据React元素的类型而不同。 函数 createFiberFromTypeAndProps 将一个React元素映射到相应的fiber节点类型。

在上面的实例中,ClickCounter 组件的属性标签是 1,表示 ClassComponent,对于 span 元素,它是 5,表示 HostComponent

updateQueue

状态更新、回调和DOM更新的队列

memoizedState

用于创建输出的fiber的state

当处理更新时,它反映了当前屏幕上呈现的状态。

memoizedProps

上一次渲染过程中用于创建输出的 fiberprops

pendingProps

从React元素的新数据中更新的props,需要应用于子组件或DOM元素。

key

用于在一组子item唯一标识子项的字段。

以帮助React弄清哪些item已经改变,已经从列表中添加或删除。


5. 渲染算法

React的工作主要分两个阶段进行:渲染 Render提交 Commit

render阶段,React 通过 setStateReact.render对预定的组件进行更新,并找出UI中需要更新的内容。

  • 如果是初次渲染Reactrender方法返回的每个元素创建一个新的fiber节点
  • 在接下来的更新中,现有 React元素fiber重新使用和更新

该阶段的结果是一棵标有副作用的fiber节点树。这些效果描述了在接下来的提交阶段需要做的工作。在commit阶段,React 遍历标有效果的fiber树,并将效果应用于实例。它遍历effect列表,执行DOM更新和其他用户可见的变化。

重要的是,render阶段的工作可以异步进行React 可以根据可用的时间来处理一个或多个fiber节点,然后停下来,把已经完成的工作储存起来,并将处理fiber的操作暂停yield。然后从上次离开的地方继续。但有时,可能需要丢弃已完成的工作并从头开始。针对在这个阶段执行的工作的暂停操作不会导致任何用户可见的UI变化,如DOM更新。相比之下,接下来的提交阶段总是同步的。这是因为在这个阶段进行的工作会导致用户可见的变化,例如DOM更新。这就是为什么React需要一次性完成这些工作。

调用生命周期的方法是React执行的一种工作类型。有些方法是在render阶段调用的,有些是在commit阶段调用的。下面是在render阶段工作时调用的生命周期的列表

  • [UNSAFE_]componentWillMount (废弃)
  • [UNSAFE_]componentWillReceiveProps (废弃)
  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate (废弃)
  • render

正如你所看到的,从16.3版本开始,一些在渲染阶段执行的传统生命周期方法被标记为 UNSAFE。它们现在在文档中被称为遗留生命周期。它们将在未来的16.x版本中被废弃。

我们来简单解释下,为什么会有生命周期会被遗弃。

由于render阶段不会产生像DOM更新那样的副作用,React可以异步处理组件的更新(甚至有可能在多个线程中进行)。然而,标有 UNSAFE 的生命周期经常被滥用。开发者倾向于将有副作用的代码放在这些方法中,这可能会给新的异步渲染方法带来问题

下面是在commit阶段执行的生命周期方法的列表。

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为这些方法在同步提交阶段执行,它们可能包含副作用并触及DOM。

这里我们贴一个针对react-16.4+版本的类组件的生命周期方法。


Render 阶段

调和算法总是使用 renderRoot 函数从最上面的 HostRoot fiber节点开始。然而,React会跳过已经处理过的fiber节点,直到找到工作未完成的节点

例如,如果你在组件树的深处调用 setState,React会从顶部开始,但迅速跳过父节点,直到它到达调用了setState方法的组件。

workLoop 主要流程

所有fiber节点都在 workLoop 中被处理

下面是该循环的同步部分的实现。

function workLoop(isYieldy{
  if (!isYieldy) {
    while (nextUnitOfWork !== null) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  } else {...}
}

在上面的代码中,nextUnitOfWork 持有对来自 workInProgress 树fiber节点的引用,该节点有一些工作要做。 当 React 遍历 Fiber 树时,它使用这个变量来了解是否还有其他未完成工作的 Fiber 节点。 处理current fiber后,该变量将包含对树中下一个fiber节点的引用或为空。

有 4 个主要函数用于遍历树并启动或完成工作:

  1. performUnitOfWork
  2. beginWork
  3. completeUnitOfWork
  4. completeWork

为了演示如何使用它们,请查看以下遍历fiber树的动图。 每个函数都接收一个fiber节点并对其处理,当 React 沿着树向下移动时,您可以看到当前活动的fiber节点发生了变化。它先完成孩子节点的处理,再转向其父节点

请注意,垂直连接表示兄弟节点,而水平连接表示子节点,
例如 b1 没有孩子,而 b2 有一个孩子 c1

可以将begin视为进入组件,将complete视为退出组件。

我们简单的分析开始阶段的函数 performUnitOfWorkbeginWork

function performUnitOfWork(workInProgress{
    let next = beginWork(workInProgress);
    if (next === null) {
        next = completeUnitOfWork(workInProgress);
    }
    return next;
}

function beginWork(workInProgress{
    console.log('work performed for ' + workInProgress.name);
    return workInProgress.child;
}

performUnitOfWork 函数从 workInProgress 树接收一个fiber节点,并通过调用 beginWork 函数开始工作。 该函数将启动针对fiber的相关处理操作。

函数 beginWork 总是返回一个指向循环中要处理的下一个子节点的指针或 null

  • 如果有下一个孩子,它将被分配给 workLoop 函数中的变量 nextUnitOfWork
  • 如果没有子节点,React 知道它到达了分支的末尾,因此它可以完成当前节点。

节点完成后,需要为兄弟姐妹执行处理,然后回溯到父节点。 这些操作是在 completeUnitOfWork 函数中完成的:

function completeUnitOfWork(workInProgress{
    while (true) {
        let returnFiber = workInProgress.return;
        let siblingFiber = workInProgress.sibling;

        nextUnitOfWork = completeWork(workInProgress);

        if (siblingFiber !== null) {
            // 如果存在兄弟节点,将其返回并对其处理
            return siblingFiber;
        } else if (returnFiber !== null) {
            // 兄弟节点不存在,父节点存在,返回父节点
            workInProgress = returnFiber;
            continue;
        } else {
            // 到达该分支的尾端
            return null;
        }
    }
}

function completeWork(workInProgress{
    console.log('work completed for ' + workInProgress.name);
    return null;
}

从代码中可以看到该函数有一个很大的 while 循环。 当 workInProgress 节点没有子节点时React 会进入此函数。 在完成current fiber的工作后,它会检查是否有兄弟姐妹。 如果找到,React 退出函数并返回指向兄弟的指针。 它将被分配给 nextUnitOfWork 变量,React 将从这个兄弟节点开始执行分支的工作。 重要的是要理解,此时 React 只完成了前面的兄弟姐妹的工作。 它还没有完成父节点的工作。 只有从子节点开始的所有分支都完成后,它才能执行回溯操作并完成父节点的工作

从代码实现中可以看出,performUnitOfWorkcompleteUnitOfWork 都主要用于迭代,而主要操作发生在 beginWorkcompleteWork 函数中


Commit 阶段

该阶段从函数 completeRoot 开始。 这是 React 更新 DOM 并调用变动前后生命周期方法的地方。

React 进入这个阶段时,它有 2 棵树

  • 第一个树代表当前在屏幕上呈现的状态。
  • 第二个树是在render阶段构建了一个备用树 alternate tree
    它在源代码中称为 finishedWorkworkInProgress,表示需要在屏幕上反映的状态。
    该备用树通过child指针和sibling指针进行各个节点的连接。

还有一个效果列表——来自finishedWork树的节点子集,通过 nextEffect 指针链接。 请记住,效果列表是render阶段的结果。 渲染的重点是确定哪些节点需要插入、更新或删除,哪些组件需要调用其生命周期方法。 这就是效果列表告诉我们。 它正是在commit阶段需要处理的节点集

commit阶段运行的主要函数是 commitRoot。 基本上,它执行以下操作:

  • 在标记有Snapshot效果的节点上调用 getSnapshotBeforeUpdate 生命周期方法
  • 在标记有Deletion效果的节点上调用 componentWillUnmount 生命周期方法
  • 执行所有 DOM 插入、更新和删除
  • finishedWork设置为current
  • 在标记有Placement效果的节点上调用 componentDidMount 生命周期方法
  • 在标记有Update效果的节点上调用 componentDidUpdate 生命周期方法

在调用方法 getSnapshotBeforeUpdate 之后,React 将提交树中的所有副作用。 它分两次完成

  • 第一遍执行所有 DOM 插入、更新、删除和 ref 卸载。
    然后 ReactfinishedWork 树分配给 FiberRoot,并将 workInProgress 树标记为current 树
  • 第二遍中React 调用所有生命周期方法和 ref 回调。

以下是运行上述步骤的函数的要点:

function commitRoot(root, finishedWork{
    commitBeforeMutationLifecycles()
    commitAllHostEffects();
    root.current = finishedWork;
    commitAllLifeCycles();
}

这些子函数中的每一个都实现了一个循环,该循环遍历效果列表并检查效果的类型。 当它找到与函数目的相关的效果时,它会应用它。


突变前的生命周期

下面是遍历效果树并检查节点是否具有Snapshot效果的代码:

function commitBeforeMutationLifecycles({
    while (nextEffect !== null) {
        const effectTag = nextEffect.effectTag;
        if (effectTag & Snapshot) {
            const current = nextEffect.alternate;
            commitBeforeMutationLifeCycles(current, nextEffect);
        }
        nextEffect = nextEffect.nextEffect;
    }
}

对于类组件,此效果意味着调用 getSnapshotBeforeUpdate 生命周期方法。

DOM更新

commitAllHostEffectsReact 执行 DOM 更新的函数。 该函数基本上定义了需要对节点执行的操作类型并执行它:

function commitAllHostEffects({
    switch (primaryEffectTag) {
        case Placement: {
            commitPlacement(nextEffect);
            ...
        }
        case PlacementAndUpdate: {
            commitPlacement(nextEffect);
            commitWork(current, nextEffect);
            ...
        }
        case Update: {
            commitWork(current, nextEffect);
            ...
        }
        case Deletion: {
            commitDeletion(nextEffect);
            ...
        }
    }
}

突变后的生命周期方法

commitAllLifecyclesReact 调用所有剩余生命周期方法 componentDidUpdatecomponentDidMount 的函数。


后记

分享是一种态度

参考资料:

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

分类:

前端

标签:

React.js

作者介绍

前端小魔女
V1