React Server Components完全指南:服务端渲染的革命性进化

文章概述

React在去年庆祝了它的10周年生日!在React首次被引入困惑的开发者社区的十年中,它经历了几次演变。React团队在激进变革方面从不羞涩:如果他们发现解决问题的更好方案,就会去实施。
之前,React团队推出了React Server Components,这是最新的范式转变。这是React组件首次可以完全在服务器上运行。

你将学到:

  • React Server Components的核心概念和工作原理
  • 与传统SSR的区别和联系
  • 客户端组件和服务端组件的边界管理
  • 实际应用场景和性能优势

网上对此有很多困惑。许多人对这是什么、如何工作、有什么好处,以及如何与服务端渲染等技术配合使用有很多疑问。


服务端渲染快速入门

为了理解React Server Components的背景,了解服务端渲染(SSR)的工作原理很有帮助。

当我在2015年开始使用React时,大多数React设置使用"客户端"渲染策略。用户会收到一个看起来像这样的HTML文件:

<!DOCTYPE html>
<html>
  <body>
    <div id="root"></div>
    <script src="/static/js/bundle.js"></script>
  </body>
</html>

那个bundle.js脚本包含运行应用程序所需的一切,包括React、其他第三方依赖项,以及我们编写的所有代码。

一旦JS被下载和解析,React就会开始工作,为整个应用程序创建所有DOM节点,并将其放在那个空的<div id="root">中。

这种方法的问题是完成所有工作需要时间。在这一切发生的时候,用户盯着空白屏幕。这个问题往往随时间恶化:我们发布的每个新功能都会向JavaScript包添加更多千字节,延长用户必须坐等的时间。

SSR的工作机制

服务端渲染旨在改善这种体验。服务器不发送空HTML文件,而是渲染我们的应用程序生成实际HTML。用户收到完整的HTML文档。

该HTML文件仍包含<script>标签,因为我们仍需要React在客户端运行来处理交互。但我们配置React在浏览器中工作方式略有不同:它不从头创建所有DOM节点,而是接管现有HTML。这个过程被称为水合(hydration)。

React核心团队成员Dan Abramov这样解释:

水合就像用交互性和事件处理程序的"水"浇灌"干燥"的HTML。

一旦JS包被下载,React会快速运行我们的整个应用程序,构建UI的虚拟草图,并将其"匹配"到真实DOM,附加事件处理程序,触发任何effects等。

SSR的核心流程:

  1. 用户访问网站
  2. Node.js服务器接收请求,立即渲染React应用程序,生成HTML
  3. 新鲜出炉的HTML发送给客户端
  4. 客户端React接管DOM并添加交互性

服务端渲染的变体

"服务端渲染"是一个涵盖多种渲染策略的术语:

静态生成(SSG):在构建应用程序时生成HTML,在部署过程中完成。

动态渲染:用户请求页面时"按需"生成HTML。

我认为"服务端渲染"是一个涵盖多种渲染策略的术语。它们都有一个共同点:初始渲染在Node.js等服务器运行时中进行,使用ReactDOMServer API。何时发生并不重要,无论是按需还是编译时。无论如何,都是服务端渲染。


传统数据获取的问题

让我们谈谈React中的数据获取。通常,我们有两个通过网络通信的独立应用程序:

  • 客户端React应用
  • 服务端REST API

使用React Query、SWR或Apollo等工具,客户端会向后端发出网络请求,后端然后从数据库获取数据并通过网络发送回来。

客户端渲染的数据流

用户请求 → 空HTML → 下载JS → 渲染外壳 → 请求数据 → 渲染内容

这种模式的问题是用户在看到实际内容之前要经历多个加载阶段。首先看到空白页面,然后是加载状态,最后才是真正的内容。

SSR改进的数据流

即使使用服务端渲染,传统模式仍然是:

服务器渲染外壳 → 客户端水合 → 请求数据 → 渲染内容

这确实是改进——外壳比空白页面好——但实际上并没有显著改善用户体验。用户访问应用不是为了看加载屏幕,而是为了看内容。

理想的数据流

为什么不在初始请求期间进行数据库查询,直接向用户发送完全填充的UI?

数据库查询 → 渲染完整应用 → 发送给客户端

但这需要能够给React一块代码,让它专门在服务器上运行来进行数据库查询。这在React中一直不是选项——即使使用服务端渲染,我们所有的组件都在服务器和客户端上渲染。


React Server Components简介

在高层次上,React Server Components是全新范式的名称。在这个新世界中,我们可以创建专门在服务器上运行的组件。这允许我们做诸如直接在React组件内编写数据库查询之类的事情!

以下是"服务器组件"的快速示例:

import db from 'imaginary-db';

async function Homepage() {
  const link = db.connect('localhost', 'root', 'passw0rd');
  const data = await db.query(link, 'SELECT * FROM products');
  
  return (
    <>
      <h1>Trending Products</h1>
      {data.map((item) => (
        <article key={item.id}>
          <h2>{item.title}</h2>
          <p>{item.description}</p>
        </article>
      ))}
    </>
  );
}

export default Homepage;

作为使用React多年的人,这段代码起初对我来说看起来绝对疯狂。

"等等!"我的直觉尖叫道。“函数组件不能是异步的!我们不允许像那样直接在渲染中产生副作用!”

关键理解

关键要理解的是:服务器组件从不重新渲染。它们在服务器上运行一次生成UI。渲染值发送给客户端并锁定。就React而言,这个输出是不可变的,永不改变。

这意味着React API的大部分与服务器组件不兼容:

  • 不能使用state,因为state可以改变,但服务器组件不能重新渲染
  • 不能使用effects,因为effects只在渲染后在客户端运行,而服务器组件从不到达客户端

这也意味着我们在规则方面有更多灵活性。例如,在传统React中,我们需要将副作用放在useEffect回调或事件处理程序中,这样它们不会在每次渲染时重复。但如果组件只运行一次,我们就不用担心这个!

术语澄清

在这个新范式中,我们熟悉的"传统"React组件被称为客户端组件。老实说,我不喜欢这个名字。

"客户端组件"这个名称暗示这些组件只在客户端渲染,但实际上并非如此。客户端组件在客户端和服务器上都渲染。

术语总结:

  • React Server Components:这个新范式的名称
  • 客户端组件:我们知道和喜爱的"标准"React组件的新名称。这是旧事物的新名称
  • 服务器组件:新类型的组件,专门在服务器上渲染。它们的代码不包含在JS包中,因此从不水合或重新渲染

与SSR的关系

React Server Components不是服务端渲染的替代品。你不应该将React Server Components视为"SSR 2.0版"。

相反,我喜欢将其视为两个完美结合的独立拼图块,两种相互补充的特性。

我们仍然依赖服务端渲染生成初始HTML。React Server Components在此基础上构建,允许我们从客户端JavaScript包中省略某些组件,确保它们只在服务器上运行。

实际上,甚至可以在没有服务端渲染的情况下使用React Server Components,尽管在实践中,两者一起使用会获得更好的结果。


兼容环境与使用方式

通常,当新的React功能出现时,我们可以通过将React依赖项升级到最新版本来在现有项目中开始使用它。快速npm install react@latest就可以开始了。

不幸的是,React Server Components不是这样工作的。

我的理解是React Server Components需要与React之外的许多东西紧密集成,比如bundler、服务器和路由器。

目前,只有一种方式开始使用React Server Components,那就是使用Next.js 13.4+,使用他们全新重新架构的"App Router"。

希望将来,更多基于React的框架将开始整合React Server Components。核心React功能只在一个特定工具中可用感觉很尴尬!


指定客户端组件

在这个新的"React Server Components"范式中,默认假定所有组件都是服务器组件。我们必须为客户端组件"选择加入"。

我们通过指定全新的指令来做到这一点:

'use client';

import React from 'react';

function Counter() {
  const [count, setCount] = React.useState(0);
  
  return (
    <button onClick={() => setCount(count + 1)}>
      Current value: {count}
    </button>
  );
}

export default Counter;

顶部的独立字符串'use client'是我们向React发出信号的方式,表明该文件中的组件是客户端组件,应该包含在JS包中以便它们可以在客户端重新渲染。

这可能看起来是创建组件类型的一种非常奇怪的方式,但这种事情是有先例的:JavaScript中的"use strict"指令选择进入"严格模式"。

组件选择策略

一般规则:如果组件可以是服务器组件,就应该是服务器组件。

服务器组件往往更简单、更容易推理。还有性能优势:因为服务器组件不在客户端运行,它们的代码不包含在JavaScript包中。

但我们也不应该将消除尽可能多的客户端组件作为使命!不应该试图优化最少数量的客户端组件。值得记住的是,直到现在,每个React应用中的每个React组件都是客户端组件。

需要客户端组件的场景:

  • 使用state或effects
  • 需要事件处理程序
  • 使用浏览器特定API
  • 需要实时更新的组件

边界概念:理解组件树的分割

我对React Server Components最初的问题之一是:当props改变时会发生什么?

例如,假设我们有这样的服务器组件:

function HitCounter({ hits }) {
  return (
    <div>
      Number of hits: {hits}
    </div>
  );
}

如果hits的值从0变成1会怎样?HitCounter需要重新渲染,但它不能重新渲染,因为它是服务器组件!

客户端边界规则

服务器组件实际上不能孤立地理解。我们必须缩小,采用更全面的视图,考虑应用程序的结构。

关键规则:客户端组件只能导入其他客户端组件。

当我们在组件上添加'use client'指令时,我们创建一个"客户端边界"。这个边界内的所有组件都隐式转换为客户端组件。即使像HitCounter这样的组件没有'use client'指令,在这种特定情况下它们仍会在客户端水合/渲染。

// 组件树示例
App (Server Component)
├── Header (Server Component)  
├── Article (Client Component - 使用state)
│   ├── HitCounter (隐式Client Component)
│   └── Discussion (隐式Client Component)
└── Footer (Server Component)

这意味着我们不必为每个需要在客户端运行的文件添加'use client'。实际上,我们只需要在创建新客户端边界时添加它。

解决边界限制的方法

当我第一次了解到客户端组件不能渲染服务器组件时,这对我来说感觉相当限制。如果我需要在应用程序高层使用state呢?这是否意味着一切都需要成为客户端组件?

在许多情况下,我们可以通过重构应用程序来解决这个限制,使所有者发生变化。

重构前:

'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
import Header from './Header';
import MainContent from './MainContent';

function Homepage() {
  const [colorTheme, setColorTheme] = React.useState('light');
  
  const colorVariables = colorTheme === 'light'
    ? LIGHT_COLORS
    : DARK_COLORS;
    
  return (
    <body style={colorVariables}>
      <Header />
      <MainContent />
    </body>
  );
}

重构后:

// ColorProvider.js
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';

function ColorProvider({ children }) {
  const [colorTheme, setColorTheme] = React.useState('light');
  
  const colorVariables = colorTheme === 'light'
    ? LIGHT_COLORS
    : DARK_COLORS;
    
  return (
    <body style={colorVariables}>
      {children}
    </body>
  );
}

// Homepage.js  
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';

function Homepage() {
  return (
    <ColorProvider>
      <Header />
      <MainContent />
    </ColorProvider>
  );
}

通过这种重构,HeaderMainContent不再隐式转换为客户端组件!

核心理解: 当涉及客户端边界时,父/子关系并不重要。重要的是谁在导入和渲染组件。Homepage决定HeaderMainContent的props,由于Homepage是服务器组件,就没有问题。


底层实现:窥探内部机制

让我们在稍低的层面看看这个。当我们使用服务器组件时,输出是什么样的?实际生成了什么?

从超简单的React应用程序开始:

function Homepage() {
  return (
    <p>
      Hello world!
    </p>
  );
}

当我们在浏览器中访问这个应用时,我们会收到看起来像这样的HTML文档:

<!DOCTYPE html>
<html>
  <body>
    <p>Hello world!</p>
    <script src="/static/js/bundle.js"></script>
    <script>
      self.__next['$Homepage-1'] = {
        type: 'p',
        props: null,
        children: "Hello world!",
      };
    </script>
  </body>
</html>

关键部分解析

  1. HTML内容:我们的React应用程序生成的UI,"Hello world!"段落。这要归功于服务端渲染。

  2. JS包:加载我们的JS包的<script>标签。这个包包括React等依赖项,以及应用程序中使用的任何客户端组件。由于Homepage组件是服务器组件,该组件的代码不包含在此包中。

  3. 内联JS:包含一些内联JS的第二个<script>标签——这是真正有趣的部分。

self.__next['$Homepage-1'] = {
  type: 'p',
  props: null,
  children: "Hello world!",
};

我们实际上是在告诉React:“嘿,我知道你缺少Homepage组件代码,但别担心:这是它渲染的结果”。

通常,当React在客户端水合时,它会快速渲染所有组件,构建应用程序的虚拟表示。它不能为服务器组件这样做,因为代码不包含在JS包中。

所以,我们发送渲染值,服务器生成的虚拟表示。当React在客户端加载时,它重用那个描述而不是重新生成它。

权衡考虑

这确实意味着虽然我们的JS包变小了,HTML文件变大了。我们不是在JS文件中有组件定义,而是在<script>标签中内联组件的返回值。

平均而言,我们仍会通过网络发送更少的总数据,但值得记住服务器组件不是完全免费的。HTML文件被分块并流式传输,所以浏览器仍然可以快速绘制UI,无需等待<script>标签中的所有内容。

不需要服务器的服务器组件

React Server Components与静态或动态渲染策略兼容。当服务器组件在Node.js运行时中渲染时,它们返回的JavaScript对象将被创建。这可以按需发生或在构建期间发生。

这意味着可以在没有服务器的情况下使用React Server Components!我们可以生成一堆静态HTML文件并在任何地方托管它们。实际上,这是Next.js App Router中默认发生的。除非我们真的需要"按需"发生事情,否则所有这些工作都会在构建期间提前进行。


React Server Components的优势

React Server Components是在React中运行服务器专用代码的第一种"官方"方式。如我之前提到的,这在更广泛的React生态系统中并不是真正的新事物;自2016年以来我们就能够在Next.js中运行服务器专用代码!

大区别是我们以前从未有办法在组件内部运行服务器专用代码。

性能优势

最明显的好处是性能。服务器组件不包含在JS包中,这减少了需要下载的JavaScript数量,以及需要水合的组件数量。

性能指标对比:

指标 传统方式 RSC方式 改善
JS Bundle Size 显著减少
水合时间 明显缩短
首次内容绘制 延迟 提前 用户体验提升
交互时间 延迟 提前 响应性提升

功能优势

更令我兴奋的是:我们不再需要在功能与包大小之间做出同样的妥协!

实际案例:语法高亮

大多数技术博客需要某种语法高亮库。合适的语法高亮库,支持所有流行编程语言,将是几兆字节,太大而无法放入JS包。因此,我们必须做出妥协,削减不是关键任务的语言和功能。

但假设我们在服务器组件中进行语法高亮。在这种情况下,库代码实际上都不会包含在JS包中。因此,我们不必做任何妥协,可以使用所有功能。

// 服务器组件中的语法高亮
import { highlight } from 'comprehensive-syntax-highlighter';

function CodeBlock({ code, language }) {
  const highlightedCode = highlight(code, language);
  
  return (
    <pre>
      <code dangerouslySetInnerHTML={{ __html: highlightedCode }} />
    </pre>
  );
}

这就是让我对React Server Components感到兴奋的事情。在JS包中成本过高而无法包含的东西现在可以在服务器上免费运行,为我们的包添加零千字节,并产生更好的用户体验。

开发体验优势

这不仅仅关于性能和用户体验。使用RSC一段时间后,我开始真正欣赏服务器组件是多么轻松。我们永远不必担心依赖数组、过时闭包、记忆化,或任何由事物变化引起的其他复杂东西。

服务器组件的简单性:

  • 无状态管理复杂性
  • 无生命周期方法
  • 无记忆化需求
  • 直接的数据获取
  • 简化的错误处理

实际应用场景

数据获取组件

// 服务器组件:直接数据库查询
import { db } from '@/lib/database';

async function ProductList({ category }) {
  const products = await db.product.findMany({
    where: { category },
    orderBy: { createdAt: 'desc' },
    take: 10
  });
  
  return (
    <div className="product-grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

内容生成组件

// 服务器组件:Markdown渲染
import { remark } from 'remark';
import html from 'remark-html';

async function BlogPost({ slug }) {
  const post = await getPostBySlug(slug);
  const processedContent = await remark()
    .use(html)
    .process(post.content);
    
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ 
        __html: processedContent.toString() 
      }} />
    </article>
  );
}

第三方API集成

// 服务器组件:天气数据
async function WeatherWidget({ city }) {
  const weather = await fetch(
    `https://api.weather.com/v1/current?key=${process.env.WEATHER_API_KEY}&q=${city}`
  ).then(res => res.json());
  
  return (
    <div className="weather-card">
      <h3>{city}天气</h3>
      <p>温度: {weather.temperature}°C</p>
      <p>状况: {weather.condition}</p>
    </div>
  );
}

最佳实践与设计模式

组件架构设计

推荐的组件分层:

App Layout (Server Component)
├── Header (Server Component)
├── Navigation (Client Component - 需要交互)
├── Main Content (Server Component)
│   ├── Article List (Server Component - 数据获取)
│   └── Interactive Features (Client Component)
└── Footer (Server Component)

数据获取策略

// 推荐:组件级数据获取
async function UserProfile({ userId }) {
  const user = await fetchUser(userId);
  const posts = await fetchUserPosts(userId);
  
  return (
    <div>
      <UserInfo user={user} />
      <PostList posts={posts} />
    </div>
  );
}

// 避免:在客户端组件中数据获取
'use client';
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
  
  // 需要处理加载状态...
}

错误处理

// 服务器组件错误边界
async function ProductPage({ productId }) {
  try {
    const product = await fetchProduct(productId);
    
    if (!product) {
      return <ProductNotFound />;
    }
    
    return <ProductDetail product={product} />;
  } catch (error) {
    console.error('Failed to fetch product:', error);
    return <ProductError error={error} />;
  }
}

性能优化策略

减少客户端JavaScript

// 优化前:所有组件都是客户端组件
'use client';
function Dashboard() {
  const [user] = useState(getCurrentUser());
  
  return (
    <div>
      <Header user={user} />
      <Sidebar navigation={NAVIGATION} />
      <MainContent data={user.data} />
      <Footer />
    </div>
  );
}

// 优化后:最小化客户端边界
function Dashboard() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Sidebar /> {/* Server Component */}
      <UserContextProvider> {/* Client Component */}
        <MainContent /> {/* 在客户端边界内 */}
      </UserContextProvider>
      <Footer /> {/* Server Component */}
    </div>
  );
}

智能缓存策略

// 服务器组件缓存
import { cache } from 'react';

const getCachedUser = cache(async (userId) => {
  return await db.user.findUnique({
    where: { id: userId }
  });
});

async function UserProfile({ userId }) {
  const user = await getCachedUser(userId);
  
  return (
    <div>
      <h1>{user.name}</h1>
      <UserStats userId={userId} /> {/* 会重用缓存的用户数据 */}
    </div>
  );
}

流式渲染优化

// 使用Suspense实现流式渲染
import { Suspense } from 'react';

function ProductPage({ productId }) {
  return (
    <div>
      <ProductHeader productId={productId} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={productId} />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <ProductRecommendations productId={productId} />
      </Suspense>
    </div>
  );
}

调试与开发工具

组件类型识别

// 开发环境中标识组件类型
function ComponentTypeIndicator({ children, type }) {
  if (process.env.NODE_ENV === 'development') {
    return (
      <div data-component-type={type}>
        {children}
      </div>
    );
  }
  return children;
}

// 使用
function MyServerComponent() {
  return (
    <ComponentTypeIndicator type="server">
      <div>Server Component Content</div>
    </ComponentTypeIndicator>
  );
}

性能监控

// 服务器组件性能追踪
async function DataHeavyComponent() {
  const startTime = performance.now();
  
  try {
    const data = await expensiveDataOperation();
    
    if (process.env.NODE_ENV === 'development') {
      const endTime = performance.now();
      console.log(`Data fetch took ${endTime - startTime}ms`);
    }
    
    return <DataDisplay data={data} />;
  } catch (error) {
    console.error('Data fetch failed:', error);
    throw error;
  }
}

未来展望:完整的现代React生态

React Server Components只是"现代React"拼图的一部分。

当我们将React Server Components与Suspense和新的流式SSR架构结合时,事情变得真正有趣。它允许我们做这样的事情:

// 未来的React应用架构
function App() {
  return (
    <html>
      <body>
        <Header /> {/* 立即渲染 */}
        <Suspense fallback={<MainSkeleton />}>
          <MainContent /> {/* 流式渲染 */}
        </Suspense>
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar /> {/* 并行渲染 */}
        </Suspense>
        <Footer /> {/* 立即渲染 */}
      </body>
    </html>
  );
}

这种架构允许:

  • 外壳立即渲染和发送
  • 数据获取并行进行
  • 内容流式传输到客户端
  • 渐进式水合
  • 最优的用户体验

生态系统发展

新兴工具和库:

  1. Bright - 为RSC设计的语法高亮包
  2. RSC DevTools - 开发和调试工具
  3. 服务器组件UI库 - 专为服务器组件设计的组件库
  4. 数据获取库 - 原生支持RSC的数据层

框架支持情况

框架 RSC支持 状态 特性
Next.js 13+ 稳定 App Router, 完整支持
Remix 🔄 开发中 实验性支持
Gatsby 📋 计划中 路线图中
Create React App 无计划 社区方案

常见问题与解决方案

迁移策略

渐进式迁移步骤:

  1. 评估现有代码库

    • 识别纯展示组件
    • 找出数据获取逻辑
    • 标记交互性组件
  2. 创建迁移计划

    • 优先迁移叶子组件
    • 逐步向上移动
    • 保持功能稳定
  3. 执行迁移

    • 小步迭代
    • 充分测试
    • 性能监控

常见陷阱

1. 边界混乱

// 错误:尝试在服务器组件中使用客户端特性
function ServerComponent() {
  const [state, setState] = useState(0); // ❌ 错误
  
  useEffect(() => {
    // ❌ 错误
  }, []);
  
  return <div onClick={() => {}}> {/* ❌ 错误 */}</div>;
}

// 正确:明确组件类型和职责
'use client';
function ClientComponent() {
  const [state, setState] = useState(0); // ✅ 正确
  return <div onClick={() => setState(s => s + 1)}>{state}</div>;
}

2. 数据重复获取

// 错误:重复获取相同数据
async function ParentComponent() {
  const user = await fetchUser();
  return (
    <div>
      <UserProfile userId={user.id} /> {/* 会再次获取用户 */}
      <UserPosts userId={user.id} />
    </div>
  );
}

// 正确:传递已获取的数据
async function ParentComponent() {
  const user = await fetchUser();
  return (
    <div>
      <UserProfile user={user} />
      <UserPosts userId={user.id} />
    </div>
  );
}

总结与最佳实践

React Server Components代表了React开发的重大范式转变。它不是简单的技术升级,而是思维方式的改变——从客户端为中心转向服务端和客户端协作。

核心收获:

  1. 理解边界概念 - 服务器组件和客户端组件的分界线是关键
  2. 明智的组件选择 - 能用服务器组件就用服务器组件
  3. 数据获取革命 - 直接在组件中获取数据,无需复杂的状态管理
  4. 性能优化新机会 - 减少JavaScript包大小,提升用户体验
  5. 开发体验改善 - 更简单的心智模型,更少的复杂性

实践指南:

  • 从小项目开始实践RSC概念
  • 逐步迁移现有项目,不要急于求成
  • 充分利用服务器组件的优势(数据获取、内容生成)
  • 保持客户端组件的最小化但不过度优化
  • 关注用户体验而非技术炫技

未来趋势:

  • 更多框架将支持RSC
  • 生态系统工具不断完善
  • 开发模式进一步标准化
  • 性能优化技术持续发展
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐