React高阶

错误边界

捕获错误显示备用 UI,而不是整个组件崩溃.
使用 getDerivedStateFromError()渲染备用 UI,使用 ComponentDidCatch 捕获错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}

componentDidCatch(error, errorInfo) {
// 你同样可以将错误日志上报给服务器
logErrorToMyService(error, errorInfo);
}

render() {
if (this.state.hasError) {
// 你可以自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}

return this.props.children;
}
}

无法捕获:

  1. 事件处理
  2. 异步代码 (例如 setTimeout 或 requestAnimationFrame 回调函数)
  3. 服务端渲染
  4. 错误边界自身抛出来的错误 (而不是其子组件)

对于无法捕获的错误,可以使用 try/catch.
或者监听 oneror 事件,window.addEventListener('error',fn())

ErrorBoundary 是什么?

  • 在 React 中,ErrorBoundary 是一个组件
  • 用于处理渲染期间出现的 JavaScript 错误
  • 当在渲染组件期间抛出未捕获的错误时,React 会在组件树中向上寻找最近的 ErrorBoundary 组件,然后调用它的 componentDidCatch() 方法

componentDidCatch() 怎么用?

  • componentDidCatch() 接收两个参数:error(表示发生的错误)和 info(包含有关错误发生位置的组件树信息)

ErrorBoundary 还有哪些作用?

  • ErrorBoundary 组件可以将错误信息记录到日志中,或者渲染一些错误信息
  • ErrorBoundary 组件还可以帮助防止应用程序崩溃

作为抛出未捕获错误的 ErrorBoundary 和 try-catch 异常捕获之间什么关系?

React 的 ErrorBoundary 和 try-catch 的区别在于

  1. ErrorBoundary 可以捕获并处理子组件内部的错误,包括渲染错误和生命周期错误
  2. try-catch 只能捕获当前代码块内的同步错误
  3. ErrorBoundary 可以显示错误信息和 fallback UI,让用户知道发生了错误
  4. try-catch 只能通过 console.log 等方式输出错误信息
  5. ErrorBoundary 只能用于类组件,而 try-catch 可以用于函数和类组件

为什么 ErrorBoundary 一定要写在类里面?

  • ErrorBoundary 是一个类组件,因此它必须被定义在类的范围内
  • 在 React 中,只有类组件才能成为高阶组件
  • ErrorBoundary 需要通过继承 React.Component 类来创建一个组件,因此必须使用类声明来定义

如果想在函数组件中使用 ErrorBoundary,可以考虑使用 React Hooks 中的 useErrorBoundary
下面是一个简单的 ErrorBoundary 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI return
<h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
  • 可以像常规组件一样访问这个组件

例如:

1
2
3
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>

已有的工具 react-error-boundary

直接使用工具

React 更新流程

通过改变 class 组件或者函数组件的状态来触发页面的更新的过程

  1. 用户事件(点击等)产生,执行回调
  2. 回调调用 setState,产生 update
  3. react 根据 update 创建渲染任务,在任务中进行 fiber 的 diff,最终会将 patch 更新到真实 DOM
  4. 渲染任务并非立即执行,会放进调度器队列中,经过调度器调度执行.

优先级

整个过程中都有优先级:

  1. 用户事件产生: 根据不同的事件类型执行回调,指定不同的事件优先级
  2. 状态改变,产生 update: 根据产生该 update 的事件优先级,计算 update 优先级
  3. react 创建渲染任务: 根据本次 update 以及未完成的工作计算渲染优先级,根据渲染优先级计算调度优先级
  4. 调度器调度执行: 根据挂载到 fiber 上的 update 优先级及渲染优先级来控制需要跳过的 fiber 及 update.

总体分为两类:

lane 优先级: 事件优先级,update 优先级,渲染优先级
调度优先级: 调度优先级.

JSX

JSX 即 JavaScript XML。一种在 React 组件内部构建标签的类 XML 语法。JSX 为 react.js 开发的一套语法糖

type:用于标识节点的类型。它可以是类似“h1”“div”这样的标准 HTML 标签字符串,也可以是 React 组件类型或 React fragment 类型。
config:以对象形式传入,组件所有的属性都会以键值对的形式存储在 config 对象中。
children:以对象形式传入,它记录的是组件标签之间嵌套的内容,也就是所谓的“子节点”“子元素”。

jsx 转 DOM 过程

  • 使用 React.createElement 或 JSX 编写 React 组件,实际上所有的 JSX 代码最后都会转换成 React.createElement(…) ,Babel 帮助我们完成了这个转换的过程。
  • createElement 函数对 key 和 ref 等特殊的 props 进行处理,并获取 defaultProps 对默认 props 进行赋值,并且对传入的子节点进行处理,最终构造成一个虚拟 DOM 对象
  • ReactDOM.render 将生成好的虚拟 DOM 渲染到指定容器上,其中采用了批处理、事务等机制并且对特定浏览器进行了性能优化,最终转换为真实 DOM

虚拟 DOM

Virtual DOM,即虚拟 DOM 节点.使用 JS 的 Object 对象模拟 DOM 中的节点.通过特定的 render 方法将其渲染成真实 DOM.比操作真实 DOM 减小性能开销.
操作虚拟 DOM 并不会比操作真实 DOM 快,只不过虚拟 DOM 可以将多次操作合并为一次,也就是减少 DOM 操作.
比如添加 1000 个节点,操作真实 DOM 要一个个添加,虚拟 DOM 可以一次性添加.
通过对比前后虚拟 dom,得出变化的部分,只修改有变化的 dom,节省性能.
虚拟 DOM 因为是对象,所以可以支持跨平台.

Diff 算法

传统的 Diff 算法也是一直都有的,diff 算法,会对比新老虚拟 DOM,记录下他们之间的变化,然后将变化的部分更新到视图上。其实之前的 diff 算法,是通过循环递归每个节点,然后进行对比,复杂程度为 O(n^3),n 是树中节点的总数,这样性能是非常差的。
dom-diff 的原理?
dom-diff 算法会比较前后虚拟 DOM,从而得到 patches(补丁),然后与老 Virtual DOM 进行对比,将其应用在需要更新的地方,将 O(n^3) 复杂度的问题转换成 O(n^1=n) 复杂度的问题,得到新的 Virtual DOM。
缺点: 在同级对比中,

降低时间复杂度的方法:

  1. 两个不同类型的元素会产生不同的树
  2. 对于同一层级的一组子节点,它们可以通过唯一 key 进行区分

React Diff

React 需要同时维护两棵虚拟 DOM 树: 一棵表示当前的 DOM 结构,另一棵在 React 状态变更将要重新渲染时生成.
React 通过对比这两棵树的差异,决定是否要修改 DOM 结构.以及如何修改,这就是 Diff 算法.
diff 算法的目的就是对比新旧两次结果,找到可复用的部分,然后剩下的该删除删除,该新增新增.

流程:

  1. React diff 是先对新旧两棵树做深度优先遍历,避免对两棵树做完全比较,因此算法复杂度 O(n).然后给每个节点生成唯一标识.
  2. 在遍历过程中,每遍历一个节点,就将新旧两棵树做对比,并且只对同一级别的的元素进行比较,记录差异,

策略

(1) tree diff

tree diff 主要针对跨层级操作,如果 A,D 两个节点同级,A 整体被移动到 D 作为子节点,那么并不是真正的移动,而是删除 A 节点,
在 D 处创建子节点 A,和 A 的子节点.

(2) Component diff

针对同一层级的 React 组件的 diff.

  • 如果是同类型的组件,就按照原策略继续比较虚拟 DOM 树.
  • 如果不是同类型,就销毁原组件,创建新组件,

(3) element diff

针对同一层级的所有节点.当节点处于同一层级,diff 提供三种操作,插入,移动,删除.
对同一层级的同组节点添加唯一的 key 进行区分.通过 key 来判断那些节点可以复用,直接使用移动操作.

为什么不能用 index 作为 key?

因为 index 会变,数组中如果删除中间某个值,它的 index 会被后面的替换.
key 只是在同层级唯一的,并不是在整个 React 环境下是唯一的.不然的话,复杂度太高.

Fiber 和虚拟 DOM 的关系

  • 在 React16 之前,react 直接递归渲染 VDom,setState 会触发重新渲染,对比渲染出的新旧 VDom,对差异部分进行 dom 操作.
  • 在 React16 之后,为了优化性能,会先把 vdom 转化成 fiber,也就是从树转化成连链表,然后再渲染.整体渲染流程分为两个部分:
    • render 阶段: 从虚拟 dom 转化成 fiber,对需要操作的节点打上 effectTag 标记.
    • commit 阶段: 对有 effectTag 标记的 fiber 节点进行 dom 操作,并执行所有的 effect 副作用函数.

从虚拟 dom 转化到 fiber 的过程叫 reconcile(调和),这个过程是可以打断的.由调度执行.

Fiber 和 diff 的关系

diff 算法作用在 reconcile 阶段:

  • 第一次渲染不需要 diff,直接 vdom 转 fiber.
  • 再次渲染时候会产生新的 vdom,这个时候需要和之前的 fiber 做对比,决定怎么产生新的 fiber,对可复用的节点打上修改的标记,剩余的旧节点打上删除标记,新节点打上新增标记.

React Diff 总结

react 是基于 vdom 的前端框架,组件渲染产生 vdom,渲染器把 vdom 渲染成 dom。
浏览器下使用 react-dom 的渲染器,会先把 vdom 转成 fiber,找到需要更新 dom 的部分,打上增删改的 effectTag 标记,这个过程叫做 reconcile,可以打断,由 scheducler 调度执行。reconcile 结束之后一次性根据 effectTag 更新 dom,叫做 commit。
这就是 react 的基于 fiber 的渲染流程,分成 render(reconcile + schedule)、commit 两个阶段。
当渲染完一次,产生了 fiber 之后,再次渲染的 vdom 要和之前的 fiber 对比下,再决定如何产生新的 fiber,目标是尽可能复用已有的 fiber 节点,这叫做 diff 算法。
react 的 diff 算法分为两个阶段:

  • 第一个阶段一一对比,如果可以复用就下一个,不可以复用就结束。
  • 第二个阶段把剩下的老 fiber 放到 map 里,遍历剩余的 vdom,一一查找 map 中是否有可复用的节点。
  • 最后把剩下的老 fiber 删掉,剩下的新 vdom 新增。

这样就完成了更新时的 reconcile 过程。
其实 diff 算法的核心就是复用节点,通过一一对比也好,通过 map 查找也好,都是为了找到可复用的节点,移动过来。然后剩下的该删删该增增。
理解了如何找到可复用的节点,就理解了 diff 算法的核心。

Fiber

为什么需要 Fiber

在 React 15 版本中采用的是 Virtual DOM 对比方案,通过对比 Virtual DOM 找出差异部分,从而只将差异部分更新到页面中,避免更新整体 DOM 以提高性能。
在 Virtual DOM 比对的过程中 React 使用了递归,递归调用的过程不能被终止,如果 Virtual DOM 的层级比较深,递归比对的过程就会长期占用主线程,而 JavaScript 又是单线程,不能同时执行多个任务,其他任务只能等待执行,而且 JavaScript 的执行和 UI 的渲染又是互斥的,此时用户要么看到的就是空白界面,要么就是有界面但是不能响应用户操作,处于卡顿状态,用户体验差。
核心就是递归比对的过程长期占用主线程产生了性能问题。

Fiber 如何解决这种问题

在 Fiber 架构中 React 放弃了递归调用,采用循环来模拟递归,因为循环可以随时被中断。
React 利用浏览器空闲时间执行比对任务, 解决了 React 执行比对任务长期占用主线程的问题。
因为采用了链表的结构,即使被中断了, 也可以从未完成处理的 Fiber 处继续遍历.
React 在执行完一个任务单元后,查看是否有其他的高优先级任务,如果有,放弃占用主线程,先执行优先级高的任务.

什么是 Fiber

  1. Fiber 是一个执行单元

React16 之前,将 VirtualDOM 树整体看成一个任务,进行递归处理,任务整体庞大,执行耗时且不能中断.
React16 开始,将整个任务分成一个个小的任务进行处理,每个小的任务就是 Fiber 节点的构建.
任务是在浏览器的空余时间执行,每个单元执行后,都会检查是否还有空余时间,如果有,就继续执行,没有就交还主线程的控制权.

  1. Fiber 是一种数据结构

Fiber 其实是一个 js 对象,对象中有 children 属性表示节点的子节点,有 sibling 属性表示节点的下一个兄弟节点,有 return 属性表示节点的父节点.

1
2
3
4
5
6
7
8
9
10
11
12
type Fiber = {
// 组件类型 div、span、组件构造函数
type: any,
// DOM 对象
stateNode: any,
// 指向自己的父级 Fiber 对象
return: Fiber | null,
// 指向自己的第一个子级 Fiber 对象
child: Fiber | null,
// 指向自己的下一个兄弟 iber 对象
sibling: Fiber | null,
};

image.png

Fiber 的工作方式

render 阶段: 构建 Fiber 对象,构建链表,在链表中标记要执行的 DOM 操作,可中断.
commit 阶段: 根据构建好的链表进行 DOM 操作,不可中断.

先从上向下走,构建节点对应的 Fiber 对象,然后再从下向上走,构建 Fiber 对象及链表

渲染阶段

Reconcilation(协调阶段)

从虚拟 DOM => Fiber 树的阶段,可以认为是 diff 阶段,这个阶段可以被中断.
这个阶段会找出所有节点变更,例如节点新增,删除,属性变更.(就是副作用)
在 Reconcilation 阶段的生命周期有:

  • constructor
  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • render

Commit(提交阶段)

将上个阶段计算出来需要处理的副作用一次性执行.
这个阶段必须同步执行,不能被中断.
在 Commit 阶段的生命周期有:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

在 Reconcilation 阶段如果时间片用完,就会让出控制权。
因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。
需要注意的是:因为协调阶段可能被中断、恢复,甚至重做,React 协调阶段的生命周期钩子可能会被调用多次!, 例如 componentWillMount 可能会被调用两次。

现在你应该知道为什么’提交阶段’必须同步执行,不能中断的吧? 因为我们要正确地处理各种副作用,包括 DOM 变更、还有你在 componentDidMount 中发起的异步请求、useEffect 中定义的副作用… 因为有副作用,所以必须保证按照次序只调用一次

Fiber 实现所利用的 api

利用了 window 的一个 API: requestIdleCallback

1
2
3
requestIdleCallback(function (deadline) {
//deadline.timeRemaining() 获取浏览器的空余时间
});

兼容性

目前只有 chrome 支持 requestIdleCallback,React 为此自己实现一个.
使用 MessageChannel 模拟,将回调延迟到绘制操作之后执行

实现 Fiber

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import React from "react";

const jsx = (
<div id="a1">
<div id="b1">
<div id="c1"></div>
<div id="c2"></div>
</div>
<div id="b2"></div>
</div>
);

const container = document.getElementById("root");

/**
* 1. 为每一个节点构建 Fiber 对象
* 2. 构建 Fiber 链表
* 3. 提交 Fiber 链接
*/

// 创建根元素 Fiber 对象
const workInProgressRoot = {
stateNode: container,
props: {
children: [jsx],
},
};

let nextUnitOfWork = workInProgressRoot;

function workLoop(deadline) {
// 如果下一个要构建的执行单元存在并且浏览器有空余时间
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
// 构建执行单元并返回新的执行单元
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
// 如果所有的执行单元都已经构建完成
if (!nextUnitOfWork) {
// 进入到第二个阶段 执行 DOM 操作
commitRoot();
}
}
// Fiber 工作的第一个阶段
function performUnitOfWork(workInProgress) {
// 构建阶段向下走的过程
// 1. 创建当前 Fiber 节点的 DOM 对象并存储在 stateNode 属性中
// 2. 构建子级 Fiber 对象
beginWork(workInProgress);
// 如果子级存在
if (workInProgress.child) {
// 返回子级 构建子级的子级
return workInProgress.child;
}
// 开始构建阶段向上走的过程
// 如果父级存在
while (workInProgress) {
// 构建 Fiber 链表
completeUnitOfWork(workInProgress);
// 如果同级存在
if (workInProgress.sibling) {
// 返回同级 构建同级的子级
return workInProgress.sibling;
}
// 同级不存在 退回父级 看父级是否有同级
workInProgress = workInProgress.return;
}
}

function beginWork(workInProgress) {
// 如果 Fiber 对象没有存储其对应的 DOM 对象
if (!workInProgress.stateNode) {
// 创建 DOM 对象并存储在 Fiber 对象中
workInProgress.stateNode = document.createElement(workInProgress.type);
// 为 DOM 对象添加属性
for (let attr in workInProgress.props) {
if (attr !== "children") {
workInProgress.stateNode[attr] = workInProgress.props[attr];
}
}
}
// 创建子级 Fiber 对象
if (Array.isArray(workInProgress.props.children)) {
// 记录上一次创建的子级 Fiber 对象
let previousFiber = null;
// 遍历子级
workInProgress.props.children.forEach((child, index) => {
// 创建子级 Fiber 对象
let childFiber = {
type: child.type,
props: child.props,
return: workInProgress,
effectTag: "PLACEMENT",
};
// 第一个子级挂载到父级的 child 属性中
if (index === 0) {
workInProgress.child = childFiber;
} else {
// 其他子级挂载到自己的上一个兄弟的 sibling 属性中
previousFiber.sibling = childFiber;
}
// 更新上一个子级
previousFiber = childFiber;
});
}
}

function completeUnitOfWork(workInProgress) {
let returnFiber = workInProgress.return;

if (returnFiber) {
// 链头上移
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = workInProgress.firstEffect;
}
// lastEffect 上移
if (!returnFiber.lastEffect) {
returnFiber.lastEffect = workInProgress.lastEffect;
}
// 构建链表
if (workInProgress.effectTag) {
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = workInProgress;
} else {
returnFiber.firstEffect = workInProgress;
}
returnFiber.lastEffect = workInProgress;
}
}
}

// Fiber 工作的第二阶段
function commitRoot() {
// 获取链表中第一个要执行的 DOM 操作
let currentFiber = workInProgressRoot.firstEffect;
// 判断要执行 DOM 操作的 Fiber 对象是否存在
while (currentFiber) {
// 执行 DOM 操作
currentFiber.return.stateNode.appendChild(currentFiber.stateNode);
// 从链表中取出下一个要执行 DOM 操作的 Fiber 对象
currentFiber = currentFiber.nextEffect;
}
}

// 在浏览器空闲的时候开始构建
requestIdleCallback(workLoop);

构建 Fiber 链表

  1. 链表的构建顺序

链表的顺序是由 DOM 操作的顺序决定的,c1 是第一个要执行 DOM 操作的所以它是链的开始,A1 是最后一个被添加到 Root 中的元素,所以它是链的最后。
image.png

  1. 如何向链的尾部添加新元素?

在链表结构中通过 nextEffect 存储链中的下一项。
在构建链表的过程中,需要通过一个变量存储链表中的最新项,每次添加新项时都使用这个变量,每次操作完成后都需要更新它。这个变量在源码中叫做 lastEffect。
lastEffect 是存储在当前 Fiber 对象的父级上的,当父级发生变化时,为避免链接顺序发生错乱,lastEffect 要先上移然后再追加 nextEffect

  1. 将链表保存在什么位置?

链表需要被保存在 Root 中,因为在进入到第二阶段时,也就是 commitRoot 方法中,是将 Root 提交到第二阶段的。
在源码中,Root Fiber 下有一个叫 firstEffect 的属性,用于存储链表。
在构建链表的遍历过程中,C1 开始,Root 是结尾,如何才能将 C1 存储到 Root 中呢?
其实是链头的不断上移做到的。
image.png

fiberRoot

fiberRoot 是整个应用的根节点相当于整棵 fiber 树的大脑,一方面可以作为存储载体,它记录了整个 firber 树的调度状态,以及渲染状态,更重要的是可以控制老 fiber 树和新 fiber 树的切换,从而达到双缓冲的效果。

rootFiber

代表 root 容器对应的 fiber 节点,作为渲染树的入口节点
image.png

beginwork

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function beginWork(fiber: Fiber): Fiber | undefined {
if (fiber.tag === WorkTag.HostComponent) {
// 宿主节点diff
diffHostComponent(fiber);
} else if (fiber.tag === WorkTag.ClassComponent) {
// 类组件节点diff
diffClassComponent(fiber);
} else if (fiber.tag === WorkTag.FunctionComponent) {
// 函数组件节点diff
diffFunctionalComponent(fiber);
} else {
// ... 其他类型节点,省略
}
}

宿主节点对比

1
2
3
4
5
6
7
8
9
10
11
12
13
function diffHostComponent(fiber: Fiber) {
// 新增节点
if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber);
} else {
updateHostComponent(fiber);
}

const newChildren = fiber.pendingProps.children;

// 比对子节点
diffChildren(fiber, newChildren);
}

类组件节点对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function diffClassComponent(fiber: Fiber) {
// 创建组件实例
if (fiber.stateNode == null) {
fiber.stateNode = createInstance(fiber);
}

if (fiber.hasMounted) {
// 调用更新前生命周期钩子
applybeforeUpdateHooks(fiber);
} else {
// 调用挂载前生命周期钩子
applybeforeMountHooks(fiber);
}

// 渲染新节点
const newChildren = fiber.stateNode.render();
// 比对子节点
diffChildren(fiber, newChildren);

fiber.memoizedState = fiber.stateNode.state;
}

子节点对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
let oldFiber = fiber.alternate ? fiber.alternate.child : null;
// 全新节点,直接挂载
if (oldFiber == null) {
mountChildFibers(fiber, newChildren);
return;
}

let index = 0;
let newFiber = null;
// 新子节点
const elements = extraElements(newChildren);

// 比对子元素
while (index < elements.length || oldFiber != null) {
const prevFiber = newFiber;
const element = elements[index];
const sameType = isSameType(element, oldFiber);
if (sameType) {
newFiber = cloneFiber(oldFiber, element);
// 更新关系
newFiber.alternate = oldFiber;
// 打上Tag
newFiber.effectTag = UPDATE;
newFiber.return = fiber;
}

// 新节点
if (element && !sameType) {
newFiber = createFiber(element);
newFiber.effectTag = PLACEMENT;
newFiber.return = fiber;
}

// 删除旧节点
if (oldFiber && !sameType) {
oldFiber.effectTag = DELETION;
oldFiber.nextEffect = fiber.nextEffect;
fiber.nextEffect = oldFiber;
}

if (oldFiber) {
oldFiber = oldFiber.sibling;
}

if (index == 0) {
fiber.child = newFiber;
} else if (prevFiber && element) {
prevFiber.sibling = newFiber;
}

index++;
}
}

双缓冲

React 中,WorkInProgress 树就是一个缓冲,它在 Reconciliation 完毕后一次性提交给浏览器进行渲染。它可以减少内存分配和垃圾回收,WorkInProgress 的节点不完全是新的,比如某颗子树不需要变动,React 会克隆复用旧树中的子树。

completeWork

副作用的收集和提交
将所有标记为 EffectTag 的节点串联起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function completeWork(fiber) {
const parent = fiber.return;

// 到达顶端
if (parent == null || fiber === topWork) {
pendingCommit = fiber;
return;
}

if (fiber.effectTag != null) {
if (parent.nextEffect) {
parent.nextEffect.nextEffect = fiber;
} else {
parent.nextEffect = fiber;
}
} else if (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect;
}
}

提交所有副作用

1
2
3
4
5
6
7
8
9
10
11
12
13
function commitAllWork(fiber) {
let next = fiber;
while (next) {
if (fiber.effectTag) {
// 提交,偷一下懒,这里就不展开了
commitWork(fiber);
}
next = fiber.nextEffect;
}

// 清理现场
pendingCommit = nextUnitOfWork = topWork = null;
}

Concurrent Mode 优点

  • 快速响应用户操作和输入,提升用户交互体验
  • 让动画更加流畅,通过调度,可以让应用保持高帧率
  • 利用好 I/O 操作空闲期或者 CPU 空闲期,进行一些预渲染。 比如离屏(offscreen)不可见的内容,优先级最低,可以让 React 等到 CPU 空闲时才去渲染这部分内容。这和浏览器的 preload 等预加载技术差不多。
  • 用 Suspense 降低加载状态(load state)的优先级,减少闪屏。 比如数据很快返回时,可以不必显示加载状态,而是直接显示出来,避免闪屏;如果超时没有返回才显式加载状态。

React 性能优化

React.memo useMemo useCallback

  • React.memo 是包裹整个组件,浅层对比 props 的变化,决定是否渲染组件,
  • useMemo 是更加细粒度的进行性能优化.
  • useMemo 是返回一个 memoized 值,只有当依赖项变化才会触发重新渲染.
  • useMemo 和 useCallback 用法差不多,都是在第一次渲染的时候执行,然后在依赖项改变时再次执行,不同点在于,useMemo 返回缓存的变量,useCallback 返回缓存的函数

异步组件

Suspense 让组件’等待’某个异步操作,直到异步操作完成再渲染.
当数据还没加载完成会展示 suspense 中 fallback 的内容.

那么回到我们的异步组件上来,如果让异步的代码放在同步执行,是肯定不会正常的渲染的,我们还是要先请求数据,等到数据返回,再用返回的数据进行渲染,那么重点在于这个等字,如何让同步的渲染停止下来,去等异步的数据请求呢? 抛出异常可以吗? 异常可以让代码停止执行,当然也可以让渲染中止。
Suspense 就是用抛出异常的方式中止的渲染,Suspense 需要一个 createFetcher 函数会封装异步操作,当尝试从 createFetcher 返回的结果读取数据时,有两种可能:一种是数据已经就绪,那就直接返回结果;还有一种可能是异步操作还没有结束,数据没有就绪,这时候 createFetcher 会抛出一个“异常”。

这个“异常”是正常的代码错误吗? 非也,这个异常是封装请求数据的 Promise 对象,里面是真正的数据请求方法,既然 Suspense 能够抛出异常,就能够通过类似 componentDidCatch 的 try{}catch{} 去获取这个异常。

获取这个异常之后干什么呢? 我们知道这个异常是 Promise,那么接下来当然是执行这个 Promise,在成功状态后,获取数据,然后再次渲染组件,此时的渲染就已经读取到正常的数据,那么可以正常的渲染了。
接下来我们模拟一下 createFetcher 和 Suspense

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
*
* @param {*} fn 我们请求数据交互的函数,返回一个数据请求的Promise
*/
function createFetcher(fn) {
const fetcher = {
status: "pedding",
result: null,
p: null,
};
return function () {
const getDataPromise = fn();
fetcher.p = getDataPromise;
getDataPromise.then((result) => {
/* 成功获取数据 */
fetcher.result = result;
fetcher.status = "resolve";
});

if (fetcher.status === "pedding") {
/* 第一次执行中断渲染,第二次 */
throw fetcher;
}
/* 第二次执行 */
if (fetcher.status) return fetcher.result;
};
}
  • 返回一个函数,在渲染阶段执行,第一次组件渲染,由于 status = pedding 所以抛出异常 fetcher 给 Susponse,渲染中止。
  • Susponse 会在内部 componentDidCatch 处理这个 fetcher,执行 getDataPromise.then, 这个时候 status 已经是 resolve 状态,数据也能正常返回了。
  • 接下来 Susponse 再次渲染组件,此时,此时就能正常的获取数据了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export class Suspense extends React.Component {
state = { isRender: true };
componentDidCatch(e) {
/* 异步请求中,渲染 fallback */
this.setState({ isRender: false });
const { p } = e;
Promise.resolve(p).then(() => {
/* 数据请求后,渲染真实组件 */
this.setState({ isRender: true });
});
}
render() {
const { isRender } = this.state;
const { children, fallback } = this.props;
return isRender ? children : fallback;
}
}

用 componentDidCatch 捕获异步请求,如果有异步请求渲染 fallback,等到异步请求执行完毕,渲染真实组件,借此整个异步流程完毕

lazy 和 Suspense

异步加载组件
React.lazy 接受一个函数,这个函数需要动态调用 import()。它必须返回一个 Promise ,该 Promise 需要 resolve 一个 default export 的 React 组件。

1
2
3
4
5
6
7
8
9
const LazyComponent = React.lazy(() => import("./test.js"));

export default function Index() {
return (
<Suspense fallback={<div>loading...</div>}>
<LazyComponent />
</Suspense>
);
}

React.lazy 是如何配合 Susponse 实现动态加载的效果的呢?

实际上,lazy 内部就是做了一个 createFetcher,而上面讲到 createFetcher 得到渲染的数据,而 lazy 里面自带的 createFetcher 异步请求的是组件。lazy 内部模拟一个 promiseA 规范场景。我们完全可以理解 React.lazy 用 Promise 模拟了一个请求数据的过程,但是请求的结果不是数据,而是一个动态的组件。

createPortal

将 jsx 作为 children 渲染到 DOM 的不同部分,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div>
<SomeComponent />
{createPortal(children, domNode, key?)}
</div>
//-----------------------
import { createPortal } from 'react-dom';

// ...

<div>
<p>这个子节点被放置在父节点 div 中。</p>
{createPortal(
<p>这个子节点被放置在 document body 中。</p>,
document.body
)}
</div>

portal 只改变 DOM 节点的所处位置。在其他方面,渲染至 portal 的 JSX 的行为表现与作为 React 组件的子节点一致。该子节点可以访问由父节点树提供的 context 对象、事件将从子节点依循 React 树冒泡到父节点。
portal 中的事件传播遵循 React 树而不是 DOM 树。例如点击

内部的 portal,将触发 onClick 处理程序。如果这会导致意外的问题,请在 portal 内部停止事件传播,或将 portal 移动到 React 树中的上层。

portal 允许组件将它们的某些子元素渲染到 DOM 中的不同位置。这使得组件的一部分可以“逃脱”它所在的容器。例如组件可以在页面其余部分上方或外部显示模态对话框和提示框。

逻辑复用

高阶组件 HOC

接收一个组件,返回一个新的组件。
接收的组件接受高阶组件的 props,也就是复用的 props 和方法。

render props

使用 children 属性作为传入参数。

自定义 hook

将复用的 state 和方法抽离到一个 hook 中,返回对应的 state 和方法。

将组件作为参数传递

大致有三种情况:

  1. 传递 jsx 元素
  2. 传递组件本身
  3. 传递返回 jsx 的回调

方式一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function DownloadOutlined() {
return /* icon 的实现*/;
}

function Button({ icon, children }) {
return (
<button>
{icon}
{children}
</button>
);
}

function App() {
return <Button icon={<DownloadOutlined />}>test</Button>;
}

可以看出来,icon 直接传递了一个 jsx 创建好的组件,从而满足了用户自定义 icon 的需求。
相比于通过字符串枚举内置 icon, 给了用户更大的定制空间。

方式二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function DownloadOutlined() {
return /* icon 的实现*/;
}

function Button({ icon: Icon, children }) {
return <button>
// 渲染方式进行了改变
<Icon />
{children}
</Button>
}

function App() {
return <Button icon={DownloadOutlined}>test</Button>
}

通过直接传递组件本身的方式,也可将其传递给子组件进行渲染,当然,子组件渲染的地方也改成了 而非上文的 {icon}。ps: 上文中由于 jsx 语法要求,将 icon 变量名改成了首字母大写的 Icon。

方式三

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function DownloadOutlined() {
return /* icon 的实现*/;
}

function Button({ icon, children }) {
return <button>
// 渲染方式进行了改变
{icon()}
{children}
</Button>
}

function App() {
return <Button icon={() => <DownloadOutlined />}>test</Button>
}

方案对比

当父组件发生渲染时,Button 组件是否会发生不必要的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import React, { useState } from "react";

function DownloadOutlined() {
return <span>icon</span>;
}

const Button1 = React.memo(({ icon, children }) => {
console.log("button1 render");

return (
<button>
{icon}
{children}
</button>
);
});

const Button2 = React.memo(({ icon: Icon, children }) => {
console.log("button2 render");

return (
<button>
<Icon />
{children}
</button>
);
});

const Button3 = React.memo(({ icon, children }) => {
console.log("button3 render");
return (
<button>
{icon()}
{children}
</button>
);
});

export default function App() {
const [count, setCount] = useState(0);
console.log("App render");

return (
<>
<Button1 icon={<DownloadOutlined />}>button1</Button1>
<Button2 icon={DownloadOutlined}>button2</Button2>
<Button3 icon={() => <DownloadOutlined />}>button3</Button3>
<button onClick={() => setCount((pre) => pre + 1)}>render</button>
</>
);
}

可以看出,Button1 和 Button3 均进行了渲染,这是由于这两种方案下,icon 的参数发生了变化,对于 Button1, , 本质是 React.createElement(DownloadOutlined), 此时将会返回一个新的引用,就导致了 Button1 参数的改变,从而使得其会重新渲染。而对于 Button3,就更加明显,每次渲染后返回的箭头函数都是新的,自然也会引发渲染。而只有方案二,由于返回的始终是组件的引用,故不会重新渲染。
要避免(虽然实际中,99%的场景都不需要避免,也不会有性能问题)这种情况,可以通过加 memo 解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default function App() {
const [count, setCount] = useState(0);
console.log("App render");

const button1Icon = useMemo(() => {
return <DownloadOutlined />;
}, []);

const button3Icon = useCallback(() => {
return <DownloadOutlined />;
}, []);

return (
<>
<Button1 icon={butto1Icon}>button1</Button1>
<Button2 icon={DownloadOutlined}>button2</Button2>
<Button3 icon={button3Icon}>button3</Button3>
<button onClick={() => setCount((pre) => pre + 1)}>render</button>
</>
);
}

实际的场景中,Icon 组件往往不会如此简单,往往会有一些参数来控制其比如颜色、点击行为以及大小等等,此时,要将这些参数传递给 Icon 组件,这也是笔者想要讨论的:
情况二:需要传递来自父组件(App)的参数的情况。
在现有的基础上, 以传递 size 到 Icon 组件为例,改造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import React, { useState, useMemo, useCallback } from "react";

// 增加 size 参数, 控制 icon 大小
function DownloadOutlined({ size }) {
return <span style={{ fontSize: `${size}px` }}>icon</span>;
}

// 无需修改
const Button1 = React.memo(({ icon, children }) => {
console.log("button1 render");

return (
<button>
{icon}
{children}
</button>
);
});

// 增加 iconProps,来传递给 Icon 组件
const Button2 = React.memo(({ icon: Icon, children, iconProps = {} }) => {
console.log("button2 render");

return (
<button>
<Icon {...iconProps} />
{children}
</button>
);
});

// 无需修改
const Button3 = React.memo(({ icon, children }) => {
console.log("button3 render");
return (
<button>
{icon()}
{children}
</button>
);
});

export default function App() {
const [count, setCount] = useState(0);
const [size, setSize] = useState(12);
console.log("App render");

// 增加size依赖
const button1Icon = useMemo(() => {
return <DownloadOutlined size={size} />;
}, [size]);

// 增加size依赖
const button3Icon = useCallback(() => {
return <DownloadOutlined size={size} />;
}, [size]);

return (
<>
<Button1 icon={button1Icon}>button1</Button1>
<Button2 icon={DownloadOutlined} iconProps={{ size }}>
button2
</Button2>
<Button3 icon={button3Icon}>button3</Button3>
<button onClick={() => setCount((pre) => pre + 1)}>render</button>
<button onClick={() => setSize((pre) => pre + 1)}>addSize</button>
</>
);
}

通过上述改动,可以发现,当需要从 App 组件中,向 Icon 传递参数时,Button1 和 Button3 组件本身不需要做任何改动,仅仅需要修改 Icon jsx 创建时的参数即可,而 Button2 的 Icon 由于渲染发生在内部,故需要额外传递 iconProps 作为参数传递给 Icon。与此同时,render 按钮点击时,由于 iconProps 是个引用类型,导致触发了 Button2 的额外渲染,当然可以通过 useMemo 来控制,此处不再赘述。
接下来看情况三,当子组件(Button1 - button3)需要传递它自身内部的状态到 Icon 组件中时,需要做什么改动。
设想一个虚构的需求, Button1 - Button3 组件内部维护了一个状态,count,也就是每个组件点击的次数,而 DownloadOutlined 也接收一个参数,count, 随着 count 的变化,他的颜色会从 rbg(0, 0, 0) 变化为 rgb(count, 0, 0)。
DownloadOutlined 改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 增加 count 参数,控制 icon 颜色
function DownloadOutlined({ size = 12, count = 0 }) {
console.log(count);
return (
<span style={{ fontSize: `${size}px`, color: `rgb(${count}, 0, 0)` }}>
icon
</span>
);
}

const Button2 = React.memo(({ icon: Icon, children, iconProps = {} }) => {
console.log("button2 render");
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount((pre) => pre + 40)}>
{/* 将count参数注入即可 */}
<Icon {...iconProps} count={count} />
{children}
</button>
);
});

const Button3 = React.memo(({ icon, children }) => {
console.log("button3 render");
const [count, setCount] = useState(0);

return (
// 此处为了放大颜色的改变,点击一次加 40
<button onClick={() => setCount((pre) => pre + 40)}>
{/* 将 count 作为参数传递给 icon 函数 */}
{icon({ count })}
{children}
</button>
);
});

export default function App() {
/* 省略 */

const button3Icon = useCallback(
(props) => {
// 接收参数并将其传递给icon组件
return <DownloadOutlined size={size} {...props} />;
},
[size]
);

/* 省略 */
}

而对于 button1, 由于 icon 渲染的时机,是在 App 组件中,而在 App 组件中,获取 Button1 组件内部的状态并不方便(可以通过 ref, 但是略显麻烦)。此时可以借助 React.cloneElement api 来新建一个 Icon 组件并将子组件参数注入,改造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Button1 = React.memo(({ icon, children }) => {
console.log("button1 render");
const [count, setCount] = useState(0);
// 借助 cloneElement 向icon 注入参数
const newIcon = React.cloneElement(icon, {
count,
});

return (
<button onClick={() => setCount((pre) => pre + 40)}>
{newIcon}
{children}
</button>
);
});

从这个例子可以看出,如果传入的组件(icon),需要获取即将传入组件(Button1, Button2, Button3)内部的状态,那么直接传递 jsx 创建好的元素,并不方便,因为在父组件(App)中获取子组件(Button1)内部的状态并不方便,而直接传递组件本身,和传递返回 jsx 创建元素的函数,前者由于元素真正的创建,就是发生在子组件内部,故可以方便的获取子组件状态,而后者由于是函数式的创建,通过简单的参数传递,即可将内部参数传入 icon 中,从而方便的实现响应的需求。
其中,三种方案,在不做 useMemo, useCallback 这样的缓存情况下,直接传递组件本身,由于引用不变,可以直接避免非必要渲染,但是当需要接收来自父组件的参数时,需要开辟额外的字段 iconProps 来接收父组件的参数,在不做缓存的情况下,由于参数的对象引用每次都会更新从而也存在不必要渲染的情况。当然,这种不必要的渲染,在绝大部分场景下,并不会存在性能问题。
考虑了来自父组件的传参后,除了方案二直接传递组件本身的方案需要对子组件增加 iconProps 之外,其余两个方案由于 jsx 创建组件元素的写法本身就在父组件中,只需稍作改动即可将参数携带入 Icon 组件中。
而当需要接收来自子组件的参数场景下,方案一显得略有不足,jsx 的创建在父组件已经创建好,子组件中需要注入额外的参数相对麻烦(使用 cloneElement 实现参数注入)。而方案三由于函数的执行时机是在子组件内部,可以很方便的将参数通过函数传参带入 Icon 组件,可以很方便的满足需求。

从实际开发组件的场景来看,被作为参数传递的组件需要使用子组件内部参数的,一般通过方案三传递函数的方案来设计,而不需要子组件内部参数的,方案一二三均可,实际的开销几乎没有差异,只能说方案一写法较为简单,也是 antd 的 api 中最常见的用法。而方案三,多见于需要子组件内部状态的情况,比如 antd 的面包屑 itemRender,Form.list 的 children 的渲染,通过函数注入参数给被作为参数传递的组件方便灵活的进行渲染。