⭐ 推荐:社区规则条款 V1.0

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

dp32323
dp32323 正式会员正式会员
发布于 2026-09-29 09:10 ·86 浏览 ·5 回复
内容摘要

区分 RSC 与 SSR,说明 Next.js App Router 中服务端组件是默认、可直接 await 取数、不能用 useState,并用 'use client' 标记客户端交互边界,且应下沉到叶子节点。

学完这篇,你能建立 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 passedprops 不可序列化只传字符串/数字/数组/普通对象
构建时提示模块找不到在客户端组件里 import 了 fs、数据库加 server-only 包,或把逻辑挪回服务端组件

小结

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

全部回复 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 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 ipzh.com域名网 Lv1 #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 里比「客户端/服务端」本身更容易被忽略。

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