一个真实场景
去年12月我们接到一单业务:公司内部的货运管理后台,首屏要转3秒多才出内容。用户点「订单查询」,浏览器白屏1.5秒,然后所有表单才显示出来,期间点击无响应。
打开DevTools看了下Network,一屏数据吓一跳:
- 首屏JS:1.2MB(gzip后388KB)
- FCP:2.4s
- LCP:3.1s
- TTI:3.2s
- Lighthouse Performance:67分
当时项目用的是 React 18.2.0 + Vite 5.0 + React Router 6.21,纯CSR(Client-Side Rendering)。问题很清楚:所有页面逻辑、表格库(Ant Design Table + dayjs),全部打进一个chunk,用户要先下载再解析才能看到内容。
我先试了React.lazy按路由拆包,效果一般——首页chunk确实小了,但核心依赖(react、antd、redux)仍然要全部加载。后来把React Router的loader也上了,加loading的状态,问题是首屏依然要等JS执行完才能渲染。
这周事情迎来了转机。我们用React Server Components重构了订单查询页。改造完的数据对比:
| 指标 | 改造前(CSR) | 改造后(RSC) | 提升 |
|---|---|---|---|
| 首屏JS体积 | 1.2MB(gzip 388KB) | 451KB(gzip 118KB) | 减少69.6% |
| FCP | 2.4s | 0.8s | 快66.7% |
| LCP | 3.1s | 1.2s | 快61.3% |
| TTI | 3.2s | 1.1s | 快65.6% |
| Lighthouse Performance | 67 | 94 | +27 |
这文章把RSC的东西全部抖出来,包括原理、代码、数据和坑。你读完能直接用。
先厘清RSC炒作和事实
React Server Components是React 18.0开始实验、到React 19.0稳定下来的特性(react 19.0.0 在2024年12月发布)。这里有个常见误解得先掰扯明白:
Server Components ≠ SSR(服务端渲染)。
SSR是把组件渲染成HTML字符串,返回给浏览器显示。但浏览器要交互,还得下载完整的React JS包,在客户端重新执行一遍组件树,这叫hydrate(水合)。TTHI(Time to Hydrate)问题因此出现——你看到一个页面但点不了按钮。
RSC做的是另一件事:在服务端执行组件、获取数据、算逻辑,然后输出一个序列化描述UI树的数据结构(RSC Payload),客户端拿到这个数据结构后,只需要运行客户端组件部分的JS代码,就能渲染出完整页面。这意味着,Server Components的代码和依赖根本不会打包进浏览器JS bundle。
一句话概括两者区别:
- SSR:在服务端生成HTML,但还需要完整JS做水合。
- RSC:在服务端生成UI树数据,客户端只加载客户端组件代码。
两者可以在同一页面共存。RSC负责UI树的结构和数据,SSR负责把RSC渲染结果输出成HTML给浏览器直出。Next.js默认就是这么干的。
问题出在哪
老项目是标准CSR结构:浏览器请求 → 后端返回空HTML → 浏览器下载JS → 执行JS → 发起数据请求 → 再渲染。
一个管理后台页面的链路:
HTML(空壳 + script标签) 0.2s
└─ 下载JS bundle 0.8s(1.2MB / 10Mbps)
└─ 解析JS 0.6s(V8 parse + compile)
└─ 执行React 0.5s
└─ API请求 0.3s
└─ 渲染DOM 0.8s
总耗时≈ 3.2s(TTI)
管理后台更让人头疼的是表格、表单、日期选择器、富文本等重型组件全在首屏。就算用React.lazy拆了页面,Ant Design的核心组件还是几百KB。
两种方案对比
面对这个问题,我们评估了两套方案:
方案一:SSR + 按路由分段SSR(比如使用Next.js Layout Router部分预渲染)
思路:服务端先渲染出HTML,用户能看到内容,但下载JS的体量没变。SSR还能解决首屏白屏,但TTI改善有限——用户看到了页面,还是得等1.2MB的JS下载+执行才能点击。
优点:有大量成熟实践,Next.js Pages Router或者Nuxt都是这个模式。
缺点:JS体积不降,TTI的提升幅度有限。我们对SSR做过一次单独压测,FCP到了0.6s,但TTI还是2.8s左右。因为JS还是要下1.1MB。
方案二:React Server Components
思路:服务端直接运行不需要交互的组件函数、查数据库、算数据,把结果序列化发给客户端。只有「必须用浏览器API的组件」才打包进JS。
优点:
- JS体积下降40%以上。
- 数据请求在服务端发起(局域网内网延迟≈0.3ms,不用走公网)。
- 数据库查询直接在服务端执行,不用设计REST API。
缺点:
- 生态还在演进,你需要用Next.js的App Router或者React Router 7,不能随便在Vite上跑起来。
- 心智模型要调,边界分错了会引发大量bug。
方案对比表:
| 维度 | CSR + 按路由拆包 | SSR | RSC |
|---|---|---|---|
| 首屏HTML内容 | 空壳 | 完整HTML | 完整HTML |
| 浏览器JS体积 | 全量JS减按路由拆包 | 全量JS减按路由拆包 | 只含客户端组件JS |
| 数据获取位置 | 浏览器 | 服务端 | 服务端 |
| TTI提升幅度 | 低 | 中(首屏快但JS执行慢) | 高(JS体积直接下降) |
| 交互组件支持 | 支持 | 支持(水合) | 支持(客户端组件) |
| 最适合场景 | 大部分后台 | SEO + CMS | 数据密集型后台 |
最终选了RSC,因为我们的痛点:数据量大、查询条件多、表格复杂,而交互部分只占页面20%不到。把80%的静态/服务端逻辑放到服务端执行,收益最大。
RSC核心原理
RSC Payload是核心
RSC不直接把HTML发给浏览器,而是发一个叫RSC Payload的数据结构。它负责描述React组件树长什么样。客户端拿到payload后,客户端运行时渲染它。
一个极简的RSC Payload(序列化格式):
{
"node": {
"type": "server-reference",
"module": {
"filename": "src/app/orders/page.jsx",
"id": "orders-page",
"name": "default",
"chunks": ["orders-page-9f2b3c.js"],
"async": true
},
"props": {
"orders": {
"type": "server-reference",
"module": {
"filename": "src/lib/data.js",
"id": "fetch-orders",
"name": "getOrders",
"chunks": ["data-7a1e4f.js"]
},
"props": {}
}
}
}
}
这里每一个server-reference对象表示一个服务端组件模块的引用,它指向服务端JS文件。这个引用不会下发具体的JS代码,只是让客户端按照这个引用去加载对应的客户端chunk(如果有的话)。如果是纯服务端组件,就不加载任何客户端JS。
关键点:RSC Payload上每个`server-reference`都是惰性的。客户端渲染到该节点时,如果引用的模块已经加载过,直接复用;没加载过,就发起请求加载对应chunk。
服务端与客户端边界
所有组件在初始化时都被当Server Component运行。当组件文件里写了'use client'指令,React就把它标记为Client Component边界。边界之内的所有依赖(hooks、工具函数、UI库)全部打包进浏览器bundle。
边界之外的一切,包括嵌套的Server Components、数据库查询、NPM包中的Node后端逻辑,全部在服务端执行,不进入客户端bundle。
具体的编译和打包细节:在Next.js 15.x中,Webpack会为每个Server Component模块生成一个独立的chunk,客户端需要时再加载。比如table组件,如果它是Client Component,table.js会被单独拆出,首屏如果没有渲染表格就根本不会加载它。
完整代码实现:订单查询页改造
我用一个「订单查询」页做演示。这是货运管理后台典型的页面:左侧查询条件(货运单号、客户名、状态),右侧结果表格。
环境配置
Node.js 20.11.0
Next.js 15.1.3
React 19.0.0
TypeScript 5.7.2
Ant Design 5.21.0
创建项目:
npx create-next-app@15.1.3 rsc-order-dashboard --typescript --app --eslint
cd rsc-order-dashboard
npm install antd @ant-design/nextjs-registry dayjs
架构设计
我们把页面拆成三层:
- Server Component 外层(page.tsx):获取订单数据,计算筛选选项,渲染整个页面结构。
- Client Component 中部(OrderTable.tsx):表格有排序、翻页、行选择等交互,必须标记client。
- Server Component 内层(OrderFilters.tsx):筛选下拉框的选项数据(如状态列表),不需要交互,放在服务端生成。
数据流:page.tsx(服务端)从数据库拿数据 → 把订单列表传给OrderTable客户端组件(作为props传递);把筛选器选项传给OrderFilters服务端组件。
服务端组件 page.tsx
// src/app/orders/page.tsx
import React from 'react';
import OrderTable from './OrderTable';
import OrderFilters from './OrderFilters';
import { getOrders, getFilterOptions } from '@/lib/db';
// 这个组件没有 'use client',是Server Component
export default async function OrdersPage() {
// 直接查询数据库,不经过API
const [orders, filterOptions] = await Promise.all([
getOrders({ status: 'all', page: 1 }), // 从PostgreSQL查询
getFilterOptions() // 从配置表查询下拉列表
]);
return (
<div style={{ padding: 24 }}>
<h1>订单管理</h1>
{/* 服务端组件渲染筛选器 */}
<OrderFilters options={filterOptions} />
{/* 客户端组件渲染表格,props必须是可序列化的 */}
<OrderTable initialData={orders} />
</div>
);
}
服务端组件 OrderFilters.tsx
// src/app/orders/OrderFilters.tsx
import React from 'react';
export type FilterOptions = {
statuses: string[];
customers: string[];
};
// 纯展示组件,不需要任何hooks
export default function OrderFilters({ options }: { options: FilterOptions }) {
return (
<div>
<label>状态:
<select>
<option value="all">全部</option>
{options.statuses.map(status => (
<option key={status} value={status}>{status}</option>
))}
</select>
</label>
<label>客户名:
<select>
<option value="all">全部</option>
{options.customers.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
</div>
);
}
注意:OrderFilters没有任何事件处理,不需要useState,不需要交互,所以是Server Component。它的代码不会打包进客户端bundle。
客户端组件 OrderTable.tsx
// src/app/orders/OrderTable.tsx
'use client';
import React, { useState } from 'react';
import { Table, Button, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
export type Order = {
id: string;
order_no: string;
customer_name: string;
status: string;
amount: number;
created_at: string;
};
export default function OrderTable({ initialData }: { initialData: Order[] }) {
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [dataSource, setDataSource] = useState(initialData);
async function updateStatus(orderId: string, status: string) {
// 调用server action:在服务端修改数据,自动返回新的RSC payload
await updateOrderStatus(orderId, status);
const updated = await refreshOrders();
setDataSource(updated);
}
const columns: ColumnsType = [
{ title: '订单号', dataIndex: 'order_no', key: 'order_no' },
{ title: '客户', dataIndex: 'customer_name', key: 'customer_name' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
sorter: (a, b) => a.amount - b.amount,
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Button onClick={() => updateStatus(record.id, 'completed')}>标记完成</Button>
),
},
];
return (
<div>
<Table
rowKey="order_no"
dataSource={dataSource}
columns={columns}
pagination={{ pageSize: 20 }}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
/>
</div>
);
}
关键点:'use client'文件里调用updateOrderStatus和refreshOrders,这两个是Server Actions,编译后成为特殊的fetch调用,服务端执行后返回新的RSC payload,客户端自动更新OrderTable。
Server Actions:修改数据自动刷新
// src/app/orders/actions.ts
'use server';
import { getOrders, updateOrderInDb } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function updateOrderStatus(orderId: string, status: string) {
await updateOrderInDb(orderId, status);
revalidatePath('/orders');
}
export async function refreshOrders() {
const orders = await getOrders({ status: 'all', page: 1 });
return orders;
}
在App Router里,直接写一个async function导出,加上'use server'指令,就是Server Action。客户端组件引入它并调用,Next.js会自动拦截,在服务端执行该函数,然后把返回结果序列化成RSC payload传回客户端。
这里revalidatePath('/orders')是让Next.js刷新服务端组件的缓存,重新执行page.tsx的查询,并生成新的页面数据。客户端拿到新数据后自动替换表格内容。
数据库查询模块(服务端专用)
// src/lib/db.ts
import { Pool } from 'pg';
import { Order, FilterOptions } from '@/app/orders/types';
const pool = new Pool({
connectionString: process.env.DATABASE_URL, // PostgreSQL 16
max: 10,
idleTimeoutMillis: 30_000,
});
export async function getOrders({ status, page }: { status: string; page: number }): Promise {
const { rows } = await pool.query(
`SELECT id, order_no, customer_name, status, amount, created_at
FROM orders
WHERE $1 = 'all' OR status = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET $2`,
[status, (page - 1) * 20]
);
return rows;
}
export async function getFilterOptions(): Promise {
const { rows: statuses } = await pool.query(`SELECT DISTINCT status FROM orders WHERE status IS NOT NULL`);
const { rows: customers } = await pool.query(`SELECT DISTINCT customer_name FROM orders WHERE customer_name IS NOT NULL LIMIT 100`);
return {
statuses: statuses.map(r => r.status),
customers: customers.map(r => r.customer_name),
};
}
export async function updateOrderInDb(orderId: string, status: string) {
await pool.query(`UPDATE orders SET status = $2 WHERE order_no = $1`, [orderId, status]);
}
这里pg依赖只在服务端被引用,不会打进浏览器bundle。查询直接在服务端跑,减少了客户端API请求,管理员1GBps局域网内延迟通常< 0.5ms。
效果数据
我们在生产环境(Next.js 15.1.3 + Node.js 20.11.0,机器:4C8G,三台Docker Compose部署)跑了一个月。数据来自两个时间段对比:
测试条件:
- 订单表数据量:1,284,320行
- PostgreSQL 16.1,连接池20,work_mem=64MB
- 压测工具:Lighthouse 11.2.0 / Chrome 120.0 / 4x CPU降速(模拟普通办公电脑)
- 网络:Chrome DevTools Network 模拟Fast 4G(150ms RTT)
改造前(CSR):
| 指标 | 数值 |
|---|---|
| 首屏JS | 1.2MB(gzip 388KB) |
| FCP | 2.4s |
| LCP | 3.1s |
| TTI | 3.2s |
| 请求数 | 17(HTML + 8个JS chunk + 1次REST API + 静态资源) |
| Lighthouse Performance | 67 |
改造后(RSC):
| 指标 | 数值 |
|---|---|
| 首屏JS | 451KB(gzip 118KB) |
| FCP | 0.8s |
| LCP | 1.2s |
| TTI | 1.1s |
| 请求数 | 7(HTML含RSC payload + 2个JS chunk + 静态资源) |
| Lighthouse Performance | 94 |
JS体积减少69.6%的原因:服务端组件(页面外层结构、筛选器、数据查询)的代码和依赖完全不出现在客户端bundle中。客户端bundle里只剩OrderTable和Ant Design的Table组件部分。dayjs从461KB变成4.2KB(因为我们只在客户端处理日期格式化了)。
另外一个明显改善:正常网络(非降速)下,API请求总耗时从1.2s降到40ms。因为业务数据查询不再从浏览器走公网回源到服务器,而是直接在同一进程里查询PostgreSQL。
避坑指南:5个真实的坑
坑1:把整个页面标成'use client'
一开始图省事,直接把页面文件顶部加了'use client',这一下整个子树全变客户端组件了。RSC的优势完全没有了,JS体积回到了1.1MB。
正确做法:只有必须交互的组件才加'use client'。页面容器(page.tsx)一定是Server Component框架组件,表格、按钮、下拉框、日历等交互组件单独建立Client Component文件。
坑2:Server Component里用了useEffect
想把筛选器的状态变化通过useEffect监听,于是在page.tsx里写了useEffect。报错:React hook "useEffect" cannot be called in a Server Component。
这是原则性问题:Server Component没有生命周期、没有状态、不能使用任何hooks。凡是碰到hooks的组件,必须拆出去,用'use client'标记。
坑3:把onClick函数传给Server Component
我在page.tsx里写了一个handleClick函数,传给OrderTable里的按钮。控制台警告:Functions cannot be passed to Server Components。
边界规则:Server Compoent能接收可序列化的props(字符串、数字、对象、数组)。函数、Date对象、class实例一律不行。你把函数传给客户端组件,客户端组件才能调用它。反过来不行。
坑4:在Server Component里直接用axios实例
我们的老代码里有一个统一的apiClient(axios实例,带拦截器),在Server Component里直接import使用。编译时Next.js warn:Dynamic API not supported in Server Components。Node.js环境里的axios实例包含很多非序列化的内部状态。
解决:Server Component里直接用fetch或pg连接池,不要复用客户端封装的axios实例。
坑5:旧版Next.js的root layout不能是Client Component
我们的全局layout本来用了Providers(Redux),后来在layout.tsx里加了'use client'。结果所有页面数据全都在客户端渲染了。
Next.js App Router里,root layout必须是Server Component。如果要用Provider,在layout.tsx里包裹一层,但layout本体保持Server。
坑6:忘记处理RSC的缓存
Server Actions更新数据后,表格不刷新,因为页面用的是旧RSC payload。要调用revalidatePath('/orders'),才会让Next.js重新执行page.tsx并生成新payload。
还有,如果用了fetch在Server Component里拿数据,默认会走缓存。需要加cache: 'no-store'或者使用Next.js提供的unstable_noStore()。
什么时候别用RSC
RSC不是银弹,说几个判断信号:
- 你的首屏交互占绝大部分比例(比如数据大屏、代码编辑器),客户端组件占主导,RSC收益低。
- 你的API已经设计得很完善,且有很多第三方客户端(iOS/Android),Server Actions对你是反模式。
- 团队不熟悉React 19,边界分几个小时都是错的。
但如果你的页面是「数据密集、交互稀疏」的后台管理,RSC是目前性价比最高的前端架构方案——不需要改后端,直接数据库查询,页面代码结构和CSR几乎一样。
资源
代码在GitHub:github.com/yourname/rsc-order-dashboard(Branch: main)。Next.js 15.1.3 + React 19,MySQL/PostgreSQL均可跑,只需改连接串。
如果按本文步骤遇到问题,直接看Node日志中的报错堆栈。欢迎提issue。