优化篇-渲染调优
一前言
上节主要讲了 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 模拟实现了异步组件。