深入理解React中Suspense与lazy的原理

 更新时间:2024年04月22日 09:42:46   作者:Story  
在react中为我们提供了一个非常有用的组件,那就是Suspense,本文主要介绍了如何使用Suspense 和 react提供的lazy结合起来达到异步加载状态的目的,感兴趣的可以了解下

一、前面的话

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

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加持的情况下,也是可以异步渲染出组件的,如下所示

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的实现原理

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的实现

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的状态来采取不同的行为:

  • 如果是初始化状态 在这里它会执行用户传进来的函数,得到一个Promise,并且开始调用这个Promise,得到异步的结果,并且标记自己处于Pedning状态,然后抛出错误
  • 如果Resolved的状态那么就判断这个得到的值是否合法,合法就返回给调用者

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

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

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么?它是这样子的:

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,而重启的操作如下:

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发生了什么呢?

简化一下就是这样的

var ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
weakable.then(ping , ping)

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

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

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

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包裹着,因此逻辑会有不同

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树的时候,假设我们的组件是下面这样的

<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阶段的引擎函数

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,我依然用一个图来给大家总结一下

以上就是深入理解React中Suspense与lazy的原理的详细内容,更多关于React Suspense lazy的资料请关注脚本之家其它相关文章!

相关文章

  • React中使用外部样式的3种方式(小结)

    React中使用外部样式的3种方式(小结)

    这篇文章主要介绍了React中使用外部样式的3种方式(小结),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-05-05
  • Webpack3+React16代码分割的实现

    Webpack3+React16代码分割的实现

    这篇文章主要介绍了Webpack3+React16代码分割的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-03-03
  • React、Vue中key的作用详解 (key的内部原理解析)

    React、Vue中key的作用详解 (key的内部原理解析)

    key是虚拟DOM对象的标识,当状态中的数据发生变化时,Vue会根据[新数据]生成[新的虚拟DOM],本文给大家介绍React、Vue中key的作用详解 (key的内部原理解析),感兴趣的朋友一起看看吧
    2023-10-10
  • Vite搭建React项目的方法步骤

    Vite搭建React项目的方法步骤

    这篇文章主要介绍了Vite搭建React项目的方法步骤,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-04-04
  • React如何使用Portal实现跨层级DOM渲染

    React如何使用Portal实现跨层级DOM渲染

    Portal 就像是一个“传送门”,能让你把组件里的元素“传送到”其他 DOM 节点下面去渲染,下面小编就来和大家简单介绍一下具体的使用方法吧
    2025-04-04
  • React使用Ant Design方式(简单使用)

    React使用Ant Design方式(简单使用)

    文章介绍了AntDesign组件库,它是基于AntDesign设计体系的ReactUI组件库,主要用于研发企业级中后台产品,文章详细讲解了如何下载和按需引入antd组件库,并通过一个小案例展示了如何使用antd进行布局和改造,最后,文章提醒大家在使用过程中可以参考官网的属性介绍
    2024-11-11
  • React Antd中如何设置表单只输入数字

    React Antd中如何设置表单只输入数字

    这篇文章主要介绍了React Antd中如何设置表单只输入数字问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-06-06
  • React Native之TextInput组件解析示例

    React Native之TextInput组件解析示例

    本篇文章主要介绍了React Native之TextInput组件解析示例,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-08-08
  • 使用React制作一个贪吃蛇游戏的代码详解

    使用React制作一个贪吃蛇游戏的代码详解

    Snake Game 使用 ReactJS 项目实现功能组件并相应地管理状态,开发的游戏允许用户使用箭头键控制蛇或触摸屏幕上显示的按钮来收集食物并增长长度,本文给大家详细讲解了如何使用 React 制作一个贪吃蛇游戏,需要的朋友可以参考下
    2023-11-11
  • Unity RectTransform详解

    Unity RectTransform详解

    unity中的ui元素是有严格的父子关系的,子物体的位置是根据父物体的变化而变化的,而子物体和父物体联系的桥梁就是Anchor,本文重点介绍Unity RectTransform的相关知识,感兴趣的朋友一起看看吧
    2024-01-01

最新评论