一.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 各的互不冲突