
Next.js 里最容易踩的 6 个性能坑
这篇文章整理了 Next.js App Router 和 React Server Components 中常见的 6 个性能坑:在页面顶层等待慢请求导致首屏被卡住、把服务端数据原封不动传给客户端、为了少量交互扩大 `use client` 边界、把非首屏数据都当成首屏依赖、服务端 mutation 后没有及时刷新缓存,以及在 layout 里塞入过多异步逻辑。文章的核心观点是:RSC 的性能收益来自正确的边界管理,而不是接入后自动变快。
React Server Components 在 Next.js 里很容易给人一种错觉:数据放到服务端拿,客户端少发一点 JavaScript,页面就应该天然更轻、更快,还顺手解决 SEO。
真到项目里,情况往往没这么理想。
不少页面接了 RSC 之后,首屏没有明显变快,交互反而更钝。问题通常不在 React 本身,也不完全在接口,而在于你把逻辑放错了位置:该先出来的内容被慢请求拖住了,该留在服务端的数据被传进了客户端,该拆小的交互边界被整个组件吞掉了。
RSC 的核心不是“自动提速”,而是重新划分服务端和客户端的职责。边界划对了,体验会很顺;边界一旦划错,性能问题只是换一种形式出现。
下面这 6 个坑,是我在 Next.js 项目里最常见到的。
1. 在页面顶层 await 慢请求,把首屏一起卡住
很多人第一次写 Server Component,都会下意识这么写:
export default async function Page() {
const report = await getReport();
return (
<main>
<Header />
<ReportView data={report} />
</main>
);
}逻辑很顺,但问题也很直接:只要 getReport() 没返回,整个 Page 就无法继续渲染。哪怕 Header 本来可以先出来,也要一起等。
更好的写法,是把慢的部分拆出去,让页面骨架先显示,再用 Suspense 等慢内容补进来:
import { Suspense } from "react";
export default function Page() {
return (
<main>
<Header />
<Suspense fallback={<ReportSkeleton />}>
<ReportSection />
</Suspense>
</main>
);
}
async function ReportSection() {
const report = await getReport();
return <ReportView data={report} />;
}
这里的重点不是让慢请求突然变快,而是别让它拖住整块首屏。用户先看到页面结构,比一直等白屏更重要。
2. 服务端查到什么,就全量传给客户端
Server Component 里拿数据很方便,所以很多代码会顺手把整包对象传给 Client Component。
但只要数据跨过 server/client 边界,它就不是免费的。它要被序列化、传输、解析,最后还要参与客户端渲染和 hydration。
比如这样:
export default async function Page() {
const users = await getUsers(); // 包含头像、权限、日志、设置等大量字段
return <UserTable users={users} />;
}如果表格实际只展示姓名、邮箱和状态,那这就是典型的“传多了”。
更合理的做法,是先在服务端裁剪数据:
export default async function Page() {
const users = await getUsers();
const tableData = users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
status: user.status,
}));
return <UserTable users={tableData} />;
}原则很简单:客户端需要什么,就只给什么。不要把数据库对象、权限字段、日志字段和内部配置一股脑塞进浏览器。
3. 为了一点交互,把整个组件都变成 Client Component
"use client" 很容易被低估。
它不是“给组件加个交互”的小开关,而是一条清楚的边界:从这里开始,组件以及它依赖的一部分代码会进入客户端环境。
最常见的误用,是为了一个输入框、按钮或切换器,直接把整块页面组件改成客户端组件:
"use client";
export default function ProductPage({ products }) {
return (
<section>
<SearchBox />
{products.map((item) => (
<ProductCard key={item.id} product={item} />
))}
</section>
);
}这样一来,本来可以留在服务端渲染的大块静态内容,也跟着一起进入客户端。
更稳妥的做法,是把交互拆小,只把真正需要事件处理和浏览器状态的部分下沉到客户端:
export default function ProductPage({ products }) {
return (
<section>
<SearchBoxClient />
{products.map((item) => (
<ProductCard key={item.id} product={item} />
))}
</section>
);
}"use client";
export function SearchBoxClient() {
return <input placeholder="搜索商品" />;
}一句话:客户端边界能小就不要大。真正需要交互的地方进客户端,不需要交互的内容尽量留在服务端。
4. 把所有数据都当成首屏必需品
有些页面并不是必须等所有数据回来才能显示。
但很多项目一上来就把所有请求都堆在页面入口,导致本来可以先出现的标题、导航、列表外壳,也被一起阻塞。
常见写法像这样:
export default async function DashboardPage() {
const stats = await getStats();
const team = await getTeam();
const notices = await getNotices();
return <Dashboard stats={stats} team={team} notices={notices} />;
}如果只有 stats 是首屏关键数据,另外两块可以稍后补,那么把它们绑在一起等,就是主动放弃 streaming 的优势。
更好的思路是给数据分优先级:
import { Suspense } from "react";
export default async function DashboardPage() {
const stats = await getStats();
return (
<>
<DashboardHero stats={stats} />
<Suspense fallback={<TeamSkeleton />}>
<TeamSection />
</Suspense>
<Suspense fallback={<NoticeSkeleton />}>
<NoticeSection />
</Suspense>
</>
);
}不是所有数据都值得阻塞首屏。先让用户看到关键内容,再补充次要模块,通常比等全部数据齐了再一次性展示更舒服。
5. 服务端更新成功了,但页面仍然是旧数据
这个坑不一定表现为“页面加载慢”,但会直接影响使用体验。
比如用户刚完成一个操作:新增、删除、修改状态。服务端已经成功了,但页面内容还是旧的。用户会怀疑是不是没点上,甚至再点一次。
常见代码像这样:
"use server";
export async function updatePost(id: string) {
await db.post.update({
where: { id },
data: { published: true },
});
}如果更新后没有触发重新验证,相关路径或数据可能仍然沿用旧缓存。Next.js 里通常要显式告诉框架:这块内容已经变了。
"use server";
import { revalidatePath } from "next/cache";
export async function updatePost(id: string) {
await db.post.update({
where: { id },
data: { published: true },
});
revalidatePath("/posts");
}也就是说,服务端 mutation 成功,不等于界面会自动变新。更新数据之后,要同时想清楚缓存和重新验证策略。
6. 在 layout 里塞太多异步逻辑
Layout 适合承载全局结构,但不适合承载太多“每次都要先等”的逻辑。
不少项目喜欢在根布局里一次性拿完用户信息、权限、导航、通知数量、团队配置。看起来集中,实际上会把页面切换也一起拖慢。
比如:
export default async function RootLayout({ children }) {
const user = await getCurrentUser();
const nav = await getNavigation(user.id);
return (
<html>
<body>
<Sidebar nav={nav} />
{children}
</body>
</html>
);
}问题在于,layout 的等待会影响很大一片区域。它本来应该是稳定外壳,结果变成了整个应用的总闸门。
更稳的处理方式通常是:
- 只把真正全局且必须的内容留在 layout
- 能下沉到具体页面的逻辑,不要提前提到最外层
- 能延迟加载的模块,不要抢首屏
- 能缓存的全局数据,要明确缓存和失效策略
写在最后
RSC 最大的误区,不是“不会用”,而是“以为用了就会自动变快”。
它确实给了 Next.js 更好的渲染模型,但这套模型不会替你决定:
- 什么应该先显示
- 什么可以延后
- 什么应该留在服务端
- 什么必须进入客户端
- 数据更新后应该刷新哪里
所以,Next.js 里的 RSC 性能问题,说到底不是某一个 API 的问题,而是边界管理的问题。
边界放对了,RSC 会很自然地发挥价值。边界放错了,它也会很自然地把慢和卡藏进页面里。


