参考文章
fiber架构
理解 React Fiber & Concurrent Mode【2】
The how and why on React’s usage of linked list in Fiber to walk the component’s tree【4】
react如何遍历并比较_探索 React 内核:深入 Fiber 架构和协调算法【5】
concurrent mode
React Concurrent Mode三连:是什么/为什么/怎么做【9】
suspense
suspense源码分析【11】
React源码 Suspense 和 ReactLazy【12】
简介
Concurrent 模式是一组 React 的新功能,可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整。
目前仅在实验版本可用,React18计划加入该机制。
并发模式指非阻塞,主要包含两个场景,一个是js执行阻塞浏览器渲染,另一个是网络请求阻塞界面渲染。
对阻塞进行优化可以提升实际和感知性能。
js执行阻塞通过fiber架构及配套逻辑实现“可中断渲染”来解决。网络请求阻塞界面渲染通过suspense方案解决,其实网络请求阻塞界面渲染可以开发者自己手动实现,基本逻辑就是某个组件如果在代码或者数据还没有准备好时候,先渲染一个loading,等准备好了之后再渲染组件,suspense为这种场景提供了比较通用和使用方便的解决方案。
可中断渲染
问题
React16 以前,对virtural dom的更新和渲染是同步的。就是当一次更新或者一次加载开始以后,diff virtual dom并且渲染的过程是一口气完成的。如果组件层级比较深,相应的堆栈也会很深,长时间占用浏览器主线程,一些类似用户输入、鼠标滚动等操作得不到响应。
如果有足够的时间,浏览器是会对我们的代码进行编译优化(JIT)及进行热代码优化,一些DOM操作,内部也会对reflow进行修正。而过长时间占用主进程,会导致性能下降。
解决方案是时间分片,即利用浏览器的渲染空余时间来执行diff工作,当执行时间过长时候,停止diff工作,把执行机会让给渲染,然后等渲染间隙再继续执行diff工作,直到diff完成,再执行commit。这样不仅避免了渲染工作被js执行阻塞导致的卡顿,还让浏览器有时间对代码优化从而提升执行性能。
时间分片
如何实现时间分片呢?
基本思路是这样的
时间分片的时长
首先分片大小和渲染频率有关,肉眼能接受的流畅画面的最低帧率是60fps,即一帧16ms。因此每个时间分片不能大于16ms,如果执行时间大于16ms,就要停止,然后让浏览器先执行渲染操作,渲染空余时候再继续执行。
这就要求React拥有暂定和重启diff操作的能力。
断点重启
基于React16及之前的版本的架构很难实现断电重启功能,因为虚拟dom天然是嵌套结构,diff是递归操作。因此React团队称React16之前的调度器为栈调度器。
栈的问题,首先递归结构每次创建函数需要生成执行上下文、变量对象,性能消耗较大。另一方面,递归结构不方便进行中断重启。比如深度优先遍历的话,遍历到某个节点时候中断,再重启时候,如果没有复杂的辅助数据结构,是不知道下一个要遍历哪个节点的。
鉴于栈结构的问题,React需要新的架构来支持断点重启。
Fiber架构应运而生。
首先,需要将虚拟dom树结构改成链表结构,链表结构利于暂停和重启,比如遍历到某个节点时候需要暂停,那么只要记录当前指针,等到重启时候指向下一个就可以了。
fiber架构
fiber tree
diff的工作就是遍历虚拟dom树,因此让diff工作能够支持断点重启,就是让遍历操作能够支持断点重启。
为此,React设计了fiber tree数据结构,每个fiber tree的node都有3个属性:return(指向父节点)、sibling(指向右兄弟节点)、child(指向第一个子节点)。
如何通过这几个属性遍历呢?【4】
首先我们看之前的递归遍历方式。
walk(a1);
function walk(instance) {
doWork(instance);
const children = instance.render();
children.forEach(walk);
}
function doWork(o) {
console.log(o.name);
}
使用fiber之后如何遍历?先看下fiber数据结构
class Node {
constructor(instance) {
this.instance = instance;
this.child = null;
this.sibling = null;
this.return = null;
}
}
访问虚拟dom节点,并遍历生成fiber树
// 访问虚拟dom节点,并遍历、构造fiber树
function walk(o) {
let root = o;
let current = o;
while (true) {
// perform work for a node, retrieve & link the children
let child = doWork(current);
// if there's a child, set it as the current active node
if (child) {
current = child;
continue;
}
// if we've returned to the top, exit the function
if (current === root) {
return;
}
// keep going up until we find the sibling
while (!current.sibling) {
// if we've returned to the top, exit the function
if (!current.return || current.return === root) {
return;
}
// set the parent as the current active node
current = current.return;
}
// if found, set the sibling as the current active node
current = current.sibling;
}
}
// 访问节点,并生成child node
function doWork(node) {
console.log(node.instance.name);
const children = node.instance.render();
return link(node, children);
}
// 创建fiber节点、并初始化return、child、sibling属性,并返回子节点
function link(parent, elements) {
if (elements === null) elements = [];
parent.child = elements.reduceRight((previous, current) => {
const node = new Node(current);
node.return = parent;
node.sibling = previous;
return node;
}, null);
return parent.child;
}
通过上面的分析可以看出,fiber tree并非通过严格的链表来进行遍历,它也是一个树的结构,它的遍历过程和深度优先遍历一个树没有区别,区别在于加了几个属性指向相关节点,让遍历可以暂停和重启,很方便地找到一个节点的DFS下一个节点。
fiber可以理解是一种数据结构,是一个树的结构,fiber节点记录的是操作,包括将要进行的操作和已经完成的操作。而fiber架构是包含数据结构和调度机制的一个整体。
A Fiber is work on a Component that needs to be done or was done. There can be more than one per component.
diff过程
参考文章【6】
使用fiber架构进行diff工作和之前有所不同。
在render函数中创建的React Element树在第一次渲染的时候会创建一颗结构一模一样的Fiber节点树。不同的React Element类型对应不同的Fiber节点类型。一个React Element的工作就由它对应的Fiber节点来负责。 一个React Element可以对应不止一个Fiber,因为Fiber在update的时候,会从原来的Fiber(我们称为current)clone出一个新的Fiber(我们称为alternate)。两个Fiber diff出的变化(side effect)记录在alternate上。所以一个组件在更新时最多会有两个Fiber与其对应,在更新结束后alternate会取代之前的current的成为新的current节点。 上面提到时间分片的计算方法,React会记录diff操作时间,如果大于一帧的渲染时间则暂停,然后等待下个渲染间隙再继续执行diff操作,直到diff完成。这个机制由调度器来完成。 基于requestIdleCallback实现的。关于该API可以参考另一篇文章。(实际上React为了照顾绝大多数的浏览器,自己实现了requestIdleCallback。) Fiber的基本规则:更新任务分成两个阶段,Reconciliation Phase和Commit Phase。Reconciliation Phase的任务干的事情是,找出要做的更新工作(Diff Fiber Tree),就是一个计算阶段,计算结果可以被缓存,也就可以被打断;Commmit Phase 需要提交所有更新并渲染,为了防止页面抖动,被设置为不能被打断。注意,这种改动带来的问题是,有些生命周期钩子可能被执行多次,因此使用时候需要保证这些生命周期钩子中执行的方法多次调用不会影响逻辑。 React团队提供了替换的生命周期方法。建议如果使用以上方法,尽量用纯函数。
简单地说,diff过程是
- 首次渲染时候构建一个和虚拟dom树一样结构的fiber树
- 组件更新时候,遍历新旧fiber树,diff区别,diff操作是分片进行,16ms内如果没完成,就先暂停等待下个渲染空闲时间再继续。
- diff完成之后进行commit,将变化提交,进行对应的dom操作,为防止界面抖动,commit是一次性完成的。
Suspense
Suspense原理
Suspense组件用来实现组件未准备好时候的loading。
某个组件如果是异步加载的(动态import),或者依赖一些业务数据,当异步代码未加载完成或者业务数据网络请求未到达时候,该组件应该渲染一个loading,等组件准备好后再替换掉loading。
整个过程大概是一个组件或者一个路由界面在准备好之前,先渲染loading,组件在内存中准备好后再渲染,这种处理方式看起来像是React可以在内存中并行地渲染,因此也是并发模式(concurrent mode)中的一部分。
看下面示例代码
import React, {Suspense} from 'react';
const AsyncComp = React.lazy(() => import('./comp'));
export default () => (
<div>
<Suspense fallback={<div>loading Comp</div>}>
<AsyncComp />
</Suspense>
</div>
);
当一个组件处于“挂起”状态时候,React 会显示出距其上游最近的
什么是“挂起”状态呢?Suspense类似ErrorBoundary,它规定其内部组件如果throw了一个promise,则是处于挂起状态,见下面示例,这个示例展示如何让一个组件告诉Suspense自己是否已经准备好可以渲染,它的场景是数据未准备好时候的渲染。【12】
import React, { Suspense, lazy } from 'react';
let data = '';
let promise = '';
function requestData() {
if (data) {
return data;
}
if (!promise) {
promise = new Promise(resolve => {
setTimeout(() => {
data = 'Data resolved';
resolve();
}, 2000);
});
}
throw promise;
}
function SuspenseComp() {
const data = requestData();
return <p>{data}</p>;
}
export default () => (
<Suspense fallback="loading data">
<SuspenseComp />
</Suspense>
);
如果一个组件是异步的(数据或者代码),可以通过suspense包裹它来实现异步更新渲染。
被包裹的组件首次渲染时候,数据/异步组件代码还未ready,所以会throw一个promise,Suspence会catch子组件throw的promise,然后将任务添加到更新队列中,React后面会异步处理更新队列,取出promise执行,当promise resolve之后,会再次渲染组件,这时候组件有了数据,不再throw promise,就会正常渲染。
React.lazy原理
React.lazy核心逻辑就是throw一个异步加载组件的promise,加载好后return这个组件(所以如果其外部不包裹Suspense,也没有ErrorBoundary的话,页面就会崩溃)。
React.lazy接收一个函数作为参数,这个函数需要返回一个thenable对象。React.lazy会先执行方法,得到异步加载组件的promise,然后throw这个promise。promise被Suspense捕获后进入异步处理队列,等组件加载好后,React.lazy就返回thenable对象resolve的对象的default属性,这个default属性就是异步加载的组件。
// 引用MyComponent
const AsyncComponent = React.lazy(() => import('./MyComponent'));
// MyComponent.js
...
export default class MyComponent extends React.Component {
...
}
这里我们用到了webpack的动态import,动态import会返回一个promise,组件加载完成后会resolve模块,使用ESM模块规范,resolve的模块的default属性就是组件MyComponent本身。
应用
目前只能在实验版本中使用concurrent模式。后续React18发布后会有支持。
目前尽量不要用于生产环境
这个版本主要针对功能早期使用者、库作者和对此好奇的人。
首先需要安装实验版本
npm install react@experimental react-dom@experimental
然后在创建应用时候使用”unstable_createRoot”:
import ReactDOM from 'react-dom';
// 如果你之前的代码是:
//
// ReactDOM.render(<App />, document.getElementById('root'));
//
// 你可以用下面的代码引入 concurrent 模式:
ReactDOM.unstable_createRoot(
document.getElementById('root')
).render(<App />);
Suspense在React16中已经支持,主要用在异步加载组件。