Next.js全栈教程:服务端组件实战
发布日期: 2026/08/20 阅读总量: 0

一次线上事故:百度收录清零

2024年3月,我负责的电商后台资讯站改版上线。技术栈是 Next.js 14.1.0 + React 18.2.0,所有页面组件都带 'use client' 指令,数据请求全部放在 useEffect 里。上线第三天,SEO 负责人找到我:百度搜索资源平台显示收录从 8472 条骤降到 0。

抓了一下线上 HTML,发现首页源码只有空壳 div 和几个 script 标签,所有商品信息、文章内容全靠 JS 异步加载。百度爬虫虽然能渲染 JS,但自家 CDN 正好把数据接口限流了,爬虫拿到的是 503 错误页。这是个典型的客户端渲染陷阱。

这次事故让我把 React 服务端组件(RSC)的文档从头读了两遍,重构后: TTFB 从 680ms 降到 210ms,LCP 从 3.2s 降到 1.1s,百度收录三天恢复到 92%。这篇文章把完整方案写出来,代码可直接跑。

方案对比:RSC vs Client Component

在 App Router 架构下,组件默认就是服务端组件。只有加上 'use client' 的组件才会在浏览器里跑 JS。

对比维度Server Component (RSC)Client Component
渲染位置服务器服务器初始渲染 + 浏览器水合
能否直接查询数据库能(async/await)不能
打包体积(以 antd 为例)0 KB约 1.2 MB(gz 后 320KB)
SEO 友好度极高,HTML 完整输出依赖 JS 执行,有风险
能否使用 useState/hooks不能
性能特征TTFB 低,无额外 JS 请求需要下载/解析/执行 JS

关键结论

  • 读操作(查数据库、调 API)放服务端组件,配合 React 18 的 Suspense 做流式渲染
  • 交互操作(点击、输入、页面状态)放客户端组件,且尽量下沉到叶子节点
  • 服务端组件能 import 客户端组件,反之不行(客户端组件里 import 服务端组件会报错)

这不是二选一的问题,而是组合的问题。下面用完整的项目代码说明边界怎么划分。

项目初始化与目录结构

版本清单:Node.js 20.11.0,Next.js 14.1.0,React 18.2.0,TypeScript 5.3.3,数据库用 SQLite 3.45.1 + Prisma 5.9.1。

# 创建项目,指定 App Router
npx create-next-app@14.1.0 rsc-demo --typescript --tailwind --eslint --app
cd rsc-demo

# 初始化 Prisma + SQLite
npm install @prisma/client@5.9.1 prisma@5.9.1
npx prisma init --datasource-provider sqlite

安装完成后,创建数据模型和数据表:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model User {
  id     Int     @id @default(autoincrement())
  name   String
  email  String  @unique
  posts  Post[]  @relation("Author")
  postId Int?    @unique
  post   Post?   @relation("Author", fields: [postId], references: [id])
}
# 执行迁移并生成 Prisma Client
npx prisma migrate dev --name init
npx prisma generate

# 造数据:插入 1000 条帖子用于压测
node scripts/seed.js
// scripts/seed.js
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()

async function main() {
  // 清空旧数据
  await prisma.post.deleteMany()
  
  const posts = []
  for (let i = 0; i < 1000; i++) {
    posts.push({
      title: `文章标题 ${i}:React 服务端组件实践`,
      content: `这是第 ${i} 篇文章的正文内容,包含完整的 RSC 讲解。`.repeat(50),
    })
  }
  
  const result = await prisma.post.createMany({ data: posts })
  console.log(`成功插入 ${result.count} 条数据`)
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())

三种组件模式:完整代码实现

模式一:纯服务端组件 - 列表页

app/page.tsx 写入:

// app/page.tsx
import { PrismaClient } from '@prisma/client'
import Link from 'next/link'

// 在服务端直接实例化 PrismaClient
// 避免每次请求都创建新连接
const prisma = new PrismaClient()

export const dynamic = 'force-dynamic' // 关闭静态缓存,便于演示

async function getPosts() {
  // 服务端组件可以直接 await 数据库查询
  const posts = await prisma.post.findMany({
    select: { id: true, title: true, createdAt: true },
    orderBy: { createdAt: 'desc' },
    take: 50,
  })
  return posts
}

export default async function HomePage() {
  // 这就是 async 服务端组件的用法
  const posts = await getPosts()

  return (
    

全部文章

    {posts.map((post) => (
  • {post.title}
    {post.createdAt.toLocaleDateString('zh-CN')}
  • ))}
) }

这段代码里没有 useEffect、没有 useState、没有 fetch。数据在服务器上查完,直接渲染成 HTML 字符串发给浏览器。浏览器收到的就是完整内容。

模式二:纯客户端组件 - 评论表单

评论区需要用户输入和提交,必须用客户端组件。在 app/client-components/CommentForm.tsx 写入:

// app/client-components/CommentForm.tsx
'use client' // 必须声明,标记这是客户端组件

import { useState } from 'react'

type CommentFormProps = {
  postId: number
}

export default function CommentForm({ postId }: CommentFormProps) {
  const [content, setContent] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [message, setMessage] = useState('')

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!content.trim()) return

    setSubmitting(true)
    setMessage('')

    try {
      // 通过 API Route 提交评论,绕开 React 服务端渲染
      const res = await fetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ postId, content }),
      })

      const data = await res.json()
      if (data.success) {
        setMessage('评论成功!')
        setContent('')
      } else {
        setMessage('评论失败:' + data.error)
      }
    } catch (err) {
      setMessage('网络错误,请稍后重试')
    } finally {
      setSubmitting(false)
    }
  }

  return (