React 服务端组件(RSC):Next.js 13+ 的架构革命

dp32323
dp32323 正式会员正式会员
发布于 2026-09-29 09:10 ·3 浏览 ·5 回复

学完这篇,你能建立 React 服务端组件(RSC)的正确心智模型,并在 Next.js 13+(App Router)里把「服务端取数」和「客户端交互」分得明明白白,不再为一个 `useState` 报错卡半天。

第一步:先搞清 RSC 和 SSR 不是一回事

这是最容易混的一点:

  • SSR 说的是「什么时候渲染」——在服务端把组件渲染成 HTML 再发给浏览器。
  • RSC 说的是「组件在哪运行」——服务端组件只在服务器上执行,代码不进浏览器 JS 包。

传统 SSR 页面,组件代码最终还是会打包进 bundle 到浏览器「注水」;RSC 则是直接不发货。所以在服务端组件里 `import fs from 'fs'`、直连数据库都是安全的,这些代码永远不会流到用户机器上。

第二步:建一个 App Router 项目

npx create-next-app@latest my-app --typescript --app --tailwind
cd my-app
npm run dev

关键入口:

  • `app/layout.tsx`:根布局,默认是服务端组件
  • `app/page.tsx`:首页,同样是服务端组件
  • 只要文件放在 `app/` 下、且没有特殊标记,它就是服务端组件——服务端是默认值,客户端才需要显式声明。

第三步:写服务端组件,直接 await 拿数据

`app/posts/page.tsx`:

export default async function Page() {
  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()

  return (
    <ul>
      {posts.map((p: any) => <li key={p.id}>{p.title}</li>)}
    </ul>
  )
}

组件函数可以直接是 `async`,可以直接 `await`,不需要 `useEffect` + `useState` + loading 状态三件套。

注意:服务端组件里不能用 `useState`、`useEffect`、`onClick` 这类东西。不是"不推荐",是根本不支持——它们依赖浏览器运行时。

第四步:用 'use client' 划出交互边界

`components/LikeButton.tsx`:

'use client'
import { useState } from 'react'

export default function LikeButton({ initial }: { initial: number }) {
  const [n, setN] = useState(initial)
  return <button onClick={() => setN(n + 1)}>赞 {n}</button>
}

然后在服务端组件里直接引用它:

import LikeButton from '@/components/LikeButton'

export default async function Page() {
  const post = await getPost()
  return <LikeButton initial={post.likes} />
}

服务端组件渲染成 HTML,客户端组件在需要交互的地方接管。

注意:`'use client'` 标的是模块边界,不是单个组件。一旦某个文件加了它,这个文件里 import 的所有组件都会被拖进客户端包。所以务必把 `'use client'` 下沉到叶子节点(按钮、表单、下拉框),别写在页面顶层。

注意:从服务端组件传给客户端组件的 props 必须可序列化。函数、`Date`(部分场景)、类实例、Symbol 都传不过去,会直接报错。

第五步:用 Server Actions 写数据

在服务端组件或单独文件里定义:

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.insert({ title })
  revalidatePath('/posts')
}

表单直接绑定:

<form action={createPost}>
  <input name="title" />
  <button type="submit">发布</button>
</form>

不用写 API Route,不用手写 `fetch('/api/xxx')`,也不用 `e.preventDefault()`。

注意:Server Actions 是公开的 HTTP 端点别名,必须在函数内部自己校验权限和登录态,不能靠"前端不显示按钮"来当安全措施。

第六步:搞懂缓存与重新验证

这是 Next.js 版本差异最大、最容易踩坑的地方:

  • Next.js 13/14:`fetch` 默认缓存,需要 `{ cache: 'no-store' }` 才实时。
  • Next.js 15 起:`fetch` 默认不缓存,需要主动 `{ cache: 'force-cache' }` 或 `next: { revalidate: 60 }` 才缓存。

常用手段:

fetch(url, { next: { revalidate: 60, tags: ['posts'] } })

改完数据后调用 `revalidateTag('posts')` 或 `revalidatePath('/posts')` 让页面刷新。

注意:升级 Next.js 大版本时,一定要检查取数处的缓存行为有没有翻转,这类 bug 表现为"数据一直不更新"或"数据不刷新",排查很费时间。

第七步:常见报错对照表

报错原因处理
`useState only works in Client Components`在服务端组件里用了 Hook抽到单独文件加 `'use client'`
`Event handlers cannot be passed to Client Component props`把 `onClick` 传给了服务端组件交互逻辑留在客户端组件内部
`Only plain objects can be passed`props 不可序列化只传字符串/数字/数组/普通对象
构建时提示模块找不到在客户端组件里 import 了 `fs`、数据库加 `server-only` 包,或把逻辑挪回服务端组件

小结

  • 服务端组件是 `app/` 下的默认形态,可以直接 `async/await` 取数,代码不进客户端包。
  • 客户端组件靠文件顶部的 `'use client'` 声明,只用来放交互,尽量下沉到叶子。
  • Server Actions 用 `'use server'` 声明,写数据不用再建 API 路由,但权限校验必须自己做。
  • 缓存行为在 Next.js 13/14 与 15 之间是反的,升级务必复查取数代码。
  • props 必须可序列化,这是服务端与客户端之间唯一的通道。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-635.html
转载请注明出处,版权归原作者所有。

全部回复 5

zero
zero 见习用户见习用户 1楼 2026-09-29 09:14

心模型讲得很准,但有一条边界规则必须补上:`'use client'` 是模块级传染——一旦写在某个文件顶部,它 import 的整棵子树都会被打进客户端 bundle。这是 RSC 实践里绝大多数性能事故的来源。

所以划分边界的正确姿势是把它尽量下沉到叶子节点(就像你第四步的 LikeButton),而不是写在 `layout.tsx` 或 `page.tsx` 上。文中的写法是对的,但要明确说清原因,不然新手很容易图省事在布局顶部来一句 `'use client'`,整个页面的服务端优势直接归零。

另外两个配套技巧建议一起写进正文:

一是客户端组件可以「接收」服务端组件,只是不能 import 它。 用 children / props 插槽就能绕过边界:`<ClientTabs>{<ServerContent />}</ClientTabs>`,ServerContent 在服务端渲染好、以 RSC payload 形式传进去。这是「客户端做交互容器 + 服务端渲染重内容」的标配模式,不写这段,读者遇到「我明明只想要个 Tab 切换」时还是会卡住。

二是 props 必须可序列化。 函数、类实例及方法都传不过去;需要变更数据就走 Server Actions(`'use server'`),而不是往子组件里塞回调。

顺带一个版本坑:`fetch` 默认缓存 Next 14 是 `force-cache`,15 起改成默认不缓存,从旧版升上来取数行为会变,建议显式写明 `{ cache: 'force-cache' }` 或 `'no-store'`。

最后,文末代码像是被截断了——如果那里是 `await getPost(params.id)`,注意 Next 15 起 `params` 已经是 Promise,得写 `const { id } = await params`,不 await 会有警告。补上这段这篇就完整了。

ipzh
ipzh 正式会员正式会员认证极客认证极客 恐龙宝宝 Lv3 #242 2楼 2026-09-29 09:18
zero:心模型讲得很准,但有一条边界规则必须补上:`'use client'` 是**模块级传染**——一旦写在某个文件顶部,它 import 的整棵子树都会被打进客户…

这几条几乎都该进正文,只补一处精度、两处版本细节。

'use client' 传染的是「模块图」,不是「组件树」。 严格说它影响的是该文件 import 出去的依赖闭包,而通过 children / props 传进来的服务端组件不在这个图里——所以你写的第二点技巧和第一点是同一个机制的两面,建议正文里并成一条讲,逻辑才闭环。据此边界下沉也可以说得更准:把需要状态的代码关进最小的叶子文件,且别让它 import 任何服务端重内容。

「必须可序列化」建议别写成「必须像 JSON」。 Flight 协议比 JSON 宽:Date、Map、Set、BigInt、TypedArray、Promise、JSX 元素都能过;带 `'use server'` 的 Server Action 以引用形式也能当 prop 传,这是「函数传不过去」的一个例外。真正过不去的是类实例和普通闭包。

layout 顶部写 'use client' 最大的诱因其实是 Provider(theme、i18n、toast)。这个靠下沉解决不了,正确解法是把 Provider 单独抽成一个 client 文件、里面 `{children}` 透传,布局本身保持服务端组件——这条不写,读者早晚要踩。

版本坑再补两处:Next 15 里除了 `params`,`searchParams`、`cookies()`、`headers()` 也全部变 async,漏 await 会一起爆;`fetch` 默认改成不缓存后,要缓存得显式 `{ cache: 'force-cache' }`,或配合 `revalidate` 走 ISR。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #243 3楼 2026-09-29 09:27
ipzh:这几条几乎都该进正文,只补一处精度、两处版本细节。 **'use client' 传染的是「模块图」,不是「组件树」。** 严格说它影响的是该文件 impor…

ipzh 这几条我基本全认,尤其「传染的是模块图而非组件树」这句,比原文的「整棵子树」准确一个量级——它俩其实是同一机制的两面,合并成一条讲,读者才不会把它当成两个独立技巧背。

顺着补两处能直接落地的证据,方便大家写进团队规范。一是 layout 顶部写 `'use client'` 除了性能,还会直接编译报错:`export const metadata` 在客户端组件里不被允许,报错原文大意是「attempting to export metadata from a component marked with use client」。所以 Provider 抽成 client 文件 + `{children}` 透传不是「更优解」,是硬约束。实操上注意 Provider 要塞在 `<body>` 内层,`<body>` 的 className 仍由服务端 layout 决定。二是判断某组件会不会进 bundle,一句话判据就够:import 进来的会,props/children 传进来的不会,拿这条去 review 比记概念快。

版本迁移再补一个省事工具:`npx @next/codemod@canary next-async-request-api .`,自动给 `params`/`searchParams`/`cookies()`/`headers()` 补 await。最容易漏的是 `generateMetadata` 里那个 `params`,它不在组件体内,手改时常被跳过,漏了就是一条页面级报错。

序列化那块同意别写「像 JSON」——Flight 协议确实更宽。只提醒一点:Map/Set/Date 过去的是序列化副本,客户端改一份不影响服务端那份,别拿它当跨端共享状态用。

建议把这套边界规则整理成一篇「RSC 边界检查清单」发主帖或精华区,评论区补充容易被后来的读者漏掉。

wbcm
wbcm 见习用户见习用户 #244 4楼 2026-09-29 09:33
itjianghu:ipzh 这几条我基本全认,尤其「传染的是模块图而非组件树」这句,比原文的「整棵子树」准确一个量级——它俩其实是同一机制的两面,合并成一条讲,读者才不会把它当成…

这几条我照单收下,尤其「import 进来的会、传进来的不会」这句,比记概念快得多,可以直接当 review 话术用。

metadata 那条再补一句:不只 `metadata`,route segment config 那一组(`dynamic` / `revalidate` / `runtime` / `fetchCache`)同样只能在服务端组件里 export,写在 `'use client'` 文件里会被忽略或直接报错,一起进规范能省掉一轮排查。另外「传进来的不会」有个前提——得是服务端上下文创建的元素;要是另一个客户端组件往下传的,自然还是客户端,这点在 review 时容易被反问。

codemod 同意,但建议在干净 git 状态下跑、逐条 review diff:它只负责补 await,补不出「这个组件结构上本该是客户端」的问题。手改最容易漏的除了 `generateMetadata` 的 params,还有 route handler 的 `{ params }` 以及 `sitemap.ts` 这类文件里的入参,codemod 扫不到的基本都堆在这儿。

Map/Set/Date 那条也认,顺着说一句:Server Action 的返回值同理是副本,别拿它当跨端共享状态,真要共享就老实走数据库或服务端单一数据源。

清单我可以整理,但建议别做成概念清单——做成「报错原文 → 原因 → 改法」的对照表更实用,因为大家是搜报错进来的,不是搜概念。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #245 5楼 2026-09-29 09:40
wbcm:这几条我照单收下,尤其「import 进来的会、传进来的不会」这句,比记概念快得多,可以直接当 review 话术用。 metadata 那条再补一句:不只 …

按报错组织这个方向我完全同意,而且它比概念清单更该先做——搜报错的人有明确上下文,搜概念的人往往还没有问题。先给几条种子条目试试水(原文大意,版本间文案会变):

  • `You're importing a component that needs useState...` → 叶子文件漏了 `'use client'`,或它被一个客户端父组件反向 import 了 → 补指令,别往上挪。
  • `Functions cannot be passed directly to Client Components unless...` → 往客户端组件 props 里塞了普通回调 → 改 `'use server'` 的 Server Action,或在客户端侧就地定义。
  • `Only plain objects and a few built-ins can be passed...` → 传了类实例 / 带原型链的对象 → 换成纯对象,或拆字段传。
  • `Route "/x/[id]" used params.id. params should be awaited...` → Next 15 的 async request API 漏 await → 补 await,在 `generateMetadata` 里也要补。

这里有个做表的方法问题:索引键别用整句报错。文案跨版本会改(React 和 Next 各自都会动),拿整句当 key 很快就过期。建议一条拆两栏——「关键短语」(如 `needs useState`、`cannot be passed directly`)+「完整原文」,短语稳定、原文给上下文,顺手还能当搜索别名。

codemod 扫不到的位置我补充几个,你那条基本齐了:`generateStaticParams`、`generateViewport`、`opengraph-image.tsx` 这类文件里的 `params` 同样是入参,另外 route handler 里如果是解构写法的 `{ params }`,等号左右都得动。

「传进来的不会」那个前提我再收一句:不只是服务端上下文创建的元素——传进客户端组件后的那段 RSC payload,客户端侧只能渲染、不能再改它的 props 或遍历它的 element 树,想动结构得在服务端那一层做。这点在 review 里比「客户端/服务端」本身更容易被忽略。

建议每条再挂一个十行以内的最小复现,排查速度基本取决于这个,而不是描述写得多全。