React Suspense 与 try…catch

一.UI 层的 try…catch

先抛出结论,Suspense 就像是 try…catch,决定 UI 是否安全

try {
  // 一旦有没ready的东西
} catch {
  // 立即进入catch块,走fallback
}

那么,如何定义安全?

试想,如果一个组件的代码还没加载完,就去渲染它,显然是不安全的。所以,姑且狭义地认为组件代码已就绪的组件就是安全的,包括同步组件和已加载完的异步组件(React.lazy),例如:

// 同步组件,安全
import OtherComponent from './OtherComponent';
// 异步组件,不安全
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
// ...等到AnotherComponent代码加载完成之后
// 已加载完的异步组件,安全
AnotherComponent

Error Boundary

有个类似的东西是Error Boundary,也是 UI 层 try…catch 的一种,其安全的定义是组件代码执行没有 JavaScript Error:

Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed.

我们发现这两种定义并不冲突,事实上,Suspense 与 Error Boundary 也确实能够共存,比如通过 Error Boundary 来捕获异步组件加载错误:

If the other module fails to load (for example, due to network failure), it will trigger an error. You can handle these errors to show a nice user experience and manage recovery with Error Boundaries.

例如:

import MyErrorBoundary from './MyErrorBoundary';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);

二.手搓一个 Suspense

开篇的 5 行代码可能有点意思,但还不够清楚,继续填充:

function Suspense(props) {
  const { children, fallback } = props;
  try {
    // 一旦有没ready的东西
    React.Children.forEach(children, function() {
      assertReady(this);
    });
  } catch {
    // 立即进入catch块,走fallback
    return fallback;
  }

  return children;
}

assertReady是个断言,对于不安全的组件会抛出 Error:

import { isLazy } from "react-is";

function assertReady(element) {
  // 尚未加载完成的Lazy组件不安全
  if (isLazy(element) && element.type._status !== 1) {
    throw new Error('Not ready yet.');
  }
}

P.S.react-is用来区分 Lazy 组件,而_status表示 Lazy 组件的加载状态,具体见React Suspense | 具体实现

试玩一下:

function App() {
  return (<>
    <Suspense fallback={<div>loading...</div>}>
      <p>Hello, there.</p>
    </Suspense>
    <Suspense fallback={<div>loading...</div>}>
      <LazyComponent />
    </Suspense>
    <Suspense fallback={<div>loading...</div>}>
      <ReadyLazyComponent />
    </Suspense>
    <Suspense fallback={<div>loading...</div>}>
      <p>Hello, there.</p>
      <LazyComponent />
      <ReadyLazyComponent />
    </Suspense>
  </>);
}

对应界面内容为:

Hello, there.
loading...
ready lazy component.
loading...

首次渲染结果符合预期,至于之后的更新过程(组件加载完成后把 loading 替换回实际内容),更多地属于 Lazy 组件渲染机制的范畴,与 Suspense 关系不大,这里不展开,感兴趣可参考React Suspense | 具体实现

P.S.其中,ReadyLazyComponent的构造有点小技巧:

const ReadyLazyComponent = React.lazy(() =>
  // 模拟 import('path/to/SomeOtherComponent.js')
  Promise.resolve({
    default: () => {
      return <p>ready lazy component.</p>;
    }
  })
);

// 把Lazy Component渲染一次,触发其加载,使其ready
const rootElement = document.getElementById("root");
// 仅用来预加载lazy组件,忽略缺少外层Suspense引发的Warning
ReactDOM.createRoot(rootElement).render(<ReadyLazyComponent />);

setTimeout(() => {
  // 等上面渲染完后,ReadyLazyComponent就真正ready了
});

因为Lazy Component 只在真正需要 render 时才加载(所谓 lazy),所以先渲染一次,之后再次使用时就 ready 了

三.类比 try…catch

如上所述,Suspense 与 try…catch 的对应关系为:

  • Suspense:对应try

  • fallback:对应catch

  • 尚未加载完成的 Lazy Component:对应Error

由于原理上的相似性,Suspense 的许多特点都可以通过类比 try…catch 来轻松理解,例如:

  • 就近 fallback:Error抛出后向上找最近的try所对应的catch

  • 存在未 ready 组件就 fallback:一大块try中,只要有一个Error就立即进入catch

所以,对于一组被 Suspense 包起来的组件,要么全都展示出来(包括可能含有的 fallback 内容),要么全都不展示(转而展示该 Suspense 的 fallback),理解到这一点对于掌握 Suspense 尤为重要

性能影响

如前面示例中的:

<Suspense fallback={<div>loading...</div>}>
  <p>Hello, there.</p>
  <LazyComponent />
  <ReadyLazyComponent />
</Suspense>

渲染结果为loading...,因为处理到LazyComponent时触发了 Suspense fallback,无论是已经处理完的Hello, there.,还是尚未处理到的ReadyLazyComponent都无法展示。那么,存在 3 个问题:

  • 伤及池鱼:一个尚未加载完成的 Lazy Component 就能让它前面许多本能立即显示的组件无法显示

  • 阻塞渲染:尚未加载完成的 Lazy Component 会阻断渲染流程,阻塞最近 Suspense 祖先下其后所有组件的渲染,造成串行等待

所以,像使用 try…catch 一样,滥用 Suspense 也会造成(UI 层的)性能影响,虽然技术上把整个应用都包到顶层 Suspense 里确实能为所有 Lazy Component 提供 fallback:

<Suspense fallback={<div>global loading...</div>}>
  <App />
</Suspense>

但必须清楚地意识到这样做的后果

结构特点

Suspense 与 try…catch 一样,通过提供一种固定结构来消除条件判断:

try {
  // 如果出现Error
} catch {
  // 则进入catch
}

将分支逻辑固化到了语法结构中,Suspense 也类似:

<Suspense fallback={ /* 则进入fallback */ }>
  { /* 如果出现未ready的Lazy组件 */ }
</Suspense>

这样做的好处是代码中不必出现条件判断,因而不依赖局部状态,我们能够轻松调整其作用范围

<Suspense fallback={<div>loading...</div>}>
  <p>Hello, there.</p>
  <LazyComponent />
  <ReadyLazyComponent />
</Suspense>

改成:

<p>Hello, there.</p>
<Suspense fallback={<div>loading...</div>}>
  <LazyComponent />
</Suspense>
<ReadyLazyComponent />

前后几乎没有改动成本,甚至比调整 try…catch 边界还要容易(因为不用考虑变量作用域),这对于无伤调整 loading 的粒度、顺序很有意义

Suspense lets us change the granularity of our loading states and orchestrate their sequencing without invasive changes to our code.

四.在线 Demo

文中涉及的所以重要示例,都在 Demo 项目中(含详尽注释):

五.总结

Suspense 就像是 UI 层的 try…catch,但其捕获的不是异常,而是尚未加载完成的组件

当然,Error Boundary 也是,二者各 catch 各的互不冲突

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code