React的 Suspense
React的内置组件 中文意为 悬念 ,所以表现的是一种拿不准的状态,该组件的作用是,作为一个包裹组件,包裹我们需要加载的子级组件,在子级组件未加载完毕的时候展示替代组件(比如loading组件)
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
本文直接对目前Suspense的使用方法做一个直白讲解,Suspense的演变过程以及理念可以看文末的参考链接详细了解。
常用用法
Suspense 结合Data Fetch
在我刚开始学习React的时候,Suspense还只能React.lazy结合使用用于加载异步组件。而现在Suspense已经可以对任何异步状态生效了,当然这需要遵守一定的规则。
Suspense结合 Data Fetch的代码如下:
import { fetchData } from './data';
export default function Albums() {
const albums = use(fetchData(`/the-beatles/album`));
console.log('albums', albums);
return (
<ul>
{albums.map((album) => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
// This is a workaround for a bug to get the demo running.
// TODO: replace with real implementation when the bug is fixed.
function use(promise) {
console.log('promise', promise);
if (promise.status === 'fulfilled') {
return promise.value;
} else if (promise.status === 'rejected') {
throw promise.reason;
} else if (promise.status === 'pending') {
throw promise;
} else {
promise.status = 'pending';
promise.then(
(result) => {
promise.status = 'fulfilled';
promise.value = result;
},
(reason) => {
promise.status = 'rejected';
promise.reason = reason;
},
);
console.log('throw', promise);
throw promise;
}
}
注意上述代码中的use方法就是将我们的异步请求方法转换为能够符合suspense用法的函数,即要求被Suspense包裹的组件是能够抛出一个Promise 异常。在这个 Promise 结束后再渲染组件,因此use
函数需要在 Pending 状态时抛出一个 Promise,使其可以被 Suspense 捕获到。
Suspense和lazy结合使用
这个就是最开始大家都了解的使用方法了
import React, { Suspense, lazy } from 'react';
const AsynComponent = lazy(
async () => await lazyLoad(import('./lazyLoad')),
);
export default function Index() {
return (
<Suspense fallback={<div>loading....</div>}>
<AsynComponent />
</Suspense>
);
}
// 模拟加载组件的延迟
async function lazyLoad(promise) {
await new Promise((resolve) => {
setTimeout(resolve, 2000);
});
return promise;
}
动态加载外部组件
最近工作中有做到在pc端动态加载H5组件资产的需求,用于展示发布的组件。这里我利用suspense和错误边界来进行一个封装,让其成为一个通用的组件。
代码示例:
错误边界组件
这里按照需求自定义即可
// ErrorBoundary.tsx
import React, { ErrorInfo, ReactNode } from 'react';
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
}
export default class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
动态加载组件
import React, { Suspense, useRef } from 'react';
import ReactDOM from 'react-dom';
import ErrorBoundary from './ErrorBoundary';
// 手动定义全局变量 保证umd方式导入的组件加载正常
window.React = React;
window.ReactDOM = ReactDOM;
interface Props {
src: string; // UMD 脚本地址
globalName: string; // UMD 暴露的全局变量名
fallback?: React.ReactNode; // 加载态
errorFallback?: React.ReactNode; // 错误态
componentProps?: Record<string, unknown>; // 组件的props
}
interface Thenable<T> extends PromiseLike<T> {
then<TResult1 = T, TResult2 = never>(
onfulfilled?:
| ((value: T) => TResult1 | PromiseLike<TResult1>)
| undefined
| null,
onrejected?:
| ((reason: any) => TResult2 | PromiseLike<TResult2>)
| undefined
| null,
): Thenable<TResult1 | TResult2>;
reason?: string;
status?: 'rejected' | 'fulfilled' | 'pending';
value?: unknown;
}
const cache = new Map();
export function loadComponent(
src: string,
getData: (src: string) => Promise<unknown>,
) {
if (!cache.has(src)) cache.set(src, getData(src));
return cache.get(src);
}
function use<T>(promise: Thenable<T>) {
// console.log('promise', promise);
if (promise.status === 'fulfilled') {
return promise.value;
} else if (promise.status === 'rejected') {
throw promise.reason;
} else if (promise.status === 'pending') {
throw promise;
} else {
promise.status = 'pending';
promise.then(
(result) => {
promise.status = 'fulfilled';
promise.value = result;
},
(reason) => {
promise.status = 'rejected';
promise.reason = reason;
},
);
// console.log('throw', promise);
throw promise;
}
}
export const UmdComponent: React.FC<Props> = ({
src,
globalName,
componentProps,
}) => {
const abortControllerRef = useRef<AbortController | null>(null);
const scriptRef = useRef<HTMLScriptElement | null>(null);
function loadScript(src: string): Promise<React.ElementType> {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
return new Promise((resolve, reject) => {
// 1. 检查是否已加载
if (scriptRef.current) {
// 清除脚本
document.head.removeChild(scriptRef.current);
scriptRef.current = null;
}
// 2. 动态插入 <script>
const script = document.createElement('script');
script.id = 'globalName';
script.src = src;
script.async = true;
// 3. 加载回调
const onLoad = () => {
if (signal.aborted) return;
const ctor = (window as any)[globalName];
if (!ctor) {
reject(new Error(`UMD global "${globalName}" not found.`));
return;
}
resolve(ctor);
};
// 4. 错误回调
const onError = (event: Event | Error) => {
console.log(event);
reject(new Error('Script load failed'));
};
script.addEventListener('load', onLoad);
script.addEventListener('error', onError);
document.head.appendChild(script);
});
}
const Component = use(loadComponent(src, loadScript)) as React.ElementType;
return <Component {...componentProps} />;
};
// 包装器:Suspense + ErrorBoundary
export const UmdComponentLoader: React.FC<Props> = (props) => (
<ErrorBoundary fallback={props.errorFallback}>
<Suspense fallback={props.fallback}>
<UmdComponent {...props} />
</Suspense>
</ErrorBoundary>
);
组件使用
import React from 'react';
import { UmdComponentLoader } from './umd';
// 链接测试时请自行替换为真实umd方式的cdn
export default function Index() {
return (
<div>
<UmdComponentLoader
src="https://xxx/umd-named/index.umd.min.js"
globalName="xxx"
componentProps={{
}}
fallback={<>加载中。。。。</>}
errorFallback={<>加载失败。。。。</>}
/>
</div>
);
}
存在的问题
- fetch库支持情况:目前Suspense 的Data Fetch还是处于比较新的一个阶段,可以看到 一些 支持Suspense的框架都暂时不推荐在生产环境使用其Suspense模式,比如 swr和react-query,不过我们只要了解这个规则使用,自己简单的使用,比如 自定义类似与use(React19会提供这个方法)的方法 那也是没有问题的。当然还是要关注最新的变动。 swr React-query
- 一闪而过的fallback:Suspense目前也存在问题就是 加载闪烁的问题,这个问题在以前结合lazy异步加载组件的时候经常遇到,如果第一次进入页面,组件加载的很快,那我们就会看到一闪而过的loading,这对用户来讲是十分糟糕的体验。我们比如可以考虑为Suspense设置一些新的属性,用于控制fallback 的最小出现时刻,或者控制fallback至少显示多长时间。目前Suspense还未支持这一特性,当然期待未来会支持这个特效。github上也有这个issue
当然我们可以对fallback组件进行改造来支持这一特性
<Suspense fallback={MyFallback} />;
const MyFallback = () => {
// 计时器,200 ms 以内 return null,200 ms 后 return <Spin />(也可以结合设置最短展示Spin多少ms)
}
附录代码
// fetchData --data
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
const cache = new Map();
// 注意这个缓存函数,如果不设置缓存的,use函数每次都会生成一个新的promise,导致永远无法更新promise状态,进而导致无限渲染
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else {
throw Error('Not implemented');
}
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise((resolve) => {
setTimeout(resolve, 2000);
});
return [
{
id: 13,
title: 'Let It Be',
year: 1970,
},
{
id: 12,
title: 'Abbey Road',
year: 1969,
},
{
id: 11,
title: 'Yellow Submarine',
year: 1969,
},
{
id: 10,
title: 'The Beatles',
year: 1968,
},
{
id: 9,
title: 'Magical Mystery Tour',
year: 1967,
},
{
id: 8,
title: "Sgt. Pepper's Lonely Hearts Club Band",
year: 1967,
},
{
id: 7,
title: 'Revolver',
year: 1966,
},
{
id: 6,
title: 'Rubber Soul',
year: 1965,
},
{
id: 5,
title: 'Help!',
year: 1965,
},
{
id: 4,
title: 'Beatles For Sale',
year: 1964,
},
{
id: 3,
title: "A Hard Day's Night",
year: 1964,
},
{
id: 2,
title: 'With The Beatles',
year: 1963,
},
{
id: 1,
title: 'Please Please Me',
year: 1963,
},
];
}