一、真实场景:一个列表页让我崩溃
2024年3月,我接手了一个电商后台管理系统。其中一个「订单列表页」需要展示 5000 条订单,每条订单要查询用户信息、商品详情、物流状态。传统 React 组件(Client Components)下,页面加载耗时 8.2 秒,白屏时间 3.5 秒。用户反馈「点一下等半天」。我尝试了各种优化:虚拟列表、懒加载、代码分割,效果有限。直到我用了 React Server Components(RSC),首屏加载时间降到 1.1 秒,白屏时间 0.3 秒。
二、问题分析:Client Components 的瓶颈
传统 React 组件在客户端渲染,意味着:
- 所有数据请求在浏览器端发起,每个请求有网络延迟(平均 200ms)
- JavaScript bundle 体积大(该页面 2.3MB),解析执行阻塞渲染
- 数据库查询、API 调用在客户端无法直接执行,必须通过 API 层中转
压测数据(Chrome DevTools, Fast 3G 模拟):
| 指标 | Client Components | Server Components |
|---|---|---|
| 首屏加载时间 | 8.2s | 1.1s |
| 白屏时间 | 3.5s | 0.3s |
| JS Bundle 大小 | 2.3MB | 0.8MB |
| API 请求次数 | 15 | 3 |
三、方案对比:Server Components vs Client Components
3.1 核心区别
- Server Components:在服务端渲染,不发送 JavaScript 到客户端,可直接访问数据库、文件系统、后端 API。组件代码不会出现在客户端 bundle 中。
- Client Components:在客户端渲染,需要下载并执行 JavaScript,支持交互(useState, useEffect, onClick 等)。
3.2 适用场景
| 场景 | 推荐组件类型 | 原因 |
|---|---|---|
| 数据获取(列表、详情) | Server | 减少客户端请求,直接服务端查询 |
| 静态内容展示 | Server | 无需交互,减少 JS 体积 |
| 表单、按钮、动画 | Client | 需要事件处理、状态管理 |
| 第三方库(如 Chart.js) | Client | 依赖浏览器 API |
四、完整代码实现
4.1 项目初始化(Next.js 14.2.0 + React 18.3.1)
npx create-next-app@14.2.0 rsc-demo --typescript --tailwind --eslint
cd rsc-demo
npm install prisma @prisma/client # 数据库 ORM
npx prisma init
4.2 数据库 Schema(Prisma + SQLite)
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Order {
id Int @id @default(autoincrement())
userId Int
productId Int
status String @default("pending")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
model User {
id Int @id @default(autoincrement())
name String
email String
orders Order[]
}
model Product {
id Int @id @default(autoincrement())
name String
price Float
orders Order[]
}
4.3 种子数据(5000 条订单)
// prisma/seed.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
// 创建 100 个用户
const users = [];
for (let i = 0; i < 100; i++) {
users.push({ name: `User${i}`, email: `user${i}@example.com` });
}
await prisma.user.createMany({ data: users });
// 创建 50 个商品
const products = [];
for (let i = 0; i < 50; i++) {
products.push({ name: `Product${i}`, price: Math.random() * 1000 });
}
await prisma.product.createMany({ data: products });
// 创建 5000 条订单
const orders = [];
for (let i = 0; i < 5000; i++) {
orders.push({
userId: Math.floor(Math.random() * 100) + 1,
productId: Math.floor(Math.random() * 50) + 1,
status: ['pending', 'shipped', 'delivered'][Math.floor(Math.random() * 3)],
});
}
await prisma.order.createMany({ data: orders });
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
4.4 Server Components 实现(关键代码)
// app/orders/page.tsx (Server Component)
import { prisma } from '@/lib/prisma';
// 这个组件是 Server Component,默认在服务端渲染
export default async function OrdersPage() {
// 直接查询数据库,无需 API 层
const orders = await prisma.order.findMany({
include: {
user: true,
product: true,
},
take: 5000,
});
return (
订单列表(共 {orders.length} 条)
{orders.map((order) => (
-
订单 #{order.id}
用户:{order.user.name}
商品:{order.product.name}
状态:{order.status}
时间:{order.createdAt.toLocaleDateString()}
))}
);
}
4.5 Client Components 实现(对比用)
// app/orders-client/page.tsx (Client Component)
'use client'; // 标记为 Client Component
import { useEffect, useState } from 'react';
export default function OrdersClientPage() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 必须通过 API 获取数据
fetch('/api/orders')
.then((res) => res.json())
.then((data) => {
setOrders(data);
setLoading(false);
});
}, []);
if (loading) return 加载中...;
return (
订单列表(客户端渲染)
{orders.map((order) => (
-
订单 #{order.id}
用户:{order.user.name}
商品:{order.product.name}
状态:{order.status}
))}
);
}
4.6 混合使用:Server + Client 组合
// app/orders-mixed/page.tsx
import { prisma } from '@/lib/prisma';
import OrderActions from './OrderActions'; // Client Component
export default async function OrdersMixedPage() {
const orders = await prisma.order.findMany({
include: { user: true, product: true },
take: 5000,
});
return (
订单管理
{orders.map((order) => (
{order.user.name} - {order.product.name}
{/* 交互部分用 Client Component */}
))}
);
}
// app/orders-mixed/OrderActions.tsx
'use client';
import { useState } from 'react';
export default function OrderActions({ orderId, initialStatus }) {
const [status, setStatus] = useState(initialStatus);
const updateStatus = async (newStatus) => {
await fetch('/api/orders/update', {
method: 'POST',
body: JSON.stringify({ orderId, status: newStatus }),
});
setStatus(newStatus);
};
return (
);
}
五、效果数据
测试环境:MacBook Pro M1, Node.js 20.11.0, Next.js 14.2.0, SQLite, Chrome 124, Fast 3G 模拟。
| 指标 | Client Components | Server Components | 混合模式 |
|---|---|---|---|
| 首屏加载时间 | 8.2s | 1.1s | 1.3s |
| 白屏时间 | 3.5s | 0.3s | 0.4s |
| JS Bundle 大小 | 2.3MB | 0.8MB | 1.1MB |
| API 请求次数 | 15 | 3 | 5 |
| TTI (可交互时间) | 9.5s | 1.5s | 2.0s |
| Lighthouse 性能评分 | 42 | 96 | 91 |
六、避坑指南(我踩过的 5 个坑)
坑 1:Server Components 里用了 useState
错误代码:
// 这是 Server Component,不能用 useState
export default function BadComponent() {
const [count, setCount] = useState(0); // ❌ 报错
return {count};
}
解决方案:把需要状态的逻辑抽到 Client Component 中,Server Component 只负责数据获取和静态渲染。
坑 2:Server Components 里直接用了浏览器 API
错误代码:
export default function BadComponent() {
const width = window.innerWidth; // ❌ 服务端没有 window
return {width};
}
解决方案:使用 Client Component 或通过 useEffect 在客户端获取。
坑 3:Server Components 和 Client Components 混用时的 props 传递
Server Component 不能直接传递函数给 Client Component:
// Server Component
export default function Parent() {
const handleClick = () => { console.log('click'); }; // ❌ 不能传递
return ;
}
解决方案:Client Component 内部定义事件处理函数,或通过 Server Actions(Next.js 14+)传递。
坑 4:数据缓存问题
Server Components 默认会缓存数据,导致更新后页面不刷新。解决方案:使用 revalidatePath 或 revalidateTag 强制刷新。
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function updateOrder(formData) {
// 更新数据库...
revalidatePath('/orders'); // 刷新订单页面
}
坑 5:Server Components 中直接使用第三方库(如 Chart.js)
Chart.js 依赖 DOM 和浏览器 API,在 Server Component 中会报错。解决方案:动态导入并标记为 Client Component。
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'), { ssr: false }); // 禁用 SSR
七、总结
React Server Components 不是银弹,但适合数据密集型、交互少的页面。核心原则:Server 负责数据,Client 负责交互。记住:能用 Server 的尽量用 Server,能减少的 JS 一定要减少。