什么是 ‘Streaming Promises’?如何在 RSC 中将还未完成的 Promise 传递给客户端组件进行局部渲染?

各位同仁,

欢迎来到今天的技术讲座。我们将深入探讨一个在现代 React 应用,特别是 React Server Components (RSC) 生态系统中日益重要的概念——"Streaming Promises"。这个主题不仅关乎性能优化,更触及了我们如何思考和构建服务器与客户端之间的数据流,以及如何利用 React 18 及其并发特性来提供卓越的用户体验。

在 React 18 之前,数据获取通常是客户端的职责,或者通过服务器端渲染(SSR)在服务器上完成所有数据获取后,再将完整 HTML 发送给客户端。这两种模式各有优劣,但都面临一些挑战:客户端数据获取可能导致瀑布效应和较慢的初始加载时间;而传统 SSR 虽然解决了初始加载速度,但意味着整个页面必须等待所有数据都准备好才能发送,这可能导致用户等待时间过长,并且服务器资源利用效率不高。

React Server Components 的出现,旨在弥合服务器渲染和客户端渲染之间的鸿沟,允许开发者在服务器上渲染部分 UI,并将它们作为 React 组件树的一部分流式传输到客户端。RSC 的核心优势在于:

  • 零捆绑体积(Zero-bundle size for Server Components):服务器组件的代码永远不会发送到客户端,从而减小了客户端 JavaScript 包的体积。
  • 访问后端资源:服务器组件可以直接访问数据库、文件系统或其他后端服务,无需额外的 API 层。
  • 提升性能:通过在服务器上完成大部分工作,减少了客户端的计算负担和网络请求,从而加快了页面加载速度和交互响应。
  • 更简单的开发模型:将数据获取和 UI 渲染逻辑更紧密地结合在服务器上,简化了开发流程。

然而,RSC 带来了新的挑战,尤其是在处理异步数据时。传统上,我们在 React 组件中使用 useEffectuseState 来管理异步操作。但在 RSC 中,组件本身可以是 async 函数,可以直接使用 await 关键字。这为我们处理数据带来了新的可能性,也引出了“Streaming Promises”这一概念。


一、RSC 中的数据获取与异步组件

在 React Server Components 中,组件被视为 async 函数,这使得直接在组件内部使用 await 关键字进行数据获取成为可能。这种模式极大地简化了数据获取逻辑,因为它避免了 useEffectuseState 或 SWR/React Query 等客户端库的复杂性。

考虑一个简单的服务器组件,它需要从数据库获取一些数据:

// app/dashboard/page.tsx (这是一个服务器组件)
import { fetchUserProfile } from '@/lib/data'; // 假设这是从数据库获取数据的函数

interface UserProfile {
  id: string;
  name: string;
  email: string;
  // ... 其他字段
}

export default async function DashboardPage() {
  const userProfile: UserProfile = await fetchUserProfile(); // 直接 await 数据

  return (
    <div className="dashboard-container">
      <h1>欢迎, {userProfile.name}!</h1>
      <p>您的邮箱: {userProfile.email}</p>
      {/* 更多仪表盘内容 */}
    </div>
  );
}

在这个例子中,DashboardPage 是一个 async 组件。当 React 渲染它时,它会执行 fetchUserProfile() 并等待其完成。一旦数据准备好,组件就会继续渲染,并将最终的 HTML/React 元素序列化并通过流发送到客户端。

这很好地解决了单个组件的数据获取问题。但如果我们的页面有多个独立的数据源,并且它们之间没有严格的顺序依赖关系,或者其中一些数据需要较长时间才能获取,那么等待所有数据都完成后再发送整个页面,可能会导致用户看到一个空白页面的时间过长。这就是“Streaming Promises”和 Suspense 发挥作用的地方。


二、Suspense 与 RSC 中的数据流

Suspense 是 React 18 引入的一个强大特性,它允许组件“暂停”渲染,直到某些异步操作完成。在 RSC 环境下,Suspense 扮演着至关重要的角色,因为它与服务器组件的 async 能力结合,实现了真正的流式 HTML 传输。

当一个 async 服务器组件被 Suspense 边界包裹时,如果该组件内部的数据获取操作(例如 await fetchUserProfile())尚未完成,React 不会等待它。相反,它会立即渲染 Suspensefallback prop 提供的加载状态,并将这个加载状态的 HTML 片段流式传输到客户端。一旦数据获取完成,React 就会将实际组件内容的 HTML 片段流式传输到客户端,替换掉之前的 fallback 内容。

这种机制有效地打破了传统 SSR 的“全有或全无”模式,实现了页面内容的渐进式加载。用户可以更快地看到页面的部分内容(即使是加载指示器),而不是长时间的空白。

考虑一个更复杂的仪表盘,包含多个独立的数据面板:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { fetchUserProfile, fetchRecentOrders, fetchAnalyticsData } from '@/lib/data';

// 假设这些是服务器组件
async function UserProfileSection() {
  const profile = await fetchUserProfile();
  return (
    <section>
      <h2>个人资料</h2>
      <p>姓名: {profile.name}</p>
      <p>邮箱: {profile.email}</p>
    </section>
  );
}

async function RecentOrdersSection() {
  const orders = await fetchRecentOrders();
  return (
    <section>
      <h2>最新订单</h2>
      <ul>
        {orders.map(order => (
          <li key={order.id}>{order.productName} - ${order.amount}</li>
        ))}
      </ul>
    </section>
  );
}

async function AnalyticsSection() {
  const analytics = await fetchAnalyticsData();
  return (
    <section>
      <h2>分析数据</h2>
      <p>总销售额: ${analytics.totalSales}</p>
      <p>活跃用户: {analytics.activeUsers}</p>
    </section>
  );
}

export default function DashboardPage() {
  return (
    <div className="dashboard-layout">
      <h1>仪表盘</h1>
      <div className="grid-container">
        <Suspense fallback={<div className="loading-card">加载用户资料...</div>}>
          <UserProfileSection />
        </Suspense>
        <Suspense fallback={<div className="loading-card">加载订单...</div>}>
          <RecentOrdersSection />
        </Suspense>
        <Suspense fallback={<div className="loading-card">加载分析...</div>}>
          <AnalyticsSection />
        </Suspense>
      </div>
    </div>
  );
}

在这个例子中:

  1. DashboardPage 立即渲染,并发送其骨架 HTML。
  2. UserProfileSectionRecentOrdersSectionAnalyticsSection 内部的数据获取是并发执行的。
  3. 如果任何一个数据获取操作尚未完成,其对应的 Suspense 边界就会渲染 fallback 内容,并将其流式传输到客户端。
  4. 一旦某个数据获取完成,React 就会将该组件的实际内容流式传输到客户端,替换掉之前的 fallback

这实现了真正意义上的“Streaming Promises”——并非将 Promise 对象本身发送到客户端,而是将 Promise 解决后的数据以流的方式发送,并通过 Suspense 管理加载状态的切换。


三、"Streaming Promises" 到客户端组件的挑战与误解

现在,我们来解决核心问题:如何在 RSC 中将还未完成的 Promise 传递给客户端组件进行局部渲染?

首先,我们需要明确一个关键点:你不能将一个 JavaScript Promise 对象本身从服务器序列化并直接传递给客户端组件。

JavaScript Promise 是一个运行时对象,它包含着执行上下文、状态(pending, fulfilled, rejected)以及回调函数等信息。这些信息在服务器和客户端是不同的运行时环境,无法直接跨越网络边界进行序列化和反序列化。当你尝试将一个复杂的 JavaScript 对象(如 Promise、函数、类实例等)作为 props 传递给客户端组件时,React 会尝试将这些 props 序列化成 JSON 或其他可传输的格式。对于 Promise,这种序列化通常会导致其丢失其异步行为和状态,或者直接报错。

那么,当人们谈论“Streaming Promises to Client”时,他们究竟指的是什么?

他们通常指的是一种模式,即:

  1. 服务器组件发起异步数据获取(返回一个 Promise)。
  2. 服务器组件将这个“未解决的” Promise 作为 prop 传递给一个客户端组件。
  3. 客户端组件使用 React 18 的 use Hook 来“解包”这个 Promise。
  4. 客户端组件的父级服务器组件用 Suspense 边界包裹该客户端组件,以处理加载状态。

这种模式的精髓在于,Promise 的实际解决过程仍然发生在服务器上(或至少其结果是在服务器上等待的),但客户端组件能够以一种声明式的方式“订阅”这个结果,并在结果可用时进行渲染,而无需在客户端重新发起数据请求。

让我们深入探讨这个机制。


四、利用 use Hook 和 Suspense 实现客户端组件的局部渲染

React 18 引入了一个新的 Hook:use。这个 Hook 旨在与 Promise 配合使用,它可以在组件内部同步地“解包”一个 Promise 的结果,而无需 async/await 语法。它的主要应用场景之一就是在客户端组件中消费从服务器组件传递下来的 Promise。

use Hook 的工作原理是:

  • 它接受一个 Promise 作为参数。
  • 如果 Promise 处于 pending 状态,use Hook 会导致组件暂停渲染,并向上冒泡,直到遇到最近的 Suspense 边界。
  • 如果 Promise 处于 fulfilled 状态,use Hook 返回 Promise 的解决值。
  • 如果 Promise 处于 rejected 状态,use Hook 抛出错误,并向上冒泡,直到遇到最近的 Error Boundary

结合 RSC,use Hook 使得“将 Promise 传递给客户端组件”这一概念得以实现,但其背后的机制是复杂的。当一个服务器组件将一个 Promise 作为 prop 传递给一个客户端组件时,React 实际上并没有将 Promise 对象本身序列化。相反,它标记了该 prop 是一个异步操作的结果,并在服务器上开始等待这个 Promise 解决。当 Promise 解决后,其结果数据才会被序列化并通过流发送到客户端。客户端组件中的 use Hook 实际上是在等待这个通过流传输过来的已解决数据。

核心流程如下:

  1. 服务器组件 (RSC) 内部:

    • 定义一个 async 函数来获取数据,该函数返回一个 Promise。
    • 将这个 Promise 对象本身作为 prop 传递给一个客户端组件。
    • Suspense 边界包裹这个客户端组件的调用。
  2. 客户端组件 ('use client') 内部:

    • 接收这个 Promise prop。
    • 使用 use(promiseProp) 来同步地获取 Promise 的解决值。
  3. React 运行时:

    • 当服务器开始渲染时,它会遇到服务器组件。
    • 服务器组件调用数据获取函数,获得一个 Promise。
    • 服务器组件将这个 Promise 作为 prop 传递给一个客户端组件。
    • React 会检测到这个 Promise prop,并在服务器上开始等待它。
    • 同时,由于客户端组件被 Suspense 包裹,React 会流式传输 Suspensefallback 内容到客户端。
    • 一旦服务器上的 Promise 解决,React 会将解决后的数据序列化,并通过流发送到客户端。
    • 客户端接收到数据后,use Hook 在客户端组件中能够获取到这个数据,从而触发客户端组件的重新渲染,替换掉 fallback 内容。

通过这种方式,客户端组件看似直接消费了一个服务器上的 Promise,但实际上是在 React 框架的协调下,实现了数据在服务器端获取、等待、序列化和客户端端消费的无缝衔接。


五、详细代码示例:利用 use Hook 在客户端组件中消费服务器 Promise

我们来构建一个具体的例子。假设我们有一个用户仪表盘,其中有一个部分需要显示用户的活动日志。为了更好地交互性,我们决定将活动日志部分做成一个客户端组件。然而,活动日志的数据获取逻辑我们希望仍然在服务器上完成。

项目结构:

app/
├── dashboard/
│   ├── page.tsx          // 服务器组件:仪表盘主页
│   └── UserActivityLog.tsx // 客户端组件:显示用户活动日志
├── lib/
│   └── data.ts           // 模拟数据获取函数

1. lib/data.ts (模拟数据获取)

// lib/data.ts
interface ActivityLog {
  id: string;
  action: string;
  timestamp: string;
}

export async function fetchUserActivityLogs(): Promise<ActivityLog[]> {
  console.log('--- Server: 开始获取用户活动日志 ---');
  return new Promise(resolve => {
    setTimeout(() => {
      const logs: ActivityLog[] = [
        { id: '1', action: '登录系统', timestamp: new Date().toLocaleString() },
        { id: '2', action: '查看报告 A', timestamp: new Date(Date.now() - 60 * 1000).toLocaleString() },
        { id: '3', action: '更新个人资料', timestamp: new Date(Date.now() - 5 * 60 * 1000).toLocaleString() },
      ];
      console.log('--- Server: 用户活动日志获取完成 ---');
      resolve(logs);
    }, 3000); // 模拟 3 秒的网络延迟
  });
}

export async function fetchUserProfile(): Promise<{ name: string; email: string }> {
  console.log('--- Server: 开始获取用户资料 ---');
  return new Promise(resolve => {
    setTimeout(() => {
      const profile = { name: '张三', email: '[email protected]' };
      console.log('--- Server: 用户资料获取完成 ---');
      resolve(profile);
    }, 1000); // 模拟 1 秒的网络延迟
  });
}

2. app/dashboard/UserActivityLog.tsx (客户端组件)

这个组件将接收一个 Promise 作为 prop,并使用 use Hook 来消费它。

// app/dashboard/UserActivityLog.tsx
'use client'; // 标记为客户端组件

import { use } from 'react';

interface ActivityLog {
  id: string;
  action: string;
  timestamp: string;
}

interface UserActivityLogProps {
  // 注意:这里我们声明接收一个 Promise<ActivityLog[]>
  activityLogsPromise: Promise<ActivityLog[]>;
}

export default function UserActivityLog({ activityLogsPromise }: UserActivityLogProps) {
  // 使用 use Hook "解包" Promise
  // 如果 activityLogsPromise 仍在 pending,这里会抛出 Suspend 信号
  // 如果 Promise fulfilled,则 activityLogs 会是其解决值
  // 如果 Promise rejected,则会抛出错误
  const activityLogs = use(activityLogsPromise);

  return (
    <div className="user-activity-log-container">
      <h3>用户活动日志 (客户端组件)</h3>
      {activityLogs.length === 0 ? (
        <p>暂无活动记录。</p>
      ) : (
        <ul>
          {activityLogs.map(log => (
            <li key={log.id}>
              <strong>{log.action}</strong> 于 {log.timestamp}
            </li>
          ))}
        </ul>
      )}
      <button onClick={() => alert('客户端交互:刷新日志功能待实现!')}>刷新日志</button>
    </div>
  );
}

3. app/dashboard/page.tsx (服务器组件)

这个服务器组件将获取两个数据:用户资料(直接在服务器组件中 await)和用户活动日志(获取 Promise 并传递给客户端组件)。

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { fetchUserProfile, fetchUserActivityLogs } from '@/lib/data';
import UserActivityLog from './UserActivityLog'; // 导入客户端组件

// 假设我们有一个独立的服务器组件来显示用户资料
async function UserProfileDisplay() {
  const profile = await fetchUserProfile(); // 直接在服务器组件中等待
  return (
    <div className="user-profile-card">
      <h2>用户信息 (服务器组件)</h2>
      <p>姓名: {profile.name}</p>
      <p>邮箱: {profile.email}</p>
    </div>
  );
}

export default async function DashboardPage() {
  // 获取用户资料的 Promise,并直接在此服务器组件中 await
  // 这样,UserProfileDisplay 会等待这个数据加载完成
  // 这种方式适合不需要客户端交互且加载时间短的数据
  // 注意:UserProfileDisplay 组件内部已经 await 了,这里只是为了展示两种不同的 await 策略
  // const profilePromise = fetchUserProfile();
  // const userProfile = await profilePromise;

  // 获取用户活动日志的 Promise,但我们不在这里 await 它
  // 而是将这个 Promise 本身传递给客户端组件
  const userActivityLogsPromise = fetchUserActivityLogs();

  return (
    <div className="dashboard-page-container">
      <h1>我的仪表盘</h1>

      {/* 用户资料部分:直接在服务器上等待数据并渲染 */}
      <Suspense fallback={<div className="loading-card">加载用户资料...</div>}>
        <UserProfileDisplay />
      </Suspense>

      <hr style={{ margin: '20px 0' }} />

      {/* 用户活动日志部分:将 Promise 传递给客户端组件,并用 Suspense 包裹 */}
      {/* 客户端组件会使用 use Hook 消费这个 Promise */}
      <Suspense fallback={<div className="loading-card">加载活动日志 (客户端组件)...</div>}>
        <UserActivityLog activityLogsPromise={userActivityLogsPromise} />
      </Suspense>
    </div>
  );
}

运行效果分析:

  1. 当用户访问 /dashboard 页面时,Next.js 服务器开始渲染 DashboardPage
  2. DashboardPage 调用 fetchUserProfile()fetchUserActivityLogs()。这两个函数立即返回 Promise,数据获取操作在服务器后台开始并行执行。
  3. UserProfileDisplay 组件内部 await fetchUserProfile()。由于它被 Suspense 包裹,如果 fetchUserProfile 未完成,React 会流式传输 加载用户资料... 的 HTML。
  4. UserActivityLog 组件的 activityLogsPromise prop 被赋予了 fetchUserActivityLogs() 返回的 Promise。
  5. 由于 UserActivityLog 是一个客户端组件,并且它的父级 Suspense 边界包裹了它,React 会流式传输 加载活动日志 (客户端组件)... 的 HTML。
  6. fetchUserProfile() 模拟 1 秒后完成。服务器会立即将 UserProfileDisplay 的实际 HTML 内容流式传输到客户端,替换掉其 fallback
  7. fetchUserActivityLogs() 模拟 3 秒后完成。服务器会将解决后的活动日志数据序列化,并通过 React 的流机制发送到客户端。
  8. 客户端接收到活动日志数据后,UserActivityLog 组件中的 use(activityLogsPromise) 会获取到这些数据,然后 UserActivityLog 重新渲染,显示真实的活动日志列表,替换掉 加载活动日志...fallback

通过这个过程,用户会先看到页面的骨架和两个加载指示器。1秒后,用户资料卡片出现。3秒后,活动日志列表出现,且活动日志部分因为是客户端组件,具备了客户端交互能力(例如刷新按钮)。


六、深入理解背后的机制:React 的序列化与水合

为了更好地理解为什么这种“传递 Promise”的模式能够工作,我们需要了解 React 在 RSC 和流式 SSR 中的序列化与水合机制。

当一个服务器组件将一个 Promise 作为 prop 传递给一个客户端组件时,React Next.js 框架并不会真的把 Promise 对象本身发送到浏览器。相反,它做了一些更智能的事情:

  1. 服务器端拦截:当 React 在服务器上渲染时,它会检测到传递给客户端组件的 props 中包含了一个 Promise。
  2. Promise 跟踪:React 会在服务器端“记住”这个 Promise,并在后台等待它解决。
  3. 占位符与流
    • 在 Promise 解决之前,如果这个客户端组件被 Suspense 包裹,React 会将 Suspensefallback 内容作为 HTML 流的一部分发送到客户端。
    • 同时,React 会在流中插入一个特殊的占位符(例如一个 <template> 标签),这个占位符包含了客户端组件的标识符以及一个引用,表明这里有一个异步数据需要填充。
  4. 数据流传输:一旦服务器上的 Promise 解决,React 会将 Promise 的结果(即实际数据)序列化为 JSON。这个 JSON 数据会作为另一个流式片段,伴随着一个指令,发送到客户端。这个指令告诉客户端,之前那个占位符对应的数据现在已经可用了。
  5. 客户端水合
    • 当客户端接收到初始 HTML 和 JavaScript 包后,React 开始水合(hydrate)应用。
    • 当客户端组件被水合时,它会尝试访问 activityLogsPromise prop。
    • use(activityLogsPromise) Hook 会在客户端检测到这个特殊的“异步数据引用”。
    • 如果客户端已经从流中接收到并解析了对应的 JSON 数据,use Hook 会立即返回这个数据。
    • 如果数据尚未到达客户端,use Hook 会导致组件暂停,直到数据通过流到达。一旦数据到达,React 就会触发客户端组件的重新渲染。

总结来说, “将 Promise 传递给客户端组件”实际上是 React 框架在服务器和客户端之间建立的一种高级通信协议:服务器负责发起异步操作并等待结果,客户端则通过 use Hook 声明式地等待这个结果,而 React 则负责在两者之间以流的方式传输加载状态和最终数据。这个过程对开发者是透明的,我们只需像传递普通 prop 一样传递 Promise,并用 Suspense 包裹即可。


七、性能考量与最佳实践

这种“Streaming Promises”的模式在 RSC 中带来了显著的性能优势:

  • 减少瀑布效应:多个独立的数据获取可以并行进行,而不需要等待前一个完成才能开始下一个。
  • 更快的首次内容绘制 (FCP):用户可以更快地看到页面的部分内容,即使是加载指示器,而不是长时间的空白。
  • 更快的首次有效绘制 (FMP):关键内容可以优先加载和显示。
  • 更好的用户体验:渐进式加载让应用感觉更快、响应更及时。

何时使用这种模式?

  • 数据需要在服务器端获取,但渲染逻辑或交互需要在客户端:例如,一个复杂的数据表格,数据来自服务器,但排序、过滤、分页等交互需要客户端逻辑。
  • 数据获取时间较长,希望避免阻塞整个页面渲染:将其包裹在 Suspense 中,可以在等待数据时显示加载状态。
  • 数据获取具有并发性:页面上的多个模块可以独立地获取数据,并通过 Suspense 边界并行加载。

注意事项:

  • 不要将 Promise 作为 prop 传递给另一个服务器组件:服务器组件之间的 Promise 传递可以直接通过 await 来解决,无需 use Hook。use Hook 主要用于服务器组件向客户端组件传递 Promise。
  • 错误处理:如果 Promise 最终 rejecteduse Hook 会抛出错误。你需要使用 Error Boundary 来捕获这些错误,并显示一个友好的错误 UI。
  • 可序列化性:虽然 Promise 本身不是直接序列化的,但 Promise 解决后的数据必须是可序列化的(JSON 兼容)。复杂的对象(如函数、类实例、Symbol 等)无法通过这种方式传输。
  • 数据新鲜度:一旦数据从服务器流式传输到客户端并水合,它就变成了静态数据。如果需要实时更新或频繁刷新,客户端组件可能仍然需要发起自己的客户端数据请求。

八、RSC 中数据获取模式比较

下表总结了在 RSC 环境下几种常见的数据获取模式及其适用场景:

特性/模式 async 服务器组件内部 await 服务器组件传递 Promise 给客户端组件 (use Hook) 客户端组件内部 useEffect 发起请求
数据获取位置 服务器 服务器 (发起并等待解决) 客户端
渲染位置 服务器 (静态 HTML 流) 服务器 (初始 fallback HTML 流), 客户端 (数据到达后水合) 客户端
初始加载性能 较好 (通过 Suspense 流式传输) 优秀 (通过 Suspense 流式传输,客户端渐进式水合) 较差 (客户端 JS 加载后才开始请求)
客户端交互性 无 (服务器组件默认无交互) 有 (客户端组件可以添加交互) 有 (客户端组件完全控制交互)
JS 包体积 零 (服务器组件代码不传输) 客户端组件代码需传输 客户端组件代码及数据获取库需传输
访问后端能力 直接访问数据库/文件系统 继承服务器组件的后端访问能力 需要 API 路由或第三方服务
错误处理 SuspenseError Boundary SuspenseError Boundary try/catch 或数据获取库的错误状态
适用场景 无需客户端交互的静态或数据驱动 UI;SEO 友好;首屏关键数据 数据需在服务器获取,但渲染或交互需在客户端进行;渐进式加载非关键数据 高度动态、用户特定、实时性强的数据;客户端缓存管理;复杂交互
复杂性 较低 中等 (理解 use Hook 和 Suspense 的协作) 中等 (管理加载状态、错误、缓存等)

九、错误处理与边界

在流式传输和异步操作中,错误处理至关重要。当一个 Promise 解决失败(即 rejected)时,use Hook 会抛出错误。这个错误会向上冒泡,直到被最近的 Error Boundary 捕获。

一个 Error Boundary 是一个 React 组件,它通过实现 static getDerivedStateFromError()componentDidCatch() 生命周期方法来捕获其子组件树中 JavaScript 错误的。

// app/ErrorWrapper.tsx (一个客户端 Error Boundary)
'use client';

import React from { Component, ErrorInfo, ReactNode } from 'react';

interface ErrorBoundaryProps {
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

class ErrorWrapper extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    // 更新 state 以便下一次渲染将显示回退 UI
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    // 你也可以将错误日志上报给错误报告服务
    console.error("Uncaught error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以渲染任何自定义的 UI
      return (
        <div className="error-boundary">
          <h2>出错了!</h2>
          <p>很抱歉,加载数据时发生问题。</p>
          {this.state.error && <details><pre>{this.state.error.message}</pre></details>}
          <button onClick={() => window.location.reload()}>刷新页面</button>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorWrapper;

然后,你可以在服务器组件中包裹你的客户端组件:

// app/dashboard/page.tsx (片段)
import ErrorWrapper from '@/app/ErrorWrapper'; // 导入 Error Boundary

// ... 其他代码

      <Suspense fallback={<div className="loading-card">加载活动日志 (客户端组件)...</div>}>
        <ErrorWrapper> {/* 在 Suspense 内部包裹 Error Boundary */}
          <UserActivityLog activityLogsPromise={userActivityLogsPromise} />
        </ErrorWrapper>
      </Suspense>

如果 userActivityLogsPromise 最终 rejectedUserActivityLog 中的 use Hook 会抛出错误,这个错误会被 ErrorWrapper 捕获,并显示自定义的错误 UI,而不会崩溃整个应用。


十、展望与未来

"Streaming Promises" 模式,以及其背后的 Suspenseuse Hook,代表了 React 在处理异步数据和优化用户体验方面的一个重要演进。它使得开发者能够更好地利用服务器和客户端的优势,构建出既具备高性能又富有交互性的应用。

随着 React 和 Next.js 的持续发展,我们可以预见这种模式将变得更加成熟和易用。它将进一步模糊服务器端和客户端渲染的界限,让开发者能够以更统一、更声明式的方式来思考和实现复杂的数据流。理解并掌握这一机制,对于构建现代、高性能的 React 应用至关重要。


通过今天的讲座,我们深入探讨了 React Server Components 中“Streaming Promises”这一概念的真正含义和实现机制。我们了解到,尽管我们不能直接将 JavaScript Promise 对象序列化并传递给客户端,但通过 React 18 的 use Hook 和 Suspense 边界,结合服务器组件发起的异步数据获取,我们能够实现一种强大的模式,让客户端组件能够以声明式的方式“消费”服务器端等待的 Promise 结果,从而优化加载体验,实现渐进式局部渲染。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注