利用Next.js的App Router和React 18的Server Components,可以构建灵活且易扩展的Web应用。但如果使用不当,也可能降低整页加载速度,或产生
不必要的服务器请求
。
这篇文章逐项介绍我如何解决实际运营博客中发现的性能瓶颈,重点包括:
- 并行执行fetch
- 移除重复API调用
- 优化Suspense嵌套
- 解决日期格式引发的hydration mismatch
- 减少generateMetadata的影响
原来的代码按以下方式依次获取数据:
const post = await PostService().getPost({ post_id: id });
await PostService().viewIncrement({ post_id: id });
这两个请求互不依赖,因此可以并行执行来改善性能。
解决方法:使用Promise.all()并行执行 🔗
const [markdownsource] = await Promise.all([
PostService().getPost({ post_id: id }),
PostService().viewIncrement({ post_id: id }),
]);
尤其当viewIncrement比较慢,或包含外部数据库I/O时,这样的修改可能带来明显差异。
最初,我在多处嵌套使用Suspense:
<Suspense fallback={<Loading />}>
<BreadCrumb params={markdownsource} />
</Suspense>
<Suspense fallback={<Loading />}>
<MdxHeader props={...} />
</Suspense>
<Suspense fallback={<Loading />}>
<Giscus />
</Suspense>
这种写法可以分别控制组件的加载状态,但实际渲染时会产生
不必要的React边界开销
,也可能让用户感到更慢。
<Suspense fallback={<Loading />}>
<>
<BreadCrumb params={markdownsource} />
<MdxHeader props={{ ... }} />
<div className="ml-auto text-right">
📅 <LocalizedDate isoString={markdownsource.init_date} />
</div>
<div className="prose mt-10 ...">
<MdxBody content={markdownsource.posting} />
<RightSidebarComp content={markdownsource.posting} />
</div>
<Giscus />
</>
</Suspense>
修改之后,加载提示不会重复出现,各组件之间的渲染顺序也保持一致。
问题3:日期格式导致Hydration Mismatch 🔗
最初的代码在服务器端格式化日期:
const dateString = dayjs(markdownsource.init_date).locale("ko").format("YYYY년 MM월 DD일");
当服务器与客户端的区域设置(locale)不同时,两端的渲染结果可能不同,进而产生hydration mismatch。
我改为由服务器只传递原始ISO字符串,再由客户端组件使用dayjs进行格式化。
'use client';
import dayjs from 'dayjs';
import 'dayjs/locale/ko';
export const LocalizedDate = ({ isoString }: { isoString: string }) => {
const [text, setText] = useState('');
useEffect(() => {
dayjs.locale('ko');
setText(dayjs(isoString).format('YYYY년 MM월 DD일'));
}, [isoString]);
return <span>{text}</span>;
};
调用处如下:
<LocalizedDate isoString={markdownsource.init_date} />
这样就可以始终按照客户端环境的格式显示日期。
问题4:重复调用PostService().getPost() 🔗
在Next.js App Router中,一个常见问题是:
generateMetadata()和页面组件正文分别
请求同一份数据
。
// metadata.ts에서 호출
const markdownsource = await PostService().getPost({ post_id: params.post });
// page.tsx에서도 또 호출
const markdownsource = await PostService().getPost({ post_id: params.post });
这意味着
加载一个页面时,会请求两次相同的数据
。尤其当数据量较大,或需要访问外部API时,会造成明显浪费。
generateMetadata()并不需要渲染页面所需的全部数据,只需要
title、description、og:image等部分字段
。因此,我另外构建了一个轻量化API:
const getPostMeta = async (params: { post_id: string }) => {
const response = await CustomFetch(`/post/meta${getParams(params)}`, {
method: "GET",
});
return response.body.data;
};
然后修改generateMetadata(),只调用元数据专用API:
export async function generateMetadata({ params }) {
const meta = await PostService().getPostMeta({ post_id: params.post });
return GenerateMeta({ meta, param: params.post });
}
这样就能够
减少不必要的完整数据请求,消除重复调用
。
| 项目 | 优化前 | 优化后 | 效果 |
|---|
重复调用getPost | generateMetadata和正文重复调用 | 拆分元数据专用API | 降低API成本 |
|---|
| Suspense | 多层嵌套 | 合并为一个 | 优化渲染 |
|---|
| 日期格式 | 服务器端格式化 | 客户端格式化 | 解决hydration mismatch |
|---|
| API调用 | 顺序调用 | 并行调用 | 缩短响应时间 |
|---|
Next.js本身具有良好的性能,但用户编写的代码结构仍然可能形成瓶颈。本文介绍的方法可以用于不同项目,并对用户感知速度和服务器成本产生积极影响。
只要遵循消除重复、简化Suspense、优化渲染时机和并行fetch这些基本原则,就能实现相当流畅的用户体验。
- 利用
revalidateTag、cache: 'no-store'等缓存策略。
- 对于只需在客户端执行的逻辑,可以考虑
dynamic(import, { ssr: false })。