React Server Components解析:核心原理与实战避坑
发布日期: 2026/08/11 阅读总量: 1

一个真实场景

去年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%
FCP2.4s0.8s快66.7%
LCP3.1s1.2s快61.3%
TTI3.2s1.1s快65.6%
Lighthouse Performance6794+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 + 按路由拆包SSRRSC
首屏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'文件里调用updateOrderStatusrefreshOrders,这两个是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):

指标数值
首屏JS1.2MB(gzip 388KB)
FCP2.4s
LCP3.1s
TTI3.2s
请求数17(HTML + 8个JS chunk + 1次REST API + 静态资源)
Lighthouse Performance67

改造后(RSC):

指标数值
首屏JS451KB(gzip 118KB)
FCP0.8s
LCP1.2s
TTI1.1s
请求数7(HTML含RSC payload + 2个JS chunk + 静态资源)
Lighthouse Performance94

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。