# 一前言

上节主要讲了 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 把数据渲染出来,达到异步渲染的效果。

5.jpg

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 也就是真正渲染的组件。这时候就可以正常渲染组件了。

4.jpg

# 三 渲染错误边界

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 增加了 componentDidCatchstatic getDerivedStateFromError() 两个额外的生命周期,去挽救由于渲染阶段出现问题造成 UI 界面无法显示的情况。

# componentDidCatch

componentDidCatch 可以捕获异常,它接受两个参数:

  • 1 error —— 抛出的错误。
  • 2 info —— 带有 componentStack key 的对象,其中包含有关组件引发错误的栈信息。

先来打印一下,生命周期 componentDidCatch 参数长什么样子?

2.jpg

那么 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>
   }
}

效果:

3.jpg 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 模拟实现了异步组件。

阅读全文