React Server Components 到底解决什么问题(带具体例子)

起因

第一次看到 React Server Components(RSC)的介绍很懵:
"在服务端渲染的组件"——这不就是 SSR 吗?为什么搞个新东西?
直到我真的写了一个 RSC 项目(Next.js 14 app router)才明白它解的
是另一个问题:让组件能直接访问后端资源(DB / 文件 / API),
零 JS bundle 开销

传统 SSR 的局限

传统 SSR(getServerSideProps / loader):

// pages/posts/[id].js (传统 Next.js pages router)
export async function getServerSideProps({ params }) {
  const post = await db.posts.findById(params.id)
  const author = await db.users.findById(post.author_id)
  const comments = await db.comments.findByPost(params.id)
  return { props: { post, author, comments } }
}

export default function Page({ post, author, comments }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <AuthorBlock user={author} />
      <CommentList items={comments} />
    </article>
  )
}

问题:

  • 数据获取集中在一个函数里,组件需要数据时要 prop drill 传
  • 整个组件树都 hydrate(即使大部分不需要交互)
  • 所有数据 JSON serialize 进 HTML,包大
  • <AuthorBlock><CommentList> 各自需要的数据要 page 这层先取
    好再传

RSC 的做法

// app/posts/[id]/page.tsx (Next.js 14 app router)
// 默认就是 Server Component!

import { db } from '@/db'
import AuthorBlock from './AuthorBlock'
import CommentList from './CommentList'

export default async function Page({ params }: { params: { id: string }}) {
  const post = await db.posts.findById(params.id)
  return (
    <article>
      <h1>{post.title}</h1>
      <AuthorBlock userId={post.author_id} />
      <CommentList postId={params.id} />
    </article>
  )
}
// app/posts/[id]/AuthorBlock.tsx
// 也是 Server Component,自己取数据
import { db } from '@/db'

export default async function AuthorBlock({ userId }: { userId: string }) {
  const user = await db.users.findById(userId)
  return (
    <div>
      <img src={user.avatar} alt="" />
      <strong>{user.name}</strong>
    </div>
  )
}
// app/posts/[id]/CommentList.tsx
import { db } from '@/db'

export default async function CommentList({ postId }: { postId: string }) {
  const comments = await db.comments.findByPost(postId)
  return (
    <ul>
      {comments.map(c => <li key={c.id}>{c.body}</li>)}
    </ul>
  )
}

变化:

  • 组件就是 async 函数,直接 await DB / API
  • 每个组件管自己的数据获取
  • 没有 prop drilling
  • 这些组件的 JS 完全不进客户端 bundle(因为它们只在服务端跑)

客户端组件交互("use client")

需要 useState / 事件 / 浏览器 API 的组件加 "use client"

// app/posts/[id]/LikeButton.tsx
"use client"
import { useState } from 'react'

export default function LikeButton({ postId, initialCount }: ...) {
  const [count, setCount] = useState(initialCount)
  return (
    <button onClick={() => {
      setCount(c => c + 1)
      fetch(`/api/posts/${postId}/like`, { method: 'POST' })
    }}>
      ❤️ {count}
    </button>
  )
}
// app/posts/[id]/page.tsx (server component)
import LikeButton from './LikeButton'

export default async function Page({ params }: ...) {
  const post = await db.posts.findById(params.id)
  return (
    <>
      <h1>{post.title}</h1>
      <LikeButton postId={post.id} initialCount={post.likes} />
    </>
  )
}

LikeButton 是 client component,JS 进 bundle、可以 useState。
其它 server component 完全不进 bundle。

实际收益

我们的项目(中等规模博客):

传统 SSR RSC
首页 JS bundle 280 KB 60 KB
First Contentful Paint 1.8s 0.9s
LCP 2.4s 1.4s
代码可读性 数据 / UI 分离明显 数据在用它的组件里

bundle 显著小(因为大部分组件不进客户端),首屏快得多。

什么时候用 client component

"use client" 的场景:

  • useState / useEffect 等 React hooks
  • onClick / onChange 等事件
  • 浏览器 API(localStorage / window / etc)
  • 用了 client-only 库(如 framer-motion / chart.js)
  • Context provider(虽然 server component 也能 consume)

其它一律 server component。原则:"静态展示 → server;交互 → client"。

数据获取最佳实践

1. 直接 DB / ORM

import { prisma } from '@/lib/db'

export default async function Page() {
  const posts = await prisma.post.findMany({ take: 20 })
  return <PostList posts={posts} />
}

不再需要 REST / GraphQL 层。component → ORM → DB。

2. 并行获取

export default async function Page({ params }: ...) {
  const [post, author] = await Promise.all([
    db.posts.findById(params.id),
    db.users.findById(params.userId),
  ])
  return ...
}

避免 await 串行让请求慢。

3. Streaming + Suspense

import { Suspense } from 'react'

export default function Page({ params }: ...) {
  return (
    <>
      <h1>Article</h1>
      <Suspense fallback={<div>loading post...</div>}>
        <PostBody id={params.id} />
      </Suspense>
      <Suspense fallback={<div>loading comments...</div>}>
        <CommentList postId={params.id} />
      </Suspense>
    </>
  )
}

server 先 stream 出 <h1> + fallback,post / comments 各自异步加载完
就 stream 出真实 DOM 替换 fallback。用户看到 "渐进显示" 而不是
"全部等好再显示"。

Server Actions:从 client 调 server 函数

// app/posts/[id]/page.tsx
async function deletePost(postId: string) {
  'use server'
  await db.posts.delete(postId)
  revalidatePath('/posts')
}

export default function Page({ params }: ...) {
  return (
    <form action={async () => { 'use server'; deletePost(params.id) }}>
      <button>删除</button>
    </form>
  )
}

不需要写 /api/posts/:id/delete endpoint。直接调函数,Next.js 自动
hook 成 RPC。

与传统 SSR / SPA 共存

不是非黑即白。常见混合:

  • 营销页 / 文章 / 列表 → server component
  • 仪表盘 / 复杂表单 / 实时聊天 → client component
  • Next.js app router 默认 server,按需 "use client"

效果

  • 首屏体验大幅改善(bundle 减少)
  • 数据 / UI 不再分离,组件代码更聚合
  • 不需要专门维护 REST API 给前端用(直接调 ORM)
  • 但要熟悉"哪些代码跑服务端 / 哪些跑客户端" 心智模型

踩过的坑

  1. 在 server component 里用 useState → 编译报错。新人最常见的
    错。要么加 "use client",要么把状态下移到 client 子组件。

  2. import 服务端库进 client component:bundle 暴涨 + 可能泄漏
    secret。import { db } from '@/db' 在 client component 里就是
    重大失误。lint 规则强制检查。

  3. server / client 边界传 props 必须 serializable:函数 / Map /
    class 实例传不过去。只能传 plain object / 数组 / 基础类型。

  4. revalidate / cache 复杂:Next.js 默认激进 cache,dev / prod
    行为差异大。明确用 cache: 'no-store' / revalidate: 60 / cookies()

  5. Vercel / 自托管差异:Server Actions 等功能在自托管 Next.js
    还要配 standalone build。Vercel 上很丝滑,自部署要折腾。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。