React Server Components vs Client-side React (SPA) 对比

在服务端预渲染、默认零 JS 的 React 层

VS
Client-side React (SPA)

在浏览器中渲染、经典且成熟的 React 使用方式

18 分钟阅读Frontend

快速结论

在内容繁重、SEO 关键的场景中,RSC 带来可衡量的收益:react.dev 的示例中,75K gzip 大小的库完全不会进入客户端。在高交互的内部面板(仪表盘、后台管理)中,client-first 方案更简单,出错面也更小。一个真实存在的关键警告是:把完整对象作为 prop 从 Server 传递到 Client,会把该对象的所有字段都写入 flight payload;如果边界划错,收益会悄悄流失。请用测量数据做决定,而不是跟风。

React Server ComponentsClient-side React (SPA)
阅读完整结论

评分对比

图表加载中…

详细评分

详细评分: React Server Components Client-side React (SPA) ——按类别打分,满分 10 分
分类React Server ComponentsClient-side React (SPA)
性能
8/10
6/10
学习难易度
4/10
8/10
生态系统
7/10
9/10
社区
7/10
9/10
就业市场
7/10
8/10
面向未来
9/10
7/10

优缺点

React Server Components

优点

  • 在内容繁重的页面中,完全不会把重量级库(markdown 解析器、sanitizer)打包进 client bundle——react.dev 自己的示例中节省了 75K gzip 体积
  • 服务端获取数据,消除了 client-server 瀑布流(例如 Note→Author 的串行 fetch)
  • 首屏加载时 HTML 立即可见,无需等待 JS hydration 即可阅读内容
  • 与 Next.js App Router 的 ISR/缓存层天然集成——通过 stale-while-revalidate 即时响应
  • 借助 React 19.2 的 Activity 与 Next.js 16.3 的 Instant Navigations,SPA 般的操作体验差距被大幅弥补
  • Turbopack 的 chunk-group 合并会自动优化 client bundle

缺点

  • 每个跨越 Server→Client 边界的 prop 都必须可序列化;传递不受支持的值时 React 会抛出异常
  • 文件被标记 'use client' 后,该文件的全部传递依赖都会被打包进 client bundle——边界划错会悄悄抵消收益
  • Server Component 无法创建 context,也不能使用 useState/useEffect 等交互式 API
  • RSC 底层 API 在 React 小版本之间不遵循 semver——必须锁定到 bundler/框架指定的特定版本
  • 2025 年 12 月 3 日发现了一个严重的未授权 RCE 漏洞(已在 19.0.1/19.1.2/19.2.1 中修复)——依赖框架带来的安全面在扩大
  • 学习曲线陡峭:在正确的位置划分 server/client 边界需要经验,错误信息大多在运行时才会出现

最适合

内容繁重、SEO 关键的页面(博客、产品页、文档、对比页)首屏为主、可在服务端一次性获取、靠近数据源的页面由 ISR/缓存驱动、更新不频繁但流量很高的页面已经使用 Next.js App Router、想要框架原生架构的团队需要可衡量地缩小客户端 JS 体积的项目

Client-side React (SPA)

优点

  • 心智模型简单:每个组件随时都可以是交互式的,无需考虑 server/client 边界
  • 基于 Vite 的工具链提供快速的 dev-server、HMR,以及成熟的代码分割(Rollup)
  • 没有序列化边界——state 直接作为 JS 对象存在于浏览器中,不存在 'flight payload' 陷阱
  • 最大也是最悠久的 React 生态系统;库、Stack Overflow 答案和招聘人才池都是最广泛的
  • 无需运行服务器——可以直接部署到静态托管/CDN
  • 调试只在单一环境(浏览器 DevTools)中进行,不存在 server/client 双重错误面

缺点

  • 首屏加载通常只是空的 `<div id="root">` + JS bundle——TTI 与 FCP 会彼此接近,JS 未运行前看不到任何内容
  • 重量级库(markdown 解析器、sanitizer、图表库)始终会进入 client bundle,没有留在服务端的选项
  • 如果数据获取滑向串行的 `useEffect` 链条,client-server 瀑布流的风险很高
  • 单一 chunk / 按页拆分 chunk 的策略可能导致共享代码被反复下载——Turbopack 正在解决的问题,在 SPA 一侧仍要靠工具链(Vite/Rollup)解决
  • 为了 SEO 和首屏性能,还需要额外搭建 SSG/预渲染层,框架本身不提供这一能力

最适合

高交互的内部面板(后台管理、仪表盘)——频繁的状态变化、拖拽、实时筛选需要身份验证、SEO 不是优先事项的应用小团队的快速原型设计和 MVP 开发要部署到静态托管/CDN、不希望承担服务器运行成本的项目现有大型 SPA 代码库的维护和渐进式现代化改造

代码对比

React Server Components
// Next.js App Router — Server Component(默认)
// app/posts/[slug]/page.tsx
import { db } from '@/lib/db';
import LikeButton from './like-button'; // Client Component(在单独文件中标记 'use client')

interface PageProps {
  params: Promise<{ slug: string }>;
}

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;

  // 直接在服务端 await —— 没有 client-server 瀑布流,
  // marked/sanitize-html 这类重量级库完全不会进入 client bundle
  const post = await db.post.findUnique({ where: { slug } });
  if (!post) return <div>未找到</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      {/* 只有可序列化的 prop 才能传给 Client Component */}
      <div dangerouslySetInnerHTML={{ __html: post.renderedHtml }} />
      <LikeButton postId={post.id} initialCount={post.likeCount} />
    </article>
  );
}

// app/posts/[slug]/like-button.tsx
'use client';
import { useState, useTransition } from 'react';

export default function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(async () => {
        setCount((c) => c + 1);
        await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
      })}
    >
      点赞 ({count})
    </button>
  );
}
Client-side React (SPA)
// Vite + React — Client-side SPA
// src/pages/PostPage.tsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';

interface Post {
  id: string;
  title: string;
  renderedHtml: string;
  likeCount: number;
}

export default function PostPage() {
  const { slug } = useParams();
  const [post, setPost] = useState<Post | null>(null);
  const [isLiking, setIsLiking] = useState(false);

  useEffect(() => {
    // 所有数据获取都在客户端 —— 瀑布流风险从这里开始
    let cancelled = false;
    fetch(`/api/posts/${slug}`)
      .then((res) => res.json())
      .then((data) => { if (!cancelled) setPost(data); });
    return () => { cancelled = true; };
  }, [slug]);

  if (!post) return <div>加载中...</div>; // JS 未运行时这个界面根本不会显示

  async function handleLike() {
    setIsLiking(true);
    setPost((p) => (p ? { ...p, likeCount: p.likeCount + 1 } : p));
    await fetch(`/api/posts/${post!.id}/like`, { method: 'POST' });
    setIsLiking(false);
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.renderedHtml }} />
      <button disabled={isLiking} onClick={handleLike}>
        点赞 ({post.likeCount})
      </button>
    </article>
  );
}

// vite.config.ts —— 代码分割交给 Rollup 处理
export default {
  build: {
    rollupOptions: {
      output: { manualChunks: { vendor: ['react', 'react-dom', 'react-router'] } },
    },
  },
};

结论

在内容繁重、SEO 关键的场景中,RSC 带来可衡量的收益:react.dev 的示例中,75K gzip 大小的库完全不会进入客户端。在高交互的内部面板(仪表盘、后台管理)中,client-first 方案更简单,出错面也更小。一个真实存在的关键警告是:把完整对象作为 prop 从 Server 传递到 Client,会把该对象的所有字段都写入 flight payload;如果边界划错,收益会悄悄流失。请用测量数据做决定,而不是跟风。

获取免费咨询
常见问题

常见问题

在内容繁重、SEO 关键、首屏加载(LCP/TTI)很重要的场景(博客、产品页、对比页)中,RSC 能带来可衡量的收益——服务端获取数据,客户端 JS 更少。根据 Next.js 16.3 官方数据,服务端渲染改用 Node.js 原生流之后,在高负载下可多处理 22% 的请求。在高交互的内部面板(仪表盘、后台管理)中,client-first 通常提供更简单的开发体验。

相关博客文章

查看全部文章
全部对比