《深入理解react》之Suspense与lazy原理

一、前面的话

在react中为我们提供了一个非常有用的组件,那就是<Suspense/>,他可以包裹一个异步组件,当这个异步组件处于pending状态的时候会展示一个过渡的UI,当异步组件处于resolved状态的时候会显示真正的UI,我们来看一下如何使用Suspense 和 react提供的lazy结合起来达到异步加载状态的目的

JSX 复制代码
import  { lazy , Suspense } from 'react';

const LazyComponent = React.lazy(() => import('./xxx'));

export default function App() {
  return (
    <Suspense fallback={<span>loading...</span>}>
      <LazyComponent/>
    </Suspense>
  )
}

它的效果如下:

接下来我们就来一步一步看一下这究竟是怎么做到这一点的!

二、lazy懒加载组件

要先从lazy这个api开始说起,根据上面的内容,LazyComponent是由lazy这个调用返回的结果,它能够被直接渲染,在没有Suspense加持的情况下,也是可以异步渲染出组件的,如下所示

js 复制代码
const LazyComponent = React.lazy(() => import('./LazyComponent.js'));

const FunctionComponent = () => {
    const [count, setCount] = React.useState(1);

    const onClick = () => {
      setCount(count + 1);
    };


    return (
      <div>
        <button onClick={onClick}>{ count }</button>
        <LazyComponent/> 
      </div>
    );
};

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<FunctionComponent />);

效果如下:

我们看一下lazy的实现原理

js 复制代码
function lazy(ctor) {
    var payload = { // 创建一个payload
      _status: Uninitialized, // -1
      _result: ctor, // ctor 就是用户传递的哪个()=> import("xxxxx") 实际上等价于 ()=> Promise<any>
    };
    var lazyType = { // 这是一个REACT_LAZY_TYPE类型的ReactElement
      $$typeof: REACT_LAZY_TYPE,
      _payload: payload,
      _init: lazyInitializer, // 下面分析一下lazyInitializer
    };
    // 下面是给lazyType做属性的配置,不重要了解即可
    {
      Object.defineProperties(lazyType, {
        defaultProps: {
          configurable: true,
          get: function () {...},
          set: function (newDefaultProps) { ...},
        },
        propTypes: {
          configurable: true,
          get: function () {... },
          set: function (newPropTypes) {...}
        }
      });
    }

    return lazyType;
}

根据我提供的注释我们可以看到,其实lazy就是返回了一个REACT_LAZY_TYPE类型的ReactElement节点,并且用一个状态机记录了当前的这个节点处于什么样的状态,引用者传进来的函数引用

这里要重点分析一下()=> import('xxxx')import('xxx')是ES6提供的一种异步加载模块的方式,他会返回一个Promise,因此可以使用.then获取异步加载所得到的数据

接下来我们看一下lazyInitializer的实现

js 复制代码
function lazyInitializer(payload) {
    if (payload._status === Uninitialized) { // 如果是初始化状态
      var ctor = payload._result; // ()=> import('xxx')
      var thenable = ctor(); // 得到一个Promise
      thenable.then( // 调用.then
        function (moduleObject) {
          if (
            payload._status === Pending ||
            payload._status === Uninitialized
          ) { // 标记成功
            var resolved = payload;
            resolved._status = Resolved;
            resolved._result = moduleObject;
          }
        },
        function (error) {
          if ( // 标记失败
            payload._status === Pending ||
            payload._status === Uninitialized
          ) {
            var rejected = payload;
            rejected._status = Rejected;
            rejected._result = error;
          }
        }
      );

      if (payload._status === Uninitialized) {// 如果是初始化
        var pending = payload;
        pending._status = Pending; // 标记正在进行
        pending._result = thenable;
      }
    }

    if (payload._status === Resolved) { // 如果不是初始化
      var moduleObject = payload._result;
      if (moduleObject === undefined) {
         报错
      }
      if (!("default" in moduleObject)) {
        报错
      }
      return moduleObject.default;
    } else {
      // 初始化都会进入到这里
      throw payload._result; // 抛出错误
    }
}

经过分析我们会发现lazyInitializer会根据payload的状态来采取不同的行为:

  1. 如果是初始化状态 在这里它会执行用户传进来的函数,得到一个Promise,并且开始调用这个Promise,得到异步的结果,并且标记自己处于Pedning状态,然后抛出错误

  2. 如果Resolved的状态那么就判断这个得到的值是否合法,合法就返回给调用者

但不用担心,此时我们分析了这个函数如果执行的话,直到现在用户只是调用了lazy,这个函数还没到执行的时候,现在用户仅仅只是得到了一个lazy类型的ReactElement类型的节点

而真正让这个函数执行得地方还是得在render阶段,当调和到lazy类型的节点的时候,会执行mountLazyComponent

js 复制代码
function mountLazyComponent(
    _current,
    workInProgress,
    elementType,
    renderLanes
  ) {
    var props = workInProgress.pendingProps; // lazy的组件一般没有props
    var lazyComponent = elementType; // ReactElement
    var payload = lazyComponent._payload; // 这就是上面的payload
    var init = lazyComponent._init; // 获取那个init函数,就是我们上面分析的那个
    var Component = init(payload); // 调用它,第一次会抛出错误
    //芭比Q,下面不用看了
    ...
    
}

根据我们上面的分析,在调用lazyInitializer函数的时候,如果是第一次调用,会进入第一种情况,状态还是初始化的状态,因此会执行异步函数,得到一个正在调用的Promise,然后会调用.then获取它的结果,然后将其保存在payload中,然后将状态置为Pending,最后抛出错误,所以后面的逻辑都不用看了,第一次在这里会抛出错误,阻塞后面的代码,整个render阶段被迫提前结束

如果提前结束了render阶段,那么后面该如何运行呢?

原来当lazy类型的render过程中,准确的来说应该是beginWork中因为第一次执行init函数导致抛出错误,阻塞了后面的过程,react会提前结束beginWork环节,然后react会捕获这个错误,还记得那个workLoop么?它是这样子的:

js 复制代码
do {
  try {
    workLoopSync(); // 当这里抛出错误时
    break;
  } catch (thrownValue) {
    handleError(root, thrownValue); // 会来到这里
  }
} while (true);

因此实际上react并不会因为抛出了这个错误就完蛋了,甚至这个错误是刻意抛出的,为的就是在handleError中捕获它,然后做不同的逻辑处理

handleError中会基于抛出错误的节点开始提前进入completeWork,然后将整棵树标记为未完成 的状态,最后因为上层函数拿到这个是否调和完整棵树的状态,决定是否进行commit流程

结果就是这棵树没有完成,因此不会进行commit阶段,第一次render因为lazy类型组件的存在就这样匆匆结束了

那现相信大家和我有同样的问题,那react是怎么重启render的呢? ,因为在平常开发中lazy组件也是可以渲染出组件的呀,所以一定有一个重启render的过程才能做到。

原来在handleError的过程中有一个这样的过程,如果发现了抛出错误的参数是一个Promise的话,就会认定他是一个懒加载的情况,然后做出重启的操作,正巧我们init抛出错误的信息刚好是一个Promise,而重启的操作如下:

js 复制代码
function throwException(value){// 这个value就是错误信息
  ...
  if (
      value !== null &&
      typeof value === "object" &&
      typeof value.then === "function" // 如果是一个Promise
    ) {
      var wakeable = value; //
      var suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // 如有上层有Suspense包裹的话,这里先不谈
      if (suspenseBoundary !== null) {
        ...
      } else{
        attachPingListener(root, wakeable, rootRenderLanes); // 这里就是关键了,它会监听这个Promise的情况
      }
}

那么attachPingListener发生了什么呢?

js 复制代码
简化一下就是这样的
var ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
weakable.then(ping , ping)

看到了吗,如果这个Promise的状态一旦从Pending状态变成其他状态,就会执行这个pingSuspendedRoot,它里面就藏着重新发起调度的ensureRootIsScheduled逻辑,然后会把更新流程重走一遍,从rendercommit,最终就呈现出了UI。

这里需要注意的一点就是当重启的这一次render阶段其实也会遇到lazy类型的节点,那它还会抛出错误吗?

其实是不会的,因为这一次来到lazy节点时,执行的init函数会发现状态已经被修改为Resolved的状态了, 会直接返回结果,然后返回的结果通常来说是一个组件,就是异步加载的组件,把它作为子组件再继续构建fiber树

js 复制代码
function mountLazyComponent(
    _current,
    workInProgress,
    elementType,
    renderLanes
  ) {
    var props = workInProgress.pendingProps; // lazy的组件一般没有props
    var lazyComponent = elementType; // ReactElement
    var payload = lazyComponent._payload; // 这就是上面的payload
    var init = lazyComponent._init; // 获取那个init函数,就是我们上面分析的那个
    var Component = init(payload); // 这一次调用直接获取到值,而不会抛出错误,往下调和异步组件
    
    workInProgress.type = Component;
    var resolvedTag = (workInProgress.tag = resolveLazyComponentTag(Component)); // 获取对应的fiber类型
    var resolvedProps = resolveDefaultProps(Component, props);
    var child;

    switch (resolvedTag) {
      case FunctionComponent: {
        ...
        child = updateFunctionComponent( // 继续调和
          null,
          workInProgress,
          Component,
          resolvedProps,
          renderLanes
        );
        return child;
      }
      ...
    }
    
}

至此lazy类型的组件原理我们就分析完了,它其实利用的是react强大的异常捕获机制,以及Promise灵敏的状态机来实现的,我画个图给大家总结一下

三、Suspense原理

当我们分析了上面的lazy类型的组件之后Suspense就很好学习了

Suspense本质上就是一个ReactElement类型的对象,没啥好说的;关键要看在render阶段react如何处理这种类型的fiber组件的,下面一起来看一下

初始化

在初始化时仅仅只是创建了fiber,然后继续调和子组件,由于他的组件就是lazy类型的组件,因此还是回到上面的逻辑,lazy组件会抛错啊,因此第一次render阶段终止了,但是在handleError处理错误的时候,因为它被Suspense包裹着,因此逻辑会有不同

js 复制代码
function throwException(value){ // 这个value就是错误信息
  ...
  if (
      value !== null &&
      typeof value === "object" &&
      typeof value.then === "function" // 如果是一个Promise
    ) {
      var wakeable = value; //
      var suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // 如有上层有Suspense包裹的话,这里会判定有,实际上就是遍历祖先节点,看是否有Suspense类型的fiber
      if (suspenseBoundary !== null) {
        suspenseBoundary.flags &= ~ForceClientRender;
        markSuspenseBoundaryShouldCapture( // 打标签应该被捕获
          suspenseBoundary,
          returnFiber,
          sourceFiber,
          root,
          rootRenderLanes
        ); 
        attachPingListener(root, wakeable, rootRenderLanes); // 监听重启
      } else{
        ...
      }
}

实际上这个逻辑和lazy还是一样的,就是监听Promise的状态,在Promise有结果的时候再重启一次render,这一点是一致的,通过这个机制可以确保当异步组件加载完成后react运行时能够知道在此时更新页面,呈现出最新的UI

但是我们知道从效果上来看,在有Suspense包裹的时候,在异步组件加载过程中应该会立马展示一个过渡UI,也就是fallback对应的参数,而需要做到这一点需要发起一次调度啊,也就是说需要经历一个render+commit才能做到啊

过渡fiber节点

原来这一切的一切在第一次render的时候就有准备了,在第一次构建fiber树的时候,假设我们的组件是下面这样的

js 复制代码
<Suspense fallback={...}>
  <Lazy/>
</Suspense>

那么实际上在构建fiber树的时候会有这样的fiber结构

因此它并不是每个组件对应一个fiber节点,Suspense对应的实际上是有2个fiber节点,当我们知道这一点之后,当做了监听完的动作之后,我们再回到外层看一下,会执行一个completeUnitOfWork的动作,这个动作实际上在上面我们讲到的只有lazy的情况也会执行,只不过在只有lazy组件的时候它会一直调和到root节点,导致workInProgressnull,而在有Suspense会表现的有所不同

因为这是由于出现了异常导致的completeUnitOfWork,因此不会走正常的completeWork,而是走unwindWork(current, completedWork);

unwindWork向上归并的时候,如果遇到有Suspense节点的情况会保留这个Suspense节点的信息,实际上就是不会一直往上走到root节点,而是将workInProgress指向这个Suspense的fiber节点,然后就退出completeWork的流程,然后我们再来看一下render阶段的引擎函数

js 复制代码
do {
  try {
    workLoopSync(); // 这里面需要workInProgress有值才能正常运行
    break;
  } catch (thrownValue) {
    handleError(root, thrownValue);
    // 结束后,还是会执行
  }
} while (true);

handleError结束后还会继续接着render,在上面提到的只有lazy组件的情况下,因为workInProgress不存在所以直接break退出了render流程,而在Suspense组件存在的情况下,会继续从这个Suspense开始继续render

这一次render就会直接调和fallback的内容,这一次根本就不会遇到lazy类型的组件了,直到整棵fiber树调和完成,然后接着正常进行commit流程,所以用户看到的就是带有fallback的UI界面

等到异步组件重新加载完成后,会重新执行一次render + commit 构建出含有异步组件的界面

小结: 以上就是Suspense,主要是react在拥有Suspense类型的组件的过程中做了处理,使其多了一次默认的render + commit的流程,从而使用户能够看到含有过渡状态的UI,我依然用一个图来给大家总结一下

四、最后的话

本篇文章中,我们从源码的角度分析了Suspenselazy组件的原理,并且梳理了它们的流程,接下来我们继续学习react的新特性,吃透react。

后面的文章我们会依然会深入剖析react的源码,学习react的设计思想,如果你也对react相关技术感兴趣请订阅我的《深入理解react》专栏,笔者争取至少月更一篇,我们一起进步,有帮助的话希望朋友点个赞支持下,多谢多谢!

相关推荐
轻口味1 小时前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王2 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发2 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀2 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪3 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef4 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6414 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻5 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云5 小时前
npm淘宝镜像
前端·npm·node.js