本文为翻译作品,原文链接:How to use React Suspense for data loading?
我在构建一个 Next.js 应用程序,我想将其从旧的 Pages Router 转移到新的 App Router,并开始利用 html 流和服务器组件。在这篇文章中,我不会深入讨论 React 和 Next.js 中的 Suspense 和服务器组件底层原理,而是想记录一下我的应用中 Suspense 和服务器组件的实践。
🧪 示例
在我的应用程序中,我有一个页面,它渲染了一个产品列表。出于 SEO 和性能原因,这些产品需要在服务器端渲染。我还需要支持客户端导航到该页面。由于 API 有时可能很慢,我需要为产品列表设置一些加载状态。
在初始加载时,页面渲染所有产品:
在页面加载之前,你可以看到空白屏幕,这个延迟是由 API 引起的。在生产环境中,这不是一个很大的问题,因为对于大多数流量,页面会被 CDN 缓存。
在客户端导航组件中显示一个加载骨架:
这是一个相当简单的场景。
在这个示例中,代码以以下方式组织:
有一个名为 fetchProducts
的简单函数,它会在 2000 秒的延迟后加载产品列表,以模拟 AP I 的慢速效果。
以下是组件:
ProductCard
渲染一个产品。
ProductList
接受一个产品列表作为属性,并将它们作为 ProductCards
在水平列表中渲染。
javascript
export function ProductList({ products }: { products: Product[] }) {
return (
<HStack width="full" padding={2} overflow="auto" gap={4}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</HStack>
);
}
ProductListSkeleton
组件在加载状态下渲染 10 个 ProductCards
。
javascript
export function ProductListSkeleton() {
return (
<HStack width="full" padding={2} overflow="auto" gap={4}>
{Array.from({ length: 10 }).map((_, index) => (
<ProductCard key={index} />
))}
</HStack>
);
}
ProductListWithEffect
是一个智能组件,它可以接受一个产品列表并使用 ProductList
渲染它们,或者如果没有提供产品,它将使用 fetchProducts
函数加载它们。
javascript
export function ProductListWithEffect({ products }: { products?: Product[] }) {
const [productsState, setProductsState] = useState<Product[] | undefined>(
products
);
useEffect(() => {
if (!productsState) {
fetchProducts().then((products) => setProductsState(products));
}
}, [productsState]);
if (!productsState) {
return <ProductListSkeleton />;
}
return <ProductList products={productsState} />;
}
在 Pages Router 里的数据加载代码如下:
javascript
export default function Page({ products }: { products: Product[] }) {
return (
<ChakraProvider>
<Header />
<Box width="full">
<ProductListWithEffect products={products} />
</Box>
<Footer />
</ChakraProvider>
);
}
export async function getServerSideProps() {
const products = await fetchProducts();
return {
props: {
products,
},
};
}
🥁 使用 Suspense 进行数据加载
乍一看,有了 Next.js 中的 Suspense 和服务器组件,我们能以更简单的方式编写代码,摆脱了 state 和 useEffect。
在 app 文件夹中,我们可以简单地开始编写页面逻辑,如下:
javascript
export default async function Page() {
const products = await fetchProducts();
return (
<>
<ProductList products={products} />
</>
);
}
这种方法的问题是我们丢失了加载状态 🫤
为了解决这个问题,我们可以添加 Suspense 或为页面定义加载状态。我不想编写页面的加载状态,因为我希望组件在加载时控制它们的外观和感觉,所以让我们尝试使用 Suspense。
我们可以添加一个新组件 ProductListWithSuspense
,它将加载数据并使用 ProductList
组件进行渲染:
javascript
export async function ProductListWithSuspense() {
const products = await fetchProducts();
return <ProductList products={products} />;
}
然后在我们的页面中,我们围绕这个组件使用 Suspense 并定义加载状态:
javascript
export default async function Page() {
return (
<Suspense fallback={<ProductListSkeleton />}>
<ProductListWithSuspense />
</Suspense>
);
}
我们要的效果几乎实现出来了:
这种方法有几个问题:
- 产品并没有真正地在服务器端渲染!HTML 会流式传输到浏览器,数据会在完全水合之前快速显示,但是如果我们在页面上禁用 JavaScript,我们只会看到加载骨架。根据你的经验看这可能没有问题,但在我的思路中,我知道页面将被 CDN 缓存,那么为什么不在没有加载状态的情况下缓存最终结果呢?🤔
- 第二个问题是页面设置了加载骨架,这意味着网站上的任何其他位置都需要进行同样的配置。
我们可以通过将 Suspense 下移组件树并有条件地添加它来解决这些问题:
javascript
async function ProductListLoader() {
const products = await fetchProducts();
return <ProductList products={products} />;
}
export function ProductListWithSuspense({
products,
}: {
products?: Product[];
}) {
if (products) {
return <ProductList products={products} />;
}
return (
<Suspense fallback={<ProductListSkeleton />}>
<ProductListLoader />
</Suspense>
);
}
而我们的页面看起来像这样:
javascript
export default async function Page() {
const products = await fetchProducts();
return <ProductListWithSuspense products={products} />;
}
你可以看到,我们现在几乎有了和使用 useEffect 时相同的逻辑,我们在页面中预加载数据并渲染 ProductList,但是如果页面决定不预加载数据,组件足够智能地自己加载数据。我们可以将这个组件放在任何页面上,它将处理数据的加载和渲染加载骨架。
但是,还有一个问题,Page
中的代码在初始页面加载和客户端导航时都会执行。这与 getServerSideProps
相比有很大的不同。你可以在下面看到为什么这是一个问题:
在客户端导航时没有加载状态 ------ 当你点击一个链接时,Next.js 等待页面渲染然后展示最终结果。同样,这可以通过为页面添加加载状态来解决,但正如我一开始所说,我不想这样做,因为页面应该负责单独渲染加载状态,我不想两次构建页面布局。
我找不到任何官方文档解释如何处理页面的初始加载与客户端导航,花了一些时间才弄清楚如何做到这一点。
通过阅读有关服务器组件的资料和检查网络流量,我意识到在客户端导航时,Next.js 向服务器发起请求,期望得到一个 JSON 负载 🥳
所以,如果我们更新页面代码以条件预加载数据,我们将得到我们想要的效果:
javascript
export default async function Page() {
let products: Product[] | undefined;
// 如果浏览器正在请求 html,这意味着它是第一次页面加载
if (headers().get("accept")?.includes("text/html")) {
products = await fetchProducts();
}
return <ProductListWithSuspense products={products} />;
}
这段代码尝试根据请求的类型来判断是否预加载数据。如果请求的 Accept
头部包含 text/html
,这通常意味着这是一个初始页面加载的请求,所以它会预加载产品数据。这种方式确保了在客户端导航时,我们可以避免重复加载数据,同时在初始加载时提供所需的数据,以便服务器组件能够在没有 JavaScript 的情况下正确渲染页面,从而提高了性能和用户体验。
最终,我们获得了与 Pages Router 和 useEffect 相同的行为 💪
🤔 结论
尽管用户的最终结果相同,但使用 Suspense 和服务器组件的新方法还有一些额外的好处。
开发者的第一个好处是,现在的代码看起来更简单,更易于阅读和理解。
页面性能是第二个好处,由于 ProductList
组件中没有客户端互动性,它可以是一个服务器组件,我们甚至不需要将组件代码发送到客户端,它只在服务器端渲染!在现实世界中,对于产品轮播图来说可能不是这样,但肯定每个应用都有一些组件可以在没有任何客户端互动性的情况下静态渲染,特别是如果你结合使用服务器组件和服务器操作,这可能减少发送给最终用户的代码量。
你可以在这里阅读更多关于服务器组件的信息,以及在这里了解有关使用 Suspense 进行加载的信息。