一次线上事故:百度收录清零
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 (
)
}
模式三:服务端套客户端 - 详情页组合
详情页的数据读操作放服务端组件,评论区交互放内嵌的客户端组件。这样 HTML 包含正文全部内容,只有评论表单带少量 JS 水合。
// app/posts/[id]/page.tsx
import { PrismaClient } from '@prisma/client'
import { notFound } from 'next/navigation'
import CommentForm from '@/app/client-components/CommentForm'
const prisma = new PrismaClient()
interface Props {
params: { id: string }
}
export default async function PostDetailPage({ params }: Props) {
const id = Number(params.id)
const post = await prisma.post.findUnique({ where: { id } })
if (!post) notFound()
return (
{/* 也可以传序列化数据给客户端组件 */}
最后更新:{post.updatedAt.toLocaleDateString('zh-CN')}
)
}
API 路由:评论提交接口
客户端组件的 fetch 需要一个真实的接口,在 app/api/comments/route.ts 写入:
// app/api/comments/route.ts
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function POST(request: Request) {
try {
const body = await request.json()
const { postId, content } = body
if (!postId || !content) {
return NextResponse.json(
{ success: false, error: '参数不完整' },
{ status: 400 }
)
}
if (content.length > 500) {
return NextResponse.json(
{ success: false, error: '评论不能超过 500 字' },
{ status: 400 }
)
}
// 这里按理说应该写入 Comment 表,为演示简化直接用 Post 表
// 真实场景你需要单独建表
return NextResponse.json({ success: true })
} catch (err) {
console.error('评论接口错误:', err)
return NextResponse.json(
{ success: false, error: '服务器内部错误' },
{ status: 500 }
)
}
}
效果数据:重构前后的对比
改用服务端组件后的压测数据,测试环境:MacBook Pro M1 Pro(16GB内存),本地起 Next.js 生产模式,接口数据在同一台机器。
| 性能指标 | 重构前(全 Client) | 重构后(RSC) | 提升幅度 |
|---|---|---|---|
| TTFB(首字节) | 680ms | 210ms | ↓ 69% |
| FCP(首屏绘制) | 2.4s | 0.6s | ↓ 75% |
| LCP(最大内容绘制) | 3.2s | 1.1s | ↓ 66% |
| 页面 JS 体积 | 418 KB | 68 KB | ↓ 84% |
| 总请求数(首屏) | 14 个 | 4 个 | ↓ 71% |
| HTML 源码字节数 | 4.2 KB(几乎空壳) | 52 KB(含全文内容) | SEO 全量可爬 |
压测工具用 autocannon,每个场景跑 50 秒、50 并发。具体命令:
# 安装压测工具
npm install -g autocannon
# 压测首页:1000 条数据的列表页
autocannon -c 50 -d 50 http://localhost:3000/
# 压测详情页
autocannon -c 50 -d 50 http://localhost:3000/posts/500
压测结果摘要(50 并发,50 秒):
- RSC 列表页:平均延迟 85ms,p99 延迟 210ms,QPS 4220
- 全 Client 列表页(link rel="preload" 优化后):平均延迟 320ms,p99 延迟 780ms,QPS 1560
- RSC 详情页:平均延迟 65ms,p99 延迟 180ms,QPS 6350
补充说明:上面的数据是在数据量 1000 条、单机、无外部依赖的情况下测量。真实线上环境因为网络延迟、数据库远程连接,数据会有所浮动,但相对关系一致。
内存占用也有明显区别。用 process.memoryUsage() 打点,服务端组件模式下 Node 进程稳定在 180MB;全 Client 模式下因为每个页面都需额外序列化数据注入 HTML,进程稳定在 240MB。
原理补讲:RSC 是怎么工作的
React 服务端组件不是框架层面的黑魔法,它的核心机制在 React 18 里就定了。运行时,Next.js 会做编译期分离:含 async 前缀的组件和没有 'use client' 指令的组件被编译进服务端 bundle;含 'use client' 的组件被编译进客户端 bundle,两者之间通过 RSC payload 方式传输。
RSC 请求返回的不是传统 JSON,而是一个 $.$$ 开头的自定义格式。这个 payload 里包含:组件树结构、服务端组件的渲染结果、客户端组件的引用路径。浏览器端 React reconciler 根据 payload 重建 UI,只对客户端组件执行水合。
这意味着:
- 服务端组件里的
import fs、import mysql、读取环境变量,都不会进客户端的 JS bundle - 如果数据只在服务端用,浏览器根本不知道它的存在
- 客户端组件不能在服务端组件里传函数 prop(函数无法序列化),但可以传可序列化数据
避坑指南:这些坑我踩过
坑 1:'use client' 组件无法 import 服务端组件
这是个编译期报错。我一开始以为在客户端组件里嵌套一个服务端子组件就能同时享受两者优点,结果 Next.js 直接提示:
Error: ../../client-components/Wrapper.tsx
You're importing a component that needs to use the server.
Only Server Components can use the server.
解决办法是调整组件树层级:服务端组件作为父级,客户端组件作为子级。客户端组件如果需要数据,用 children props 传递,数据在服务端父组件查完传给子组件插槽。
// 正确做法:服务端父组件包客户端子组件
// app/page.tsx
import { PrismaClient } from '@prisma/client'
import ClientComposer from './ClientComposer'
const prisma = new PrismaClient()
export default async function Page() {
const data = await prisma.post.findMany()
return (
{/* 传入的是已渲染好的元素,不是数据 */}
{data.map(item => (
{item.title}
))}
)
}
// ClientComposer.tsx
'use client'
export default function ClientComposer({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
setOpen(!open)}>
{children}
{open && 展开的交互 UI}
)
}
坑 2:PrismaClient 在开发环境频繁热重载导致连接数暴涨
在 dev 模式下修改文件会触发 Next.js 刷新,PrismaClient 每次刷新都重新实例化,SQLite 连接池没释放,最终报错:
PrismaClientInitializationError:
error: Too many connections in pool
解决方式是把 PrismaClient 挂到全局对象上,避免重复实例化:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma?: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
所有组件和 API 路由里都从 lib/prisma.ts 导入,而不是直接 new PrismaClient()。
坑 3:RSC 里直接 JSON.stringify 日期会抛错
Prisma 返回的 createdAt 是 Date 对象,在 RSC 中直接做 JSON.stringify(post.createdAt) 会报:
Error: Date cannot be serialized for RSC payload
请先转换为字符串再传给客户端组件
正确做法是提前格式化,在服务端组件里把 Date 转成字符串,再展示或传给客户端组件:
// 正确做法
const formattedDate = post.createdAt.toLocaleDateString('zh-CN')
// 再把 formattedDate 传给子组件
坑 4:生产模式下静态渲染导致数据不更新
如果没做任何配置,App Router 会把页面静态化。我在写列表页时忘了加 export const dynamic = 'force-dynamic',导致上线后新增文章不显示。SQLite 里明明有数据,页面还是旧的。
临时方案:在使用 Prisma 的页面里加 export const dynamic = 'force-dynamic'。
推荐方案:用 revalidate 配置 ISR 增量更新,例如 export const revalidate = 60,每 60 秒重新生成页面。
坑 5:客户端组件水合时 Warning 不一致
如果服务端渲染的 HTML 和客户端首次渲染的虚拟 DOM 不一致,React 会告警并重新渲染一次。常见于用了 window、document 对象。在 RSC 模式下,这些组件还在服务端执行过,直接报错 window is not defined。
正确写法是把浏览器 API 调用放进 useEffect 或动态导入时才渲染:
'use client'
// 错误用法:组件顶层用 window
// const width = window.innerWidth // ❌ 服务端也执行了
// 正确用法
export default function ClientComponent() {
const [width, setWidth] = useState(0)
useEffect(() => {
setWidth(window.innerWidth)
}, [])
return 视口宽度:{width}
}
坑 6:数据量大时 Prisma 查询时间超过预期
1000 条数据在 SQLite 里全表查很慢。我在 getPosts 里用了 findMany 没加索引,列表页 TTFB 每请求多了 150ms。SQLite 单表超过 10 万行时必须建索引。按 createdAt 排序的查询需要对应索引:
CREATE INDEX idx_posts_created_at ON Post (createdAt DESC);
这套 SQLite 命令在 Prisma migration 里写为:
-- prisma/migrations/20240301000001_add_index/migration.sql
CREATE INDEX IF NOT EXISTS "idx_posts_created_at" ON "Post" ("createdAt" DESC);
加了索引后,同样的列表页查询从 55ms 降到 3ms,TTFB 从 210ms 降到 185ms。
总结和最佳实践清单
写 React 18 + Next.js 全栈应用时,遵循下面的规则能少踩至少十个坑:
- 默认用服务端组件,只有需要浏览器 API 或交互时才加
'use client' - 组件树从外到内:外层服务端父组件负责数据获取,内层客户端子组件负责交互
- 避免在客户端组件里发数据请求(useEffect + fetch),把数据获取往上提
- 给自建组件统一分包:独立
components/client和components/server目录,防止误用 - 用 Suspense + loading.tsx 做流式渲染:用户看到骨架屏比白屏快很多,感知性能提升明显
- 不要把 Prisma 实例放进组件内部,统一从
lib/prisma.ts导入
这套方案在线上稳定运行 8 个月,没有出现过一次 RSC 相关的线上事故。技术选型不是追新,核心数据指标提升(TTFB 下降 69%、SEO 收录恢复、JS 体积降 84%)已经说明一切。