在 Next.js App Router 页面中如何划分服务器端与客户端边界
React Server Components 的实用思维模型:哪些代码在何处运行、博客文章页面如何拆分为服务器端和客户端部分,以及需遵循的导入规则。
即使阅读了相关文档,React Server Components 仍可能让人感到难以理解,主要是因为它要求人们打破每位 React 开发者多年来一直持有的一个假设:组件必须在浏览器中运行。一旦这个假设被打破,其余内容就会相当自然地迎刃而解。本文以一个博客文章页面为例构建思维模型,说明哪些部分应在服务器端处理,哪些需要在客户端处理,以及维持二者之间清晰界限的几条规则。
如需更深入地了解渲染流程本身,请参阅Server Components 实现零包体渲染的架构原理。
从“全部内容都需要同步”到“仅同步必要部分”
在 Server Components 出现之前,所有的 React 组件最终都必须在浏览器中运行。即便使用服务端渲染,整个组件树的 JavaScript 代码也会被发送到客户端并进行初始化,才能让应用具备交互功能。对于那些仅负责获取数据并将其作为属性传递的组件来说,这种做法十分浪费——它们的代码会被下载并运行,却从未对用户操作做出响应。
Server Component 通过仅在服务器端运行改变了这一状况。它的代码永远不会被发送到浏览器,也不会进行初始化,因此不会为客户端打包添加任何 JavaScript 代码。最终传送到浏览器的是其渲染后的输出,React 会将其与 HTML 一起序列化为紧凑的数据包。
在脑海中区分这两种组件的最简单方法如下:
- 客户端组件:在服务器端预渲染后于浏览器中运行,能够存储状态并响应事件。
为何这种区分如此重要
以典型的博客文章页面为例,标题和正文来自数据库,对所有访问者显示一致且不考虑点击行为。传统上,用于渲染这些内容的代码以及任何 Markdown 或格式化库仍需传输到浏览器。而使用服务器组件后,所有这些内容都留在服务器上。这样一来,代码包体积更小,页面加载速度也更快,尤其是在性能较弱的设备上。
那些转向基于服务器组件构建的 App Router 的团队报告称,在某些场景下核心网页指标有所提升,尤其是 LCP 指标。需注意这些效果因场景而异:收益程度取决于页面减少了多少客户端 JavaScript 代码,因此建议自行测试各路由的表现。
被拆分为两部分的博客文章页面
在 Next.js App Router 项目中,除非另有说明,否则页面文件均为服务器组件。下面的页面是一个 async 函数,它直接从数据层获取数据,处理未找到的情况,渲染静态内容,然后再添加一个用于评论的交互式子组件。该代码是基于 Next.js 14 的 API 编写的:
// app/posts/[slug]/page.tsx
// This is a Server Component by default — no "use client" needed
import { getPostBySlug } from "@/lib/db"
import { PostContent } from "@/components/PostContent"
import { CommentSection } from "@/components/CommentSection"
type Props = {
params: { slug: string }
}
export default async function PostPage({ params }: Props) {
// Direct DB call — no useEffect, no API route, no loading state
const post = await getPostBySlug(params.slug)
if (!post) {
return <div>Post not found.</div>
}
return (
<article className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<PostContent content={post.body} />
{/* This one needs interactivity — so it's a Client Component */}
<CommentSection postId={post.id} />
</article>
)
}
注意其中缺失了什么:没有 useState,没有 useEffect,没有 API 路由封装,也没有加载状态管理。数据的获取方式与其他异步函数完全相同,这正是该模式的核心所在。
有两个实用提示。从 Next.js 15 开始,params 以 Promise 的形式传递,因此页面需要在读取 slug 之前先执行 await params;请确认您使用的版本。而对于真正的未找到页面情况,调用 next/navigation 中的 notFound() 函数会返回标准的 404 状态码,而非带有错误信息的普通页面。
评论框则有所不同。它需要将草稿文本保存在状态中,并对输入和点击操作做出响应,因此必须在浏览器中运行。文件顶部的 "use client" 指令将其标记为客户端组件:
// components/CommentSection.tsx
"use client" // opts into browser rendering
import { useState } from "react"
type Props = {
postId: string
}
export function CommentSection({ postId }: Props) {
const [comment, setComment] = useState("")
const handleSubmit = async () => {
await fetch("/api/comments", {
method: "POST",
body: JSON.stringify({ postId, comment }),
})
setComment("")
}
return (
<div className="mt-8">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Leave a comment..."
className="w-full border rounded p-2 text-sm"
/>
<button
onClick={handleSubmit}
className="mt-2 bg-blue-600 text-white px-4 py-2 rounded text-sm"
>
Post Comment
</button>
</div>
)
}
所有交互功能都集中在这里:文本区域的本地状态、变化处理函数,以及用于向 API 路由发送数据并清空该字段的提交处理函数。在实际实现时,请求中应添加 Content-Type: application/json 头部信息,同时可以考虑使用 Server Action 作为独立 API 路由的替代方案。
这样的分工便于理解:服务器负责数据,客户端负责交互操作。
保持边界清晰的规则
- 在 App Router 中,组件默认为 Server Components。
- 仅在与状态、效应、事件处理函数或
window、localStorage等浏览器 API 相关的场景下才添加"use client"。 - Server Components 可以导入并渲染 Client Components,就像页面中使用
CommentSection那样。
children或其他属性接收,这样就可以将服务器渲染的内容嵌套在交互式界面中。"use client"标记的是一个边界而非单个文件:该文件导入的所有内容都会成为客户端代码包的一部分。将此指令放在层级尽可能低的位置,即那些小的交互式组件上,就能让其余内容保留在服务器端。
总结
一旦形成“这段代码在何处运行?”这个思维模式,它就会成为你思考某个组件时的第一个问题,而App Router的默认答案就是服务器。
- Server Components将渲染和数据访问功能移至服务器端,不会生成任何组件级JavaScript代码。
- 数据获取操作在组件内部可通过普通的
async/await方式实现。 - 应将
"use client"视为交互式组件的可选功能,而非默认设置。 - 实际的第一步是:在现有项目中挑选一个用于数据获取的组件,判断它是否真的需要浏览器环境,若不需要则进行转换。
一旦确立了这种架构模式,布局、Suspense流式加载、Server Actions以及并行路由等功能就会容易得多,因为它们都是建立在相同的以服务器优先为基础之上的。
相关阅读
- 使用 Next.js App Router 构建具备高稳定性的电影详情页 — 了解如何通过异步服务器组件、awaited 参数以及正确的 404 处理方式,在 Next.js App Router 中正确获取并缓存 OMDB API 数据。
- 服务器动作还是路由处理程序?Next.js 16 的决策指南 — 了解在 Next.js 16 中,何时应将数据变更操作放在服务器动作中,何时需要使用路由处理程序,并通过表单、错误处理、乐观更新及网络钩子等场景的修正示例进行说明。