# 一前言
上节主要讲了 React 对组件渲染的控制方法以及原理,本章节将继续围绕 React 渲染话题,谈一谈整个 React 渲染过程中细节问题怎么解决。
通过本章节,你将学会 Suspense 用法和原理,React.lazy 用法和配合 Suspense 实现代码分割,渲染错误边界、渲染异常的处理手段, 以及 diff 流程以及 key 的合理使用。
# 二懒加载和异步渲染
# 异步渲染
Suspense 是 React 提出的一种同步的代码来实现异步操作的方案。Suspense 让组件‘等待’异步操作,异步请求结束后在进行组件的渲染,也就是所谓的异步渲染,但是这个功能目前还在实验阶段,相信不久这种异步渲染的方式就能和大家见面了。
Suspense 用法
Suspense 是组件,有一个 fallback 属性,用来代替当 Suspense 处于 loading 状态下渲染的内容,Suspense 的 children 就是异步组件。多个异步组件可以用 Suspense 嵌套使用。
我写了一个异步渲染的例子如下。
// 子组件
function UserInfo() {
// 获取用户数据信息,然后再渲染组件。
const user = getUserInfo();
return <h1>{user.name}</h1>;
}
// 父组件
export default function Index(){
return <Suspense fallback={<h1>Loading...</h1>}>
<UserInfo/>
</Suspense>
}
- Suspense 包裹异步渲染组件 UserInfo ,当 UserInfo 处于数据加载状态下,展示 Suspense 中 fallback 的内容。
如上所示,异步渲染的 UserInfo 组件可以直接通过 getUserInfo 请求数据,直接用数据 user 进行渲染,很显然现在是做不到的。现在的异步请求方式比较繁琐,主要是是通过类组件 componentDidMount 或者函数组件 useEffect 进行数据交互,获得数据后通过调用 setState 或 useState 改变 state 触发视图的更新。
传统模式:挂载组件-> 请求数据 -> 再渲染组件。 异步模式:请求数据-> 渲染组件。
那么异步渲染相比传统数据交互相比好处就是:
- 不再需要 componentDidMount 或 useEffect 配合做数据交互,也不会因为数据交互后,改变 state 而产生的二次更新作用。
- 代码逻辑更简单,清晰。
# 动态加载(懒加载)
现在的 Suspense 配合 React.lazy 可以实现动态加载功能。
React.lazy
const LazyComponent = React.lazy(()=>import('./text'))
React.lazy 接受一个函数,这个函数需要动态调用 import() 。它必须返回一个 Promise ,该 Promise 需要 resolve 一个 default export 的 React 组件。
先来看一下基本使用:
const LazyComponent = React.lazy(() => import('./test.js'))
export default function Index(){
return <Suspense fallback={<div>loading...</div>} >
<LazyComponent />
</Suspense>
}
- 用 React.lazy 动态引入 test.js 里面的组件,配合 Suspense 实现动态加载组件效果。这样很利于代码分割,不会让初始化的时候加载大量的文件。
原理揭秘: React.lazy和Suspense实现动态加载原理
整个 render 过程都是同步执行一气呵成的,但是在 Suspense 异步组件情况下允许调用 Render => 发现异步请求 => 悬停,等待异步请求完毕 => 再次渲染展示数据。
那么整个流程是如何实现的,逐步分析一下:
Suspense原理:
Suspense 在执行内部可以通过 try{}catch{} 方式捕获异常,这个异常通常是一个 Promise ,可以在这个 Promise 中进行数据请求工作,Suspense 内部会处理这个 Promise ,Promise 结束后,Suspense 会再一次重新 render 把数据渲染出来,达到异步渲染的效果。

React.lazy原理:
再看一下 React.lazy,lazy 内部模拟一个 promiseA 规范场景。完全可以理解 React.lazy 用 Promise 模拟了一个请求数据的过程,但是请求的结果不是数据,而是一个动态的组件。下一次渲染就直接渲染这个组件,所以是 React.lazy 利用 Suspense 接收 Promise ,执行 Promise ,然后再渲染这个特性做到动态加载的。说到这可能有很多同学不明白什么意思,不要紧,接下来通过以下代码加深一下对 lazy + susponse 的理解。
react/src/ReactLazy.js
function lazy(ctor){
return {
$$typeof: REACT_LAZY_TYPE,
_payload:{
_status: -1, //初始化状态
_result: ctor,
},
_init:function(payload){
if(payload._status===-1){ /* 第一次执行会走这里 */
const ctor = payload._result;
const thenable = ctor();
payload._status = Pending;
payload._result = thenable;
thenable.then((moduleObject)=>{
const defaultExport = moduleObject.default;
resolved._status = Resolved; // 1 成功状态
resolved._result = defaultExport;/* defaultExport 为我们动态加载的组件本身 */
})
}
if(payload._status === Resolved){ // 成功状态
return payload._result;
}
else { //第一次会抛出Promise异常给Suspense
throw payload._result;
}
}
}
}
整个流程是这样的,React.lazy 包裹的组件会标记 REACT_LAZY_TYPE 类型的 element,在调和阶段会变成 LazyComponent 类型的 fiber ,React 对 LazyComponent 会有单独的处理逻辑:
- 第一次渲染首先会执行 init 方法,里面会执行 lazy 的第一个函数,得到一个Promise,绑定 Promise.then 成功回调,回调里得到将要渲染组件
defaultExport,这里要注意的是,如上面的函数当第二个 if 判断的时候,因为此时状态不是 Resolved ,所以会走 else ,抛出异常 Promise,抛出异常会让当前渲染终止。 - 这个异常 Promise 会被 Suspense 捕获到,Suspense 会处理 Promise ,Promise 执行成功回调得到 defaultExport(将想要渲染组件),然后 Susponse 发起第二次渲染,第二次 init 方法已经是 Resolved 成功状态,那么直接返回 result 也就是真正渲染的组件。这时候就可以正常渲染组件了。

# 三 渲染错误边界
React 组件渲染过程如果有一个环节出现问题,就会导致整个组件渲染失败,那么整个组件的 UI 层都会显示不出来,这样造成的危害是巨大的,如果越靠近 APP 应用的根组件,渲染过程中出现问题造成的影响就越大,有可能直接造成白屏的情况。
比如如下例子
function ErrorTest(){
return
}
function Test(){
return <div>let us learn React!</div>
}
class Index extends React.Component{
componentDidCatch(...arg){
console.log(arg)
}
render(){
return <div>
<ErrorTest />
<div> hello, my name is alien! </div>
<Test />
</div>
}
}
- 造成错误,由于 ErrorTest 不是一个真正的组件但是却用来渲染,结果会造成整个 Index 组件渲染异常,Test 也会受到牵连,UI 都不能正常显示。
为了防止如上的渲染异常情况 React 增加了 componentDidCatch 和 static getDerivedStateFromError() 两个额外的生命周期,去挽救由于渲染阶段出现问题造成 UI 界面无法显示的情况。
# componentDidCatch
componentDidCatch 可以捕获异常,它接受两个参数:
- 1 error —— 抛出的错误。
- 2 info —— 带有 componentStack key 的对象,其中包含有关组件引发错误的栈信息。
先来打印一下,生命周期 componentDidCatch 参数长什么样子?

那么 componentDidCatch 中可以再次触发 setState,来降级UI渲染,componentDidCatch() 会在commit阶段被调用,因此允许执行副作用。
class Index extends React.Component{
state={
hasError:false
}
componentDidCatch(...arg){
uploadErrorLog(arg) /* 上传错误日志 */
this.setState({ /* 降级UI */
hasError:true
})
}
render(){
const { hasError } =this.state
return <div>
{ hasError ? <div>组件出现错误</div> : <ErrorTest /> }
<div> hello, my name is alien! </div>
<Test />
</div>
}
}
效果:
componentDidCatch 作用:
- 可以调用 setState 促使组件渲染,并做一些错误拦截功能。
- 监控组件,发生错误,上报错误日志。
# static getDerivedStateFromError
React更期望用 getDerivedStateFromError 代替 componentDidCatch 用于处理渲染异常的情况。getDerivedStateFromError 是静态方法,内部不能调用 setState。getDerivedStateFromError 返回的值可以合并到 state,作为渲染使用。用 getDerivedStateFromError 解决如上的情况。
class Index extends React.Component{
state={
hasError:false
}
static getDerivedStateFromError(){
return { hasError:true }
}
render(){
/* 如上 */
}
}
如上完美解决了 ErrorTest 错误的问题。注意事项: 如果存在 getDerivedStateFromError 生命周期钩子,那么将不需要 componentDidCatch 生命周期再降级 ui。
# 四 从diff children看key的合理使用
上述内容讲了异步渲染和渲染错误边界,都是对一些特殊情况下渲染的处理。上章节讲到,大部分优化环节 React 都自己在内部处理了。但是有一种情况也值得开发者注意,那就是列表中 key 的使用。合理的使用 key 有助于能精准的找到用于新节点复用的老节点。 React 是如何 diff children 的呢。
我这里为了方便大家了解流程,就不放过多源码了,我用如下几个案例来描述 React diffChild 核心流程。之前做过一期 vue3.0 diff算法的文章,实际在处理手法上还是有一些相似之处的。首先 React 在一次更新中当发现通过 render 得到的 children 如果是一个数组的话。就会调用 reconcileChildrenArray 来调和子代 fiber ,整个对比的流程就是在这个函数中进行的。
# diff children流程
第一步:遍历新 children ,复用 oldFiber
react-reconciler/src/ReactChildFiber.js
function reconcileChildrenArray(){
/* 第一步 */
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],expirationTime,);
if (newFiber === null) { break }
// ..一些其他逻辑
}
if (shouldTrackSideEffects) { // shouldTrackSideEffects 为更新流程。
if (oldFiber && newFiber.alternate === null) { /* 找到了与新节点对应的fiber,但是不能复用,那么直接删除老节点 */
deleteChild(returnFiber, oldFiber);
}
}
}
}
- 第一步对于 React.createElement 产生新的 child 组成的数组,首先会遍历数组,因为 fiber 对于同一级兄弟节点是用 sibling 指针指向,所以在遍历children 遍历,sibling 指针同时移动,找到与 child 对应的 oldFiber 。
- 然后通过调用 updateSlot ,updateSlot 内部会判断当前的 tag 和 key 是否匹配,如果匹配复用老 fiber 形成新的 fiber ,如果不匹配,返回 null ,此时 newFiber 等于 null 。
- 如果是处于更新流程,找到与新节点对应的老 fiber ,但是不能复用
alternate === null,那么会删除老 fiber 。
第二步:统一删除oldfiber
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
- 第二步适用于以下情况,当第一步结束完
newIdx === newChildren.length此时证明所有 newChild 已经全部被遍历完,那么剩下没有遍历 oldFiber 也就没有用了,那么调用 deleteRemainingChildren 统一删除剩余 oldFiber 。
情况一:节点删除
- oldChild: A B C D
- newChild: A B
A , B 经过第一步遍历复制完成,那么 newChild 遍历完成,此时 C D 已经没有用了,那么统一删除 C D。
第三步:统一创建newFiber
if(oldFiber === null){
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber,newChildren[newIdx],expirationTime,)
// ...
}
}
- 第三步适合如下的情况,当经历过第一步,oldFiber 为 null , 证明 oldFiber 复用完毕,那么如果还有新的 children ,说明都是新的元素,只需要调用 createChild 创建新的 fiber 。
情况二:节点增加
- oldChild: A B
- newChild: A B C D
A B 经过第一步遍历复制完,oldFiber 没有可以复用的了,那么直接创建 C D。
第四步:针对发生移动和更复杂的情况
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren,returnFiber)
/* 从mapRemainingChildren删掉已经复用oldFiber */
}
- mapRemainingChildren 返回一个 map ,map 里存放剩余的老的 fiber 和对应的 key (或 index )的映射关系。
- 接下来遍历剩下没有处理的 Children ,通过 updateFromMap ,判断 mapRemainingChildren 中有没有可以复用 oldFiber ,如果有,那么复用,如果没有,新创建一个 newFiber 。
- 复用的 oldFiber 会从 mapRemainingChildren 删掉。
情况三:节点位置改变
- oldChild: A B C D
- newChild: A B D C
如上 A B 在第一步被有效复用,第二步和第三步不符合,直接进行第四步,C D 被完全复用,existingChildren 为空。
第五步:删除剩余没有复用的oldFiber
if (shouldTrackSideEffects) {
/* 移除没有复用到的oldFiber */
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
最后一步,对于没有复用的 oldFiber ,统一删除处理。
情况四:复杂情况(删除 + 新增 + 移动)
- oldChild: A B C D
- newChild: A E D B
首先 A 节点,在第一步被复用,接下来直接到第四步,遍历 newChild ,E被创建,D B 从 existingChildren 中被复用,existingChildren 还剩一个 C 在第五步会删除 C ,完成整个流程。
# 关于diffChild思考和key的使用
- 1 React diffChild 时间复杂度 O(n^3) 优化到 O(n)。
- 2 React key 最好选择唯一性的id,如上述流程,如果选择 Index 作为 key ,如果元素发生移动,那么从移动节点开始,接下来的 fiber 都不能做得到合理的复用。 index 拼接其他字段也会造成相同的效果。
# 五实践 - React.lazy + Susponse模拟异步组件功能
接下来 React.lazy + Susponse 来完全模拟实现一个异步组件。
实现效果:
- 异步组件要实现的功能,异步请求数据,请求完数据再挂载组件。没有加载完数据显示 loading 效果。
- 可量化生产。
主要思路:
- 可以使用 React.lazy 实现动态加载,那么可以先请求数据,然后再加载组件,这时候以 props 形式将数据传递给目标组件,实现异步效果。
编写:
/**
*
* @param {*} Component 需要异步数据的component
* @param {*} api 请求数据接口,返回Promise,可以再then中获取与后端交互的数据
* @returns
*/
function AysncComponent(Component,api){
const AysncComponentPromise = () => new Promise(async (resolve)=>{
const data = await api()
resolve({
default: (props) => <Component rdata={data} { ...props} />
})
})
return React.lazy(AysncComponentPromise)
}
思路:
- 用 AysncComponent 作为一个 HOC 包装组件,接受两个参数,第一个参数为当前组件,第二个参数为请求数据的 api 。
- 声明一个函数给 React.lazy 作为回调函数,React.lazy 要求这个函数必须是返回一个 Promise 。在 Promise 里面通过调用 api 请求数据,然后根据返回来的数据 rdata 渲染组件,别忘了接受并传递 props 。
使用:
/* 数据模拟 */
const getData=()=>{
return new Promise((resolve)=>{
//模拟异步
setTimeout(() => {
resolve({
name:'alien',
say:'let us learn React!'
})
}, 1000)
})
}
/* 测试异步组件 */
function Test({ rdata , age}){
const { name , say } = rdata
console.log('组件渲染')
return <div>
<div> hello , my name is { name } </div>
<div>age : { age } </div>
<div> i want to say { say } </div>
</div>
}
/* 父组件 */
export default class Index extends React.Component{
LazyTest = AysncComponent(Test,getData) /* 需要每一次在组件内部声明,保证每次父组件挂载,都会重新请求数据 ,防止内存泄漏。 */
render(){
const { LazyTest } = this
return <div>
<Suspense fallback={<div>loading...</div>} >
<LazyTest age={18} />
</Suspense>
</div>
}
}
效果:

- 如上 name 和 say 都是数据交互获取的数据。
- 组件只渲染了一次,实现了异步渲染的效果。
总结:
这个demo仅供大家参考,加深以下对异步组件和 HOC 的理解,但是这种在真实的开发场景也会遇到一些问题。
- 1 需要约定好接受数据格式rdata和数据交互形式api。
- 2 因为数据本质是用闭包缓存的,所以绑定需要在在组件内部,这样才能保证每次父组件挂载,都会重新请求数据,另外也防止内存泄漏情况发生。
- 3 数据源更新维护困难。
# 六总结
这节主要讲了 React 未来版本的异步组件,React.lazy + Susponse 动态加载原理,渲染的错误边界及其处理,diff 性能调优,以及用一个实践 demo ,lazy + susponse 模拟实现了异步组件。
← 优化篇 渲染控制 优化篇 处理海量数据 →