【SSR系列】之 - React.Suspense 的巧妙设计

340 阅读8分钟
// app/dashbord/page.js
import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
 
export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  )
}

以上是 nextjs 官网的一个示例,是 v18 一个重要的优化,SSR 的选择性Hydration的示例代码, loading.js 也是这个原理。

在写 React 18 中 SSR 流式HTML之前,很有必要了解 Suspense 的设计,因为流式HTML 和选择性Hydration 中 Suspense 都扮演着重要的角色。在学习源码的过程中,发现很多巧妙的设计,日常敲代码也变得有趣起来,好像比拧螺丝稍微好一点点吧。

我们先用 SSR 流式HTML做个开场白,介绍一下为什么要放在SSR系列中,最终学完 Suspense 的目的是什么。首先抛一张对比图,左侧是传统 SSR 需要返回浏览器的 HTML,右侧是流式HTML 的状态。图来自 New Suspense SSR Architecture in React 18,官方。

image.png

说白了就是页面的多个模块都需要数据查询,查询时间太久直接导致返回浏览器的 HTML 等待时间变久,以至于还不如 CSR,至少背景像素点能早点看到🌚。。。原本 SSR 是为了防止 SPA 场景下js过大下载时间过长导致执行时间太晚,并且数据还要在 js 执行时才能发起,这两者造成的首屏渲染性能问题。但是如今只要出现一个模块的数据查询迟迟没结果,那么整个页面都要等待。

如图右侧,新版 Suspense SSR 架构可以做到不是特别重要的或者耗时太久的流式返回,查询过长的模块可以用 Suspense 组件包裹,并且在查询成功后再在同一个流中返回的包含数据 HTML 插入到正确的位置

Tips:已经返回的 loading DOM 怎么换成有数据的 DOM 呢?答案是:没魔法,插入 script 标签替换 DOM。

好了,我们大概了解了流式 HTML 中是通过包裹 Suspense 配合的,那么开始今天的主题,Suspense 和 lazy (官网),是如何相互配合的呢,也就是 Suspense 是如何确定 lazy 包裹的组件处于挂起还是正常状态的,这两种状态决定了渲染 children 还是 fallback。

开胃菜

一、基础用法

// 声明:dynamic import 懒加载
const Com = lazy(() =>
  import(/* webpackChunkName: "lazy_chunk" */ "./features/counter/counter.jsx")
);

// 消费:挂起状态(资源没请求到时) fallback 做后备
<Suspense fallback={<div>loading</div>}>
    <Com />
</Suspense>

这是动态加载 组件 的示例, 最早接触到 Suspense 的用法(官方文档),在工程化构建中会将请求的资源模块拆包,运行时懒加载来避免资源浪费,通常用在条件加载、按需加载场景中,如路由、权限、交互后才需要渲染的模块。

扩展阅读

二、基础进阶用法

如果开发环境想测试一下 pending 状态下 fallback 的效果怎么搞呢?👇🏻

// 声明
const Content = () => {
  return <div>我是迟到的 Content 组件</div>;
};

const LazyContent = lazy(
  () =>
    new Promise((resolve) => {
      setTimeout(() => {
        resolve({
          default: OtherContent,
        });
      }, 4000);
    })
);

// 消费
<Suspense fallback={<div>loading</div>}>
    <LazyContent />
</Suspense>

很明显,LazyContent 部分的 lazy 中的 load 函数 是和 Dynamic Import 不同的地方。React 官方文档中 lazy 参数 load 的介绍,一个返回 Promise 或另一个 thenable(具有 then 方法的类 Promise 对象)的函数。

三、进阶应用

假设现在有个场景(鄙人遇到过很多大屏、分析报表嵌入场景),加载 iframe,iframe 中的 url 是向后端请求包含签名的,在 url 返回之前 loading,成功获取到 url 后加载 iframe。需求是封装一个通用的组件,接收 渲染组件请求方法,可以嵌入 Suspense 中兜底,以满足多个类似场景的使用。👇🏻

// 声明
/**
 *  @param {正常渲染的组件} Component 
 *  @param {异步请求数据的函数} fetcher 
 */
const geneComponent = (Com, fetcher) => {
  return lazy(
    () =>
      new Promise((resolve) => {
        fetcher.then((data) => {
          resolve({
            default: props => {
              return <Com data={data} {...props} />;
            },
          });
        });
      })
  );
};

const $fetcher = () => new Promise(resolve => {
    setTimeout(() => {
        resolve('我是返回的数据 🥕');
    }, 3000);
});

const $Com = ({data}) => <div>{data}</div>;

// 消费
const App = () => {
    const LazyContent = geneComponent($Com, $fetcher);
    return <Suspense fallback={<div>loading</div>}>
        <LazyContent  />
    </Suspense>
}

四、升级用法

我们都知道可以和 Suspense 搭配的 api 不仅有 lazy,useDeferredValue、 canary 版本中的 use 等都可以配合。再来回顾上一个例子,如果不用 React 提供的 lazy,还可以怎样实现配合 Suspense 异步请求数据达到前挂起呢?有没有原生解法👇🏻

以下代码的重点在包装器 geneAsync

// 异步请求包装器   关键看这里
const geneAsync = (fetcher) => {
  // 闭包保存状态的变量
  const payload = {
    _result: fetcher,
    _status: "Uninitialized",
  };

  return () => {
    if (payload._status === "Uninitialized") {
      // 仅在初始化执行异步请求函数,并返回一个 promise | thenable 结构的数据
      var ctor = payload._result;
      var thenable = ctor();

      // 监听状态并保存到 payload 中
      thenable.then(
        function (moduleObject) {
          if (
            payload._status === "Pending" ||
            payload._status === "Uninitialized"
          ) {
            payload._status = "Resolved"; // 更新状态
            payload._result = moduleObject; // 赋值
          }
        },
        function (error) {}
      );

      if (payload._status === "Uninitialized") {
        payload._status = "Pending"; // 更新状态
        payload._result = thenable; // 保存 promise
      }
    }
    // 根据状态决定返回值
    if (payload._status === "Resolved") {
      const moduleObject = payload._result;
      return moduleObject;
    } else {
      throw payload._result;
    }
  };
};

// 请求函数
const fetcher = () =>
  new Promise((resolve) => {
    setTimeout(() => {
      resolve("我是迟到的数据 🥕");
    }, 4000);
  });

// 用包装器处理后,支持同步写法
const syncFetcher = geneAsync(fetcher);

// 同步写异步请求的看似平平无奇的组件
const LazyContent = () => {
  const data = syncFetcher();

  return <div>{data}</div>;
};

// 使用
<Suspense fallback={<div>loading</div>}>
    <LazyContent />
</Suspense>

有条件的可以测一下,注意当前只考虑了挂起状态到请求成功状态的切换。从 geneAsync 中的注释中再简单复述一下包装器做了哪些事情

  1. 声明了闭包存储状态的变量 payload
  2. 消费闭包的函数最终会在 LazyContent 中多次执行,这个后边在 Suspense 中会讲;
  3. 那么消费闭包的函数的任务是在数据请求不同状态下返回正确的数据,两个状态分别是 挂起状态 & 完成状态,挂起状态 throw thenable,完成状态返回 thenable 中的响应数据;
  4. 上述状态切换再加一个前提,初始化阶段执行一次请求函数 ctor

所以包装器到底做了什么呢,也就是在 pending 状态 throw thenable,resolved 状态正常返回数据,仅此而已。符合以上关键逻辑统统可以嵌入 <Suspense /> 中享受挂起状态的 fallback 兜底,再也不用 useState + setLoading 了🌝

image.png

原理篇

额,开胃菜第四条就是照着源码 lazy 实现学的,也就是说我们已经学到了一半原理✌🏻[自信]

其实想讲的内容都在开胃菜中了,虽然第四条只是配合 Suspense 的写法,但基本也可以猜到大致逻辑,父执行子的时候用 try catch 监听异常,子初始化抛出监听自己状态的 thenable 给父,父之后就可以在 resolved 状态时再次调用子

这就是阅读 Suspense 相关源码时我学到编程思想,至于 React 内部的具体实现,确实没上边说的那么简单,毕竟是大工程,代码的解耦,可复用与干净程度都需要考虑。感兴趣的可以一起看看lazy 和 Suspense 的配合,或者自己调试一下更方便。

📢: 源码部分只截取关键信息,完整细节可自行查看。

lazy

先来看看我们最熟悉的部分:

// react.development.js
var Uninitialized = -1;
var Pending = 0;
var Resolved = 1;
var Rejected = 2;

function lazyInitializer(payload) {
  if (payload._status === Uninitialized) {
    var ctor = payload._result;
    var thenable = ctor(); // Transition to the next state.
    
    thenable.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;

    return moduleObject.default;
  } else {
    throw payload._result;
  }
}

// 创建 lazy fiber,和开胃菜不同的是,payload 保存在了 fiber 中
function lazy(ctor) {
  var payload = {
    // We use these fields to store the result.
    _status: Uninitialized,
    _result: ctor
  };
  var lazyType = {
    $$typeof: REACT_LAZY_TYPE,
    _payload: payload,
    _init: lazyInitializer // 初始化执行器
  };

  // ...

  return lazyType;
}

是不是一毛一样,lazyInitializer 就是我们开胃菜中的包装器,只不过 payload 保存在了 fiber 中。下面我们看看什么时候调用的包装器,这其中要涉及到调和阶段的几个过程:

整个 Reconciler 过程包含 render 阶段和 commit 阶段,前者是整棵树的深度遍历,包含向下 diff 和向上的归并收集变更信息 effectList。我们只关注 render 阶段,这个阶段中以 workInProgress 为指针不断指向下一个 fiber,以 fiber 为单位进行调和,调度函数就是 workLoopConcurrent,每个单位的执行函数是 performUnitOfWork

这里我们要重点记一下 catch 中处理错误时调用了 handleError,会和后边的逻辑串起来。

// 从 root 开始调和
function renderRootConcurrent(root, lanes) {
  do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      // 记住这个处理函数
      handleError(root, thrownValue);
    }
  } while (true);
}
// 调和过程
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  var current = unitOfWork.alternate;
  var next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
}

下边的 beginWork$1 是很关键的部分,每执行一个任务,都包在 try...catch 中,细心的朋友应该还记得 lazy 中初始化时的 throw thenable,刚好可以在 catch 中捕获到。离闭环越来越近了,这也是为什么 lazy 中抛出错误,但 Suspense 组件中却没有找到 catch 相关逻辑的原因(感兴趣的可以想想为什么要放在 beginWork$1 中)。

beginWork$1 = function (current, unitOfWork, lanes) {
  try {
    return beginWork(current, unitOfWork, lanes);
  } catch (originalError) {
      //  || 后边的逻辑是判断是否是 thenable
    if (didSuspendOrErrorWhileHydratingDEV() || originalError !== null && typeof originalError === 'object' && typeof originalError.then === 'function') {
      throw originalError;
    }
    throw originalError;
  }
};

上述代码中 beginWork$1 分别处理 正常流程 beginWorkcatch error ,我们也分两个部分来看 lazy 相关的逻辑。以下代码同样是提取主要逻辑的片段,细节可以去看看源码。

beginWork

我们先来补充一下 lazy 链路,beginWork 中如何调用的 lazyInitializer 的 👇

// 不同 fiber 执行不同挂载函数
function beginWork(current, workInProgress, renderLanes) {
    switch (workInProgress.tag) {
        case LazyComponent:
          {
            var elementType = workInProgress.elementType;
            return mountLazyComponent(current, workInProgress, elementType, renderLanes);
          }
        // 下文 suspense 中会用到
        case SuspenseComponent:
          return updateSuspenseComponent(current, workInProgress, renderLanes);
         // case others 省略
    }
}
// 调用 init,即 lazyInitializer
function mountLazyComponent(_current, workInProgress, elementType, renderLanes) {
  resetSuspendedCurrentOnMountInLegacyMode(_current, workInProgress);
  var props = workInProgress.pendingProps;
  var lazyComponent = elementType;
  var payload = lazyComponent._payload;
  var init = lazyComponent._init;
  var Component = init(payload); // 调用上一步的初始化执行函数
  // ...
}

这部分比较简单,没有复杂逻辑,调用 lazyComponent._init

catch error

beginWork$1 中可以看出处理错误只是又抛出了一次错误,也就是说外层还有 catch 处理才对,这就要从上一步调和根节点的函数 renderRootConcurrent -> handleError 串回去了。那我们看看 handleError 汇总如何处理 lazy 中的异常 thenable 的👇🏻

function handleError(root, thrownValue) {
  throwException(root, erroredWork.return, erroredWork, thrownValue, workInProgressRootRenderLanes);
}

function throwException(root, returnFiber, sourceFiber, value, rootRenderLanes) {
  // 1. 打标签 Incomplete 表示异常未完成
  sourceFiber.flags |= Incomplete;
  if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
    var wakeable = value;
    resetSuspendedComponent(sourceFiber);
    // 寻找最近的 suspense
    var suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
    // ----------- Suspense 中串链路讲解------------
    if (suspenseBoundary !== null) {
      suspenseBoundary.flags &= ~ForceClientRender;
      // 2. 打标签:suspenseBoundary.flags |= ShouldCapture;
      markSuspenseBoundaryShouldCapture(suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes);
      // 3. 监听返回的 promise 状态变化
      attachRetryListener(suspenseBoundary, root, wakeable);
      return;
    }
  }
}

上述代码中 getNearestSuspenseBoundaryToCapture 是一个向上递归寻找最近 Suspense 的方法,至此我们建立了 lazy 与 Suspense 的连接,剩下的就是搞清楚 Suspense 如何根据 lazy 抛出的 thenable 来确定什么时候挂起渲染 fallback,什么时候再次调用 lazyInitializer 获取异步响应,这部分我们在后续的 Suspense 部分填充。

Suspense

这部分是与渲染最相关的部分,不过从 lazy 的行为已经可以猜到七七八八,这部分我们依然不会讲很细节的分支,只顺一下流程,从 Suspense 的 fiber 结构到 fallback 与 resolved 状态的切换,来把整个设计链路闭环一下。

  1. 初始化 Suspense 时,会走到以下逻辑中,此时还不知道 children 是否都处于 resolved 状态。(左 - fiber 新增 offscreen 右 - 源码逻辑)

image.png

  1. 当 lazy 中报错时,找到最近的 Suspense,这时候将状态更新为 fallback。👇🏻

lazy 结尾时讲到 handleError 中还会做这两件事,打标签然后添加监听

    // 未完成
    sourceFiber.flags |= Incomplete;
 // ----------- Suspense 中串链路讲解------------
    if (suspenseBoundary !== null) {
      suspenseBoundary.flags &= ~ForceClientRender;
      // 1. 打标签:suspenseBoundary.flags |= ShouldCapture;
      markSuspenseBoundaryShouldCapture(suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes);
      // 2. 将 pendding 的 promise 挂到 suspense 的 updateQueue 上,监听返回的 promise 状态变化
      attachRetryListener(suspenseBoundary, root, wakeable);
      return;
    }
  1. 那么接着开始向上归并 completeUnitOfWork,找到 SuspenseComponent 组件,由于之前打过 ShouldCapture 的标签,这时会改为 DidCapture状态,这时意味着捕获到错误,需要展示 fallback 备用组件了,那么会变成下边这样:

image.png

  1. attachRetryListener 中将 pendding 的 promise 挂到 suspense 的 updateQueue 后,当捕获的 thenable 监听到状态更新为 resolved 时,再一次更新视图执行 updateSuspenseComponent,删除 fallback,渲染真正的子组件。就此整个渲染链路完毕,感兴趣的可以查看 updateSuspenseComponent

好了,完结撒花~

总结

try...catch 是执行单个小任务(尤其是框架层执行不受信任的业务代码时)必然做得兜底,

阅读源码的态度:不要陷入细节中,设计更重要。明显的感知是组件的设计上用到很多骚操作,复用性更好、可读性增强、更加优雅,不至于写出屎山。最关键的是写干净的代码不容易出 bug,因为有原理加持,非条件反射避开掉坑写法,就不用一天浪费在一个 bug 上,最终只知道不能怎样用,下次写个变种代码又进 bug 了。。。命都是耗 bug 上的。

顺便提一嘴,这篇文章是两个月前写的草稿,缺个结尾一直没发,因为当时准备找工作就拖了下来,后边遇到各种面完被鸽的情况,比如提交完流水offer也申请中,最后整个公司杭州 hc 都没掉的奇葩经历,上个月阴差阳错被介绍到一家公司重新开启上班生活。因为项目使用了 Nextjs,但是很多原因不得不重构,也借此将 nextjs 进行了更深入的学习。最近也会抽出时间写一篇 Nextjs 架构和实践相结合笔记,希望减少大家的学习成本(在 ai 取代我们之前)。

同时还有一篇草稿箱文章,是之前 node 写服务端时的笔记,关于流程设计的场景,可能下一篇会等到叭。