React18 源码解析之 fiber 等数据结构

蚊子前端博客
发布于 2022-08-02 22:55
React源码中有诸如fiber等多个数据结构,那每个数据结构都长什么样子,有什么作用呢?

我们解析的源码是 React18.1.0 版本,请注意版本号。React 源码学习的 GitHub 仓库地址:https://github.com/wenzi0github/react

我们稍微了解下 React 中的几个结构。 我们这里仅了解其中的转换过程,后续我们再了解两棵 fiber 树是如何进行对比的。

1. jsx 结构

我们在 React 中写的类似于 html 的结构就被称为 JSX,但他并不是 html,而是一个 JavaScript 的语法扩展。即他是 js,而不是 html。

官方文档:

COPYJSX

const App = () => { const handleClick = () => { console.log('click'); }; return ( <div onClick={handleClick}> <p>hello world</p> </div> ); };

不过这里我们不深入 jsx 的使用方式,主要说下 JSX 的作用。jsx 是 js 的语法糖,方便我们开发者的维护。最后实际上会被 React(React16.x 及之前)或 babel 编译(React17.0 及更新)成用 createElement 编译的结构。

一开始我写 jsx 时也不太习惯,觉得把逻辑和模板混合到一起太乱了,还是 Vue 中的模板+逻辑+样式的组合更好。后来写多了以后,发现 jsx 其实也挺香的,比如它没有额外语法糖的记忆,各种语法跟 js 本身就很像;同时,因为 typescript 给开的后门,jsx 对 ts 的支持程度很高。而且 React 中并不是用文件来分割组件的,我们可以在一个文件里,编写多个组件。

同样的,我们在 React 中像下面这样写的效果是一样的:

COPYJAVASCRIPT

createElement('div', { onClick: handleClick }, createElement('p', null, 'hello world'));

但这种方式使用起来确实不方便。

-蚊子的前端博客

2. element 结构

上面提到会将 jsx 编译成由 createElement()函数组成的一个嵌套结果。那么 createElement 里具体都干了什么呢?

在 React16 及之前,createElement()方法是 React 中的一个方法,因此有些同学就会有疑问,在写.jsx的组件时,本来没用到 React 中的方法,但还是要引入 React。就如上面的代码,在 React16 及之前,要在头部显式地将 React 引入进来的。

COPYJSX

import React from 'react';

最终转换出的代码是:

COPYJAVASCRIPT

React.createElement('div', { onClick: handleClick }, React.createElement('p', null, 'hello world'));

但从 React17 开始,React 和 babel 合作,将 jsx 的转换工作放到了编译工具 babel 中。新的 JSX 转换不会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入新的入口函数并调用。

假设你的源代码如下:

COPYJSX

function App() { return <h1>Hello World</h1>; }

下方是新 JSX 被转换编译后的结果:

COPYJAVASCRIPT

// 由编译器引入(禁止自己引入!) import { jsx as _jsx } from 'react/jsx-runtime'; function App() { return _jsx('h1', { children: 'Hello world' }); }

注意,此时源代码无需引入 React 即可使用 JSX 了!若仍然要使用 React 提供的 Hook 等功能,还是需要引入 React 的。

可以看到新 jsx()和之前的 React.createElement()方法转换出来的结构稍微有点区别。之前的 React.createElement()方法里,子结构会通过第三个参数进行传入;而在 jsx()方法中,这里将子结构放到了第二个参数的 children 字段里,第 3 个字段则用于传入设置的 key 属性。若子结构中只有一个子元素,那么 children 就是一个 jsx(),若有多个元素时,则会转为数组:

COPYJAVASCRIPT

const App = () => { return jsx('div', { children: jsx('p', { children: [ jsx('span', { className: 'dd', children: 'hello world', }), _jsx('span', { children: '123', }), ], }), }); };

这里有个 babel 的在线网站,我们可以编写一段 React 代码,能实时看到通过 babel 编译后的效果:React 通过 babel 实现新的 jsx 转换。若 jsx 的转换方式还是旧版的,请在左侧的配置中,将 React Runtime 设置为 automatic 。

那么 jsx()方法里具体是怎么执行的呢?最后返回了样子的数据呢?源码位置:jsx()

jsx()方法会先进行一系列的判断,相关链接: 介绍全新的 JSX 转换。jsx()方法中,会经过一些判断,将 key 和 ref 两个比较特殊的属性单独提取出来。

COPYJAVASCRIPT

/** * 将jsx编译为普通的js树形结构 * @param {string|function} type 若节点为普通html标签时,type为标签的tagName,若为组件时,即为该函数 * @param {object} config 该节点所有的属性,包括children * @param {string?} maybeKey 显式地设置的key属性 * @returns {*} */ export function jsx(type, config, maybeKey) { let propName; // Reserved names are extracted const props = {}; let key = null; let ref = null; // 若设置了key,则使用该key if (maybeKey !== undefined) { if (__DEV__) { checkKeyStringCoercion(maybeKey); } key = '' + maybeKey; } // 若config中设置了key,则使用config中的key if (hasValidKey(config)) { if (__DEV__) { checkKeyStringCoercion(config.key); } key = '' + config.key; } // 提取设置的ref属性 if (hasValidRef(config)) { ref = config.ref; } // Remaining properties are added to a new props object // 剩余属性将添加到新的props对象中 for (propName in config) { if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) { props[propName] = config[propName]; } } /** * 我们的节点有有三种类型: * 1. 普通的html标签,type为该标签的tagName,如div, span等; * 2. 当前是Function Component节点时,则type该组件的函数体,即可以执行type(); * 3. 当前是Class Component节点,则type为该class,可以new出一个实例; * 而type对应的是Function Component时,可以给该组件添加defaultProps属性, * 当设置了defaultProps,则将未明确传入的属性给到props里 */ // Resolve default props if (type && type.defaultProps) { const defaultProps = type.defaultProps; for (propName in defaultProps) { if (props[propName] === undefined) { props[propName] = defaultProps[propName]; } } } /** * 参数处理完成后,就调用ReactElement()方法返回一个object结构 */ return ReactElement(type, key, ref, undefined, undefined, ReactCurrentOwner.current, props); }

ReactElement()方法的作用就是返回一个 object 结构,我们这里把所有的提示代码都去掉:

COPYJAVASCRIPT

/** * Factory method to create a new React element. This no longer adheres to * the class pattern, so do not use new to call it. Also, instanceof check * will not work. Instead test $$typeof field against Symbol.for('react.element') to check * if something is a React Element. */ const ReactElement = function(type, key, ref, self, source, owner, props) { const element = { // This tag allows us to uniquely identify this as a React Element $$typeof: REACT_ELEMENT_TYPE, // 用来标识当前是否是React元素 /** * 我们的节点有有三种类型: * 1. 普通的html标签,type为该标签的tagName,如div, span等; * 2. 当前是Function Component节点时,则type该组件的函数体,即可以执行type(); * 3. 当前是Class Component节点,则type为该class,可以通过该type,new出一个实例; * 而type对应的是Function Component时,可以给该组件添加defaultProps属性, * 当设置了defaultProps,则将未明确传入的属性给到props里 */ // Built-in properties that belong on the element type: type, key: key, ref: ref, props: props, // Record the component responsible for creating this element. _owner: owner, }; return element; };

上面方法注释的大概意思是:现在不再使用类的方式 new 出一个实例来,因此不再使用 instanceOf 来判断是否是 React 元素;而是判断 $$typeof 字段是否等于Symbol.for('react.element')来判断。

我们已经知道 $$typeof 字段的作用是为了标识 React 元素的,但他的值为什么用 Symbol 类型呢?可以参考这篇文章:为什么 React 元素有一个$$typeof 属性?

到目前位置,我们已经知道了 jsx 在传入 render()方法之前,会编译成什么样子。

我们在*.jsx文件中,先直接输出下 jsx 的结构:

COPYJSX

console.log( <div> <span>hello world</span> </div>, );

在控制台里就能看到这样的结构:

jsx编译后的结构效果-蚊子的前端博客

COPYJAVASCRIPT

const element = { $$typeof: Symbol(react.element), key: null, props: { children: { // 当children有多个时,会转为数组类型 $$typeof: Symbol(react.element), key: null, props: { children: 'hello world', // 文本节点没有类型 }, ref: null, type: 'span', }, }, ref: null, type: 'div', };

我们再输出一个完整的组件,如一个 App 组件如下:

COPYJSX

const App = ({ username }) => { return ( <div> <span>hello {username}</span> </div> ); };

分别输出下 App 和

COPYJAVASCRIPT

console.log(<App />, App);

React组件的jsx编译后-蚊子的前端博客

单纯的App是一个函数,function 类型,但这里不能直接执行App(),会报错的;而<App />则是一个 json 结构,object 类型的,其本来的方法则存放到了 type 字段中。

我们在上面的代码中已经说了 type 字段的含义,这里再说下跟 type 相关的 children 字段。当 type 为 html 标签时,children 就其下面所有的子节点。当只有一个子节点时,children 为 object 类型,当有多个子节点时,children 是 array 类型。

有些同学可能一时反应不过来,觉得组件的 children 是其内部返回的 jsx 结构。这是不对的。这里我们要把组件也当做一个跟普通 html 标签一样的标签来对待,组件的 children 就是该组件标签包裹的内容。组件里的内容,可以通过执行type字段对应的 function 或 class 来获得。如:

COPYJSX

const Start = ( <div> <App> <p>this is app children</p> </App> </div> );

这里<App>标签里的 p 标签才是他的 children。

因此,在传入到 render()方法时,就是这样子的一个 object 类型的 element 结构的元素。

有点膨胀-蚊子的前端博客

3. fiber 结构

在上面通过 babel 转换后的 element 结构的数据,会在 render()方法中的某个阶段将其转为 fiber 结构。render()方法里具体怎样转换的,我们以后的文章再讲,这里我们只是看下 fiber 节点的结构。

3.1 单个 fiber 的属性

我们先看看一个 fiber 节点都有哪些属性,这些属性都是什么含义。

COPYJAVASCRIPT

/** * 创建fiber节点 * @param {WorkTag} tag * @param {mixed} pendingProps * @param {null | string} key * @param {TypeOfMode} mode * @constructor */ function FiberNode(tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode) { // Instance this.tag = tag; // 当前节点的类型,如 FunctionComponent, ClassComponent 等 /** * 这个字段和 react element 的 key 的含义和内容有一样(因为这个 key 是 * 从 react element 的key 那里直接拷贝赋值过来的),作为 children 列表 * 中每一个 item 的唯一标识。它被用于帮助 React 去计算出哪个 item 被修改了, * 哪个 item 是新增的,哪个 item 被删除了。 * @type {string} */ this.key = key; /** * fiber 中的 elmentType 与 element 中的type一样 * this.elementType = element.type */ this.elementType = null; /** * 当前fiber节点的元素类型,与React Element里的type类型一样,若是原生的html标签, * 则 type 为该标签的类型('div', 'span' 等);若是自定义的Class Component或 * Function Component等,则该type的值就是该class或function,后续会按照上面的tag字段, * 来决定是用new初始化一个实例(当前是 Class Component),然后执行该class内 * 的render()方法;还是执行该type(当前是 Function Component),得到其返回值; */ this.type = null; /** * 1. 若当前fiber节点是dom元素,则对应的是真实DOM元素; * 2. 若当前是function component,则值为null; * 3. 若当前是class component,则值为class初始化出来的实例; * 4. 若当前是 host component,即树的根节点,stateNode为 FiberRootNode; */ this.stateNode = null; /** * 下面的return, child和sibling都是指针,用来指向到其他的fiber节点, * React会将jsx编译成的element结构,转为以fiber为节点的链表结构, * return: 指向到父级fiber节点; * child: 指向到该节点的第1个子节点; * sibling: 指向到该节点的下一个兄弟节点; * 如图所示:https://www.xiabingbao.com/upload/386262ff06785779c.jpg */ this.return = null; this.child = null; this.sibling = null; this.index = 0; this.ref = null; this.pendingProps = pendingProps; this.memoizedProps = null; this.updateQueue = null; this.memoizedState = null; this.dependencies = null; this.mode = mode; // Effects this.flags = NoFlags; // 该节点更新的优先级,若为NoFlags时,则表示不更新 this.subtreeFlags = NoFlags; // 子节点的更新情况,若为NoFlags,则表示其子节点不更新,在diff时可以直接跳过 this.deletions = null; // 子节点中需要删除的节点 this.lanes = NoLanes; this.childLanes = NoLanes; /** * 双缓冲:防止数据丢失,提高效率(之后Dom-diff的时候可以直接比较或者使用 * React在进行diff更新时,会维护两颗fiber树,一个是当前正在展示的,一个是 * 通过diff对比后要更新的树,这两棵树中的每个fiber节点通过 alternate 属性 * 进行互相指向。 */ this.alternate = null; }

React 中大到组件,小到 html 标签,都会转为 fiber 节点构建的 fiber 链表。

阿欧-蚊子的前端博客

3.2 fiber 树的构成

jsx 中的所有节点都会转为 fiber 节点,那他们是怎么组合起来的呢?

正如上面代码中的注释中说到的,每个 fiber 节点都有 3 个指针:

  • return: 指向到父级的 fiber 节点;

  • child: 指向到该节点的第 1 个子节点;若想访问其他的子节点,可以通过下面的sibling指针来访问;

  • sibling: 指向到该节点的下一个兄弟节点;

如图所示:

react中fiber节点的指针-蚊子的前端博客

并列的节点,会形成单向链表,父级节点只会指向到这个单向链表的头节点。正如上图中的 p 标签和 span 标签。

4. 为什么要使用 fiber 结构

为什么要使用 fiber 链表?这里我们稍微了解下,后面会详细介绍 fiber 链表如何进行 diff 每个 fiber 节点的。

4.1 Stack Reconciler

在 React 15.x 版本以及之前的版本,Reconciliation 算法采用了栈调和器( Stack Reconciler )来实现,但是这个时期的栈调和器存在一些缺陷:不能暂停渲染任务,不能切分任务,无法有效平衡组件更新渲染与动画相关任务的执行顺序,即不能划分任务的优先级(这样有可能导致重要任务卡顿、动画掉帧等问题)。Stack Reconciler 的实现。

4.2 Fiber Reconciler

为了解决 Stack Reconciler 中固有的问题,以及一些历史遗留问题,在 React 16 版本推出了新的 Reconciliation 算法的调和器—— Fiber 调和器(Fiber Reconciler)来替代栈调和器。Fiber Reconciler 将会利用调度器(Scheduler)来帮忙处理组件渲染/更新的工作。此外,引入 fiber 这个概念后,原来的 react element tree 有了一棵对应的 fiber node tree。在 diff 两棵 react element tree 的差异时,Fiber Reconciler 会基于 fiber node tree 来使用 diff 算法,通过 fiber node 的 return、child、sibling 属性能更方便的遍历 fiber node tree,从而更高效地完成 diff 算法。

fiber 调度的优点:

  1. 能够把可中断的任务切片处理;

  2. 能够调整任务优先级,重置并复用任务;

  3. 可以在父子组件任务间前进后退切换任务;

  4. render 方法可以返回多个元素(即可以返回数组);

  5. 支持异常边界处理异常;

大胆的想法-蚊子的前端博客

5. 总结

fiber 结构是 React 整体的一个基础,两棵状态树的 遍历、diff 对比,任务优先级的判断等,都是基于 fiber 结构来实现的。

其实我们在上面的讲解中,已经解决了几个常见的问题,如:

5.1 为什么 React17 需要显式地引入 React,而之后不用了?

这是因为在 React17 之前,createElement() 方法是在放在 React 中的,只要涉及到 jsx 的,都需要引入 React,才能使用该方法。而从 React17 开始,修改了 jsx 的编译方式。

5.2 Virtual Dom 是什么?

这里我们介绍了 3 种数据结构,那么 React 中说的虚拟 DOM(Virtual DOM)指的是哪一个呢?

实际上指的是 element 这个数据结构,用 js 对象描述真实 dom 的 js 对象。

  • 优点:处理了浏览器的兼容性,防范 xss 攻击,跨平台,差异化更新,减少更新的 dom 操作;

  • 缺点:额外的内存,初次渲染不一定快;因为要进行后续一系列的构建、hooks 的搭建等,才会渲染 DOM;会比直接操作 DOM 要慢一些;

标签:
阅读(360)

公众号:

qrcode

微信公众号:前端小茶馆