React 流式传输错误处理:在传输过程中某个 Suspense 块失败后的降级与重试策略

各位未来的 React 服务器组件大师们,晚上好!

今晚,我们不谈那些枯燥的 API 文档,也不谈那些让你秃头的 useEffect 依赖数组。今晚,我们要聊的是 React 流式传输(Streaming)中的“暗黑料理”——错误处理。具体来说,就是当你的水流(数据流)在管道里流到一半,突然遇到一颗“炸药包”(某个组件挂起或报错)时,我们该如何优雅地处理,既不让整条管道爆裂,又能给用户一个体面的“重试”按钮。

想象一下,你正在给一位挑剔的顾客上菜。你端着一盘热气腾腾的“用户界面”,你告诉顾客:“先生,这道菜正在烹饪中,大约需要 5 秒钟。”顾客点点头,开始等待。

这 5 秒钟里,你正在通过流式传输把菜端上去。突然,你的后厨传来一声巨响——“数据库连接超时!”或者“某个第三方图表库崩溃了!”

这时候,如果你只是傻傻地站在那里,或者把整盘菜都掀翻在地,那这生意就没法做了。我们需要的是一种策略:降级。我们要把那盘炸了的菜撤下来,换上一盘备用的,或者把炸药包挖掉,重新做。

这就是我们今天要探讨的核心:Suspense 块失败后的降级与重试策略

第一部分:流式传输的幻觉与现实的裂痕

首先,让我们回顾一下 React Server Components (RSC) 的美好愿景。它是如此纯粹,如此干净。你写一个组件,服务器渲染它,然后像水龙头放水一样,一点点地把 HTML、CSS 和 JS 滴进浏览器。用户不需要等待整个页面编译完成,他们可以立刻看到内容,然后随着水流逐渐丰满。

这就像是在看一部没有广告的剧,你一集一集地看,流畅无比。

但是,现实是残酷的。现实充满了网络波动、API 延迟、第三方服务的罢工。

在流式传输中,错误处理比传统渲染要棘手得多。在传统的 React 中,如果一个组件抛出了错误,React 会把它捕获并交给最近的 ErrorBoundary。但在流式传输中,情况变得更像是在跑马拉松。你在半路(流式传输中途)遇到了障碍物。如果障碍物处理不当,整个马拉松(页面渲染)就会停滞,甚至中断。

这就是为什么我们需要 SuspenseSuspense 是流式传输的救生圈。

第二部分:第一层防御——Suspense 的 fallback

最基础的处理方式,就是在组件树上包裹一层 Suspense,给它一个 fallback 属性。

async function UserProfile({ userId }) {
  const user = await fetchUser(userId);
  return <div>{user.name}</div>;
}

export default function App() {
  return (
    <div>
      <Suspense fallback={<div>加载用户信息中...</div>}>
        <UserProfile userId={123} />
      </Suspense>
      <p>这是页面其他部分,应该立即显示。</p>
    </div>
  );
}

在这个例子中,如果 fetchUser 很慢,或者抛出错误(虽然 fetch 通常不会,但假设它内部逻辑复杂),Suspense 会渲染那个“加载中”的 div。页面其他部分不受影响,依然会立即显示。

这很好,但这只是“等待”。如果用户等待了 30 秒,服务器崩溃了,UserProfile 抛出了错误,Suspense 的 fallback 会一直显示,用户只会看到“加载用户信息中…”,然后开始怀疑人生。

我们需要更高级的防御。

第三部分:第二层防御——use Hook 的 fallback 属性

React 18 引入了 use hook,它允许你在组件内部等待 Promise。更重要的是,use hook 也有一个 fallback 属性!

这是处理组件内部错误的利器。它的逻辑是这样的:当你调用 use(promise) 时,React 会检查这个 Promise 的状态。

  1. Pending:渲染 fallback
  2. Resolved:渲染 Promise 解析后的值。
  3. Rejected渲染 fallback(并且会把错误作为参数传递进去!)。

这就给了我们一个机会,在组件内部直接处理错误,而不是把错误抛给外层的 Suspense

让我们来写一个会“炸”的组件。

import { use } from 'react';

// 模拟一个可能会失败的 API 请求
async function fetchUserData(userId) {
  // 模拟 50% 的概率失败
  if (Math.random() > 0.5) {
    throw new Error(`用户 ${userId} 不存在或数据损坏`);
  }
  return { name: "React 专家", role: "架构师" };
}

function UserProfile({ userId }) {
  // 这里是魔法发生的地方
  const data = use(fetchUserData(userId), {
    fallback: (error) => (
      <div className="error-box">
        <h3>哎呀,出错了!</h3>
        <p>错误信息:{error.message}</p>
        <button onClick={() => window.location.reload()}>刷新页面</button>
      </div>
    )
  });

  return (
    <div className="user-card">
      <h2>{data.name}</h2>
      <p>{data.role}</p>
    </div>
  );
}

这里有个非常关键的概念:

在流式传输中,当 use hook 遇到错误并渲染 fallback 时,流式传输并没有停止!

这就像你正在往杯子里倒水,突然掉进了一块石头。石头(错误)会让水流(渲染)暂时偏离方向,去展示一个气泡(fallback),但杯子里的水还在继续流。UserProfilefallback 会插入到 DOM 中,而页面其余部分(比如下方的文章列表)依然在继续渲染。

这就是降级的核心:隔离错误。一个组件的崩溃不应该导致整个页面的雪崩。

第四部分:第三层防御——重试策略

现在我们有了错误显示,但用户体验还是很差。用户看到“哎呀,出错了”,然后只能点击刷新页面。如果这是在移动端,刷新简直是噩梦。

我们需要一个重试机制。当错误发生时,我们应该尝试重新获取数据。

但是,React 的 use hook 一旦遇到错误并渲染了 fallback,它就不会再重新执行了。所以,我们需要一个状态来控制重试。

这里我们需要一个技巧:fallback 内部再次调用 use hook!

import { use, useState } from 'react';

// 增强版的 fetch,支持重试
async function fetchUserDataWithRetry(userId, retries = 3) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP 错误: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (retries > 0) {
      // 递归重试
      console.log(`重试中... 剩余次数: ${retries}`);
      await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟延迟
      return fetchUserDataWithRetry(userId, retries - 1);
    }
    throw error; // 抛出最终错误
  }
}

function UserProfile({ userId }) {
  const [retryCount, setRetryCount] = useState(0);

  // 第一次尝试
  const data = use(fetchUserDataWithRetry(userId), {
    fallback: (error) => {
      // 如果重试次数达到上限,或者用户点击了重试按钮
      return (
        <div className="error-box">
          <h3>加载失败</h3>
          <p>{error.message}</p>
          <button 
            onClick={() => setRetryCount(prev => prev + 1)}
          >
            再试一次 ({retryCount + 1}/3)
          </button>
        </div>
      );
    }
  });

  return (
    <div className="user-card">
      <h2>{data.name}</h2>
      <p>{data.role}</p>
    </div>
  );
}

这看起来很完美,对吧? 我们在 fallback 里放了一个按钮,点击它,retryCount 增加,React 重新渲染组件。此时,use hook 会重新执行 fetchUserDataWithRetry

等等!这里有个巨大的陷阱!

如果你直接在组件内部写 fetch,每次 fallback 重新渲染,都会发起一个新的网络请求。这会导致“请求地狱”。如果用户疯狂点击重试按钮,你的服务器会收到成千上万个请求。

而且,更糟糕的是,旧的请求还在进行中。我们不需要它们了,我们需要取消它们。

第五部分:终极武器——AbortController 与 请求去重

为了解决这个问题,我们需要引入 AbortController。它就像是一个交通警察,可以挥手示意正在行驶的车辆(请求)停止。

同时,我们需要一种机制来确保在重试时,旧的请求被取消,且不会发起重复的请求。

让我们重构一下代码。

import { use, useState, useEffect, useRef } from 'react';

// 封装一个带 AbortController 的 fetch
async function fetchWithAbort(signal, url) {
  const response = await fetch(url, { signal });
  if (!response.ok) {
    throw new Error(`网络错误: ${response.status}`);
  }
  return response.json();
}

function UserProfile({ userId }) {
  const [retryCount, setRetryCount] = useState(0);
  // 使用 ref 来存储当前的 AbortController,以便在组件卸载或重试时获取它
  const abortControllerRef = useRef(null);

  useEffect(() => {
    // 组件挂载时创建一个新的 controller
    abortControllerRef.current = new AbortController();
    return () => {
      // 组件卸载时取消请求
      abortControllerRef.current?.abort();
    };
  }, []);

  // 核心逻辑:在 use hook 中使用 abortController
  const data = use(
    fetchWithAbort(abortControllerRef.current.signal, `/api/users/${userId}`),
    {
      fallback: (error) => {
        // 如果错误是 AbortError,说明请求被取消了,我们不需要显示错误
        if (error.name === 'AbortError') return null;

        return (
          <div className="error-box">
            <h3>数据加载失败</h3>
            <p>{error.message}</p>
            <button 
              onClick={() => setRetryCount(prev => prev + 1)}
            >
              重试 ({retryCount + 1})
            </button>
          </div>
        );
      }
    }
  );

  return (
    <div className="user-card">
      <h2>{data.name}</h2>
      <p>{data.role}</p>
    </div>
  );
}

但是,这里还有一个更深层次的问题。

在 React 18 的流式传输中,use hook 的 fallback 渲染是有副作用的。这意味着,当你点击“重试”按钮,fallback 重新渲染,React 再次执行 use hook。这会导致一个新的请求发出。

如果用户点击了 5 次,就会有 5 个请求发出。虽然我们用了 AbortController,但那个被取消的请求依然会浪费服务器资源,而且可能导致竞态条件(Race Condition)——最新的请求回来了,但之前的请求也回来了,React 可能会随机使用其中一个结果,导致状态不一致。

第六部分:完美的重试模式——状态驱动与 use Hook 的分离

为了解决这个问题,我们需要把“重试逻辑”和“数据获取逻辑”彻底分开。

不要在 use hook 的 fallback 里直接写 onClick 触发重试。相反,我们应该让 fallback 去调用一个外部函数,或者更简单的方法:fallback 仅仅是展示一个“重试按钮”,而真正的重试逻辑由状态控制。

但 React 的 use hook 很特殊,它不能在 fallback 里使用 useState。如果我们在 fallback 里用 useState,每次重试都会重新创建一个 hook 实例,导致无限循环。

解决方案:使用 use hook 的 onError 回调(如果可用)或者手动管理 Promise。

实际上,React 目前推荐的做法是:fallback 中渲染一个状态,然后通过 useEffect 监听这个状态的变化,手动触发 Promise。

但这太繁琐了。让我们看看 React 官方文档和一些高级库(如 TanStack Query / React Query)是如何处理这个问题的。它们通常使用一个“缓存层”来管理数据请求的状态。

让我们模拟一个更高级的模式:

import { use, useState, useRef, useEffect } from 'react';

// 一个自定义的 hook,用于管理数据获取、错误、重试
function useAsync(fetcher, options = {}) {
  const { retryCount = 3, retryDelay = 1000 } = options;
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(true);
  const [data, setData] = useState(null);

  const abortControllerRef = useRef(new AbortController());

  // 初始执行
  useEffect(() => {
    const controller = abortControllerRef.current;

    fetcher(controller.signal)
      .then(setData)
      .catch(err => {
        if (err.name === 'AbortError') return; // 忽略取消错误
        setError(err);
      })
      .finally(() => setIsPending(false));

    return () => controller.abort();
  }, [fetcher, retryCount]); // 依赖 fetcher,如果 fetcher 引用变了,就重新请求

  // 重试逻辑
  const retry = () => {
    setIsPending(true);
    setError(null);
    setData(null);
    abortControllerRef.current = new AbortController();

    fetcher(abortControllerRef.current.signal)
      .then(setData)
      .catch(err => {
        if (err.name === 'AbortError') return;
        setError(err);
      })
      .finally(() => setIsPending(false));
  };

  return { data, error, isPending, retry };
}

function UserProfile({ userId }) {
  // 定义 fetcher
  const fetchUser = (signal) => {
    return fetch(`/api/users/${userId}`, { signal }).then(r => r.json());
  };

  const { data, error, retry } = useAsync(fetchUser);

  // 当 use hook 返回 fallback 时,我们在这里渲染 fallback
  if (error) {
    return (
      <div className="error-box">
        <h3>出错了</h3>
        <p>{error.message}</p>
        <button onClick={retry}>重试</button>
      </div>
    );
  }

  if (!data) {
    return <div>加载中...</div>;
  }

  return (
    <div className="user-card">
      <h2>{data.name}</h2>
    </div>
  );
}

等等,这又回到了传统 React 的错误处理模式。

我们要的是流式传输中的错误处理

让我们回到 use hook 的 fallback 属性。这是最优雅的方案。

第七部分:实战演练——构建一个“坚不可摧”的 Suspense 边界

让我们把所有东西组合起来。我们将构建一个场景:一个复杂的仪表盘页面,其中包含三个部分:

  1. 用户信息:可能失败。
  2. 股票行情:可能失败。
  3. 广告位:可能失败。

我们希望即使广告位挂了,用户信息和股票行情也能正常显示。

import { Suspense, use } from 'react';

// 模拟广告组件
function AdComponent() {
  // 模拟 30% 的概率失败
  if (Math.random() > 0.7) {
    throw new Error("广告加载失败:第三方服务超时");
  }
  return <div className="ad-banner">这里是广告</div>;
}

// 模拟股票组件
function StockComponent() {
  const stock = use(fetchStockPrice('AAPL'));
  return <div className="stock">Apple: ${stock.price}</div>;
}

// 模拟用户组件(带重试逻辑)
function UserProfileComponent({ userId }) {
  const [retryKey, setRetryKey] = useState(0);

  // 使用 use hook
  const user = use(fetchUserWithRetry(userId), {
    fallback: (error) => (
      <div className="error-fallback">
        <h3>用户加载失败</h3>
        <p>{error.message}</p>
        <button onClick={() => setRetryKey(k => k + 1)}>重试</button>
      </div>
    )
  });

  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>重试次数: {retryKey}</p>
    </div>
  );
}

// 服务器组件
export default function Dashboard() {
  return (
    <div className="dashboard">
      <h1>仪表盘</h1>

      <Suspense fallback={<div>加载股票中...</div>}>
        <StockComponent />
      </Suspense>

      <Suspense fallback={<div>加载用户中...</div>}>
        <UserProfileComponent userId={123} />
      </Suspense>

      <Suspense fallback={<div>加载广告中...</div>}>
        <AdComponent />
      </Suspense>
    </div>
  );
}

代码分析:

  1. 隔离性AdComponent 抛出错误,被外层的 <Suspense fallback={<div>加载广告中...</div>}> 捕获。它显示“加载广告中…”,而不会影响上面的股票和用户。
  2. 降级UserProfileComponent 抛出错误,被它内部的 use hook 的 fallback 捕获。它显示“用户加载失败”和重试按钮。
  3. 流式传输:注意,虽然广告加载失败了,但页面其他部分(股票、用户)依然在流式传输,依然在渲染。

但是!

如果你仔细观察上面的代码,你会发现一个性能问题。每次点击“重试”按钮,UserProfileComponent 会重新渲染,use hook 会重新执行。如果 fetchUserWithRetry 里面包含了复杂的逻辑(比如数据库查询),这会非常消耗资源。

而且,如果 fetchUserWithRetry 内部有副作用(比如发送日志),重试可能会导致副作用重复执行。

第八部分:高级技巧——use Hook 的 fallbackSuspense 的嵌套

React 官方推荐了一种更高效的模式:use hook 的 fallbackSuspense 结合使用。

如果你在 Suspense 内部使用 use hook,并且 use hook 抛出了错误,那么 Suspensefallback 会覆盖 use hook 的 fallback

这意味着,你可以用 Suspense 来处理“加载中”的状态,用 use hook 的 fallback 来处理“错误”的状态。

function UserProfileComponent({ userId }) {
  const user = use(fetchUser(userId), {
    fallback: (error) => <div>用户组件错误: {error.message}</div>
  });

  return <div>{user.name}</div>;
}

export default function App() {
  return (
    <Suspense fallback={<div>正在加载...</div>}>
      <UserProfileComponent userId={123} />
    </Suspense>
  );
}

在这个例子中:

  1. 如果 fetchUser 正在加载,Suspense 的 fallback(“正在加载…”)会被渲染。
  2. 如果 fetchUser 抛出了错误,use hook 的 fallback(“用户组件错误…”)会被渲染。
  3. Suspense 的 fallback 不会显示。

这非常方便,但我们需要结合重试逻辑。

第九部分:终极方案——带重试逻辑的 use Hook 封装

为了兼顾性能和易用性,我们需要一个封装好的 use hook,它能够:

  1. 接收一个 fetch 函数。
  2. 自动处理 AbortController
  3. 提供重试逻辑。
  4. 返回 data, error, retry
  5. 关键点:当 retry 被调用时,它应该触发一次新的请求,并清除旧的请求。

让我们来写这个终极封装。

import { use, useState, useRef, useCallback } from 'react';

// 定义返回类型
type UseAsyncResult<T> = {
  data: T | null;
  error: Error | null;
  retry: () => void;
  isPending: boolean;
};

// 封装函数
function useAsyncData<T>(fetchFn: (signal: AbortSignal) => Promise<T>, options?: { initialRetryCount?: number }): UseAsyncResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isPending, setIsPending] = useState(true);
  const [retryCount, setRetryCount] = useState(options?.initialRetryCount || 0);

  const abortControllerRef = useRef<AbortController | null>(null);

  // 执行请求的核心函数
  const executeRequest = useCallback(async (signal: AbortSignal) => {
    try {
      const result = await fetchFn(signal);
      setData(result);
      setError(null);
      setIsPending(false);
    } catch (err) {
      if (err instanceof Error) {
        setError(err);
        setIsPending(false);
      }
    }
  }, [fetchFn]);

  // 初始加载
  useEffect(() => {
    const controller = new AbortController();
    abortControllerRef.current = controller;
    setIsPending(true);
    executeRequest(controller.signal);

    return () => {
      controller.abort();
    };
  }, [executeRequest]);

  // 重试函数
  const retry = useCallback(() => {
    const controller = new AbortController();
    abortControllerRef.current = controller;
    setRetryCount(prev => prev + 1);
    setIsPending(true);
    setError(null);
    executeRequest(controller.signal);
  }, [executeRequest]);

  return { data, error, retry, isPending };
}

// 使用示例
function UserProfile({ userId }) {
  const fetchUser = async (signal: AbortSignal) => {
    const res = await fetch(`/api/users/${userId}`, { signal });
    if (!res.ok) throw new Error("User not found");
    return res.json();
  };

  const { data, error, retry, isPending } = useAsyncData(fetchUser);

  // 使用 use hook 的 fallback 属性
  const user = use(
    Promise.resolve(isPending ? Promise.reject(new Error("Loading...")) : data), 
    {
      fallback: (err) => (
        <div className="error-container">
          {err.message === "Loading..." ? (
             <div>加载中...</div>
          ) : (
            <>
              <h3>出错了</h3>
              <p>{error?.message}</p>
              <button onClick={retry}>重试 ({retryCount})</button>
            </>
          )}
        </div>
      )
    }
  );

  if (error?.message === "Loading...") return null; // 避免渲染两次 fallback

  return (
    <div className="user-card">
      {data && <h2>{data.name}</h2>}
    </div>
  );
}

等等,上面的代码有点绕。

Promise.resolve(...) 这一行是为了欺骗 use hook,让它认为这是一个 Promise。如果 isPending 是 true,我们抛出一个错误(“Loading…”),use hook 就会渲染 fallback。如果 isPending 是 false,我们返回 data

但这有个问题:use hook 的 fallback 会覆盖 Suspense 的 fallback。

让我们简化一下。我们不需要把所有逻辑都塞进 use hook 的 fallback。我们可以直接在组件主体里写逻辑。

function UserProfile({ userId }) {
  const fetchUser = async (signal: AbortSignal) => {
    const res = await fetch(`/api/users/${userId}`, { signal });
    if (!res.ok) throw new Error("User not found");
    return res.json();
  };

  const { data, error, retry, isPending } = useAsyncData(fetchUser);

  if (isPending) return <div>加载中...</div>;
  if (error) return <div onClick={retry}>出错了: {error.message} (点击重试)</div>;

  return <div>{data.name}</div>;
}

这又回到了传统模式。

看来,要在流式传输中完美实现“带重试的降级”,我们需要一种混合模式。

最佳实践总结:

  1. 对于简单的、非关键的数据:直接在组件内部使用 use hook,并给它一个 fallback,里面显示错误信息和重试按钮。
  2. 对于复杂的、关键的组件:使用 Suspense 来包裹它,Suspensefallback 显示加载状态。
  3. 重试逻辑:不要在 use hook 的 fallback 里写复杂的重试逻辑。使用 useAsyncData 这样的 hook 来管理状态和重试,然后在组件里根据状态渲染 UI。

第十部分:流式传输中的 Suspense fallback 陷阱

在流式传输中,Suspense 的行为很特殊。

当你在一个流式传输的组件树中使用 Suspense 时,如果 Suspense 内部的组件挂起,Suspensefallback 会被渲染,并且流式传输会暂停,直到 Suspense 内部的组件完成挂起。

但是,如果你在 Suspense 内部使用了 use hook,并且 use hook 抛出了错误,use hook 的 fallback 会被渲染,并且流式传输会继续

这意味着,如果 Suspense 内部有一个会失败的组件,Suspensefallback 可能永远不会显示(或者显示一瞬间),因为 use hook 的 fallback 会接管渲染。

这是好事还是坏事?

这取决于你的需求。

  • 如果你想让整个块都失败,就只用 Suspense
  • 如果你想让块内的某个组件失败,但其他组件继续渲染,就用 use hook。

例子:

export default function Page() {
  return (
    <div>
      <h1>页面标题</h1>

      <Suspense fallback={<div>正在加载组件...</div>}>
        <ErrorProneComponent /> {/* 如果这个组件失败,Suspense fallback 会显示 */}
      </Suspense>

      <h2>页面底部</h2>
    </div>
  );
}

function ErrorProneComponent() {
  const data = use(fetchData());
  return <div>{data}</div>;
}

如果 fetchData 失败,ErrorProneComponent 会抛出错误。React 会捕获这个错误,并渲染 Suspensefallback(“正在加载组件…”)。页面底部会继续渲染。

这看起来没问题。

但是,如果 ErrorProneComponent 里面有一个 use hook,它捕获了错误并显示了一个 fallback 呢?

function ErrorProneComponent() {
  const data = use(fetchData(), {
    fallback: (err) => <div>组件内部错误: {err.message}</div>
  });
  return <div>{data}</div>;
}

现在,如果 fetchData 失败,use hook 的 fallback 会被渲染。Suspensefallback 会被忽略

所以,如果你想显示“加载中…”,然后如果加载失败显示“错误…”,你就不能在组件内部使用 use hook 的 fallback

结论:

  • Suspense:处理加载中 -> 处理错误。
  • use hook fallback:处理错误(覆盖加载中)。

第十一部分:实战场景——第三方库的噩梦

大多数第三方库(如 Chart.js, Leaflet, Stripe)都不支持 Suspense。它们通常会在组件挂载时立即请求数据或初始化。

如果在流式传输中使用它们,你会遇到一个巨大的问题:流式传输会在第三方库挂起时暂停。

解决方案是:不要直接在流式传输组件中使用它们。

你应该把它们包裹在 Suspense 里,并且给它们一个 fallback

import { Suspense } from 'react';
import Chart from 'some-chart-library';

function ChartWrapper() {
  return <Chart />;
}

export default function Dashboard() {
  return (
    <div>
      <h1>仪表盘</h1>

      <Suspense fallback={<div className="chart-placeholder">图表加载中...</div>}>
        <ChartWrapper />
      </Suspense>

      <p>其他内容...</p>
    </div>
  );
}

如果图表加载失败怎么办?

大多数第三方库不会抛出错误,它们只是不显示任何东西。或者它们会打印一个警告到控制台。

对于这种情况,你需要一个更高级的封装。

import { Suspense, useState } from 'react';
import Chart from 'some-chart-library';

function ChartWrapper() {
  const [error, setError] = useState(null);

  useEffect(() => {
    // 监听图表库的全局错误
    window.addEventListener('chart-error', (e) => setError(e.detail));

    return () => {
      window.removeEventListener('chart-error', (e) => setError(e.detail));
    };
  }, []);

  if (error) {
    return <div className="chart-error">图表渲染失败: {error.message}</div>;
  }

  return <Chart />;
}

export default function Dashboard() {
  return (
    <div>
      <h1>仪表盘</h1>

      <Suspense fallback={<div className="chart-placeholder">图表加载中...</div>}>
        <ChartWrapper />
      </Suspense>

      <p>其他内容...</p>
    </div>
  );
}

第十二部分:重试策略的细节——指数退避

在处理网络错误时,盲目地重试是不可取的。用户可能因为网络问题点击了 10 次重试,这会给服务器带来巨大的压力。

我们应该使用指数退避 策略。

每次重试时,等待的时间应该是上一次的两倍。

function useAsyncDataWithBackoff<T>(fetchFn: (signal: AbortSignal) => Promise<T>) {
  const [error, setError] = useState<Error | null>(null);
  const [isPending, setIsPending] = useState(true);
  const [retryCount, setRetryCount] = useState(0);
  const abortControllerRef = useRef<AbortController | null>(null);

  const executeRequest = useCallback(async (signal: AbortSignal, delay: number = 0) => {
    if (delay > 0) {
      await new Promise(resolve => setTimeout(resolve, delay));
    }

    try {
      const result = await fetchFn(signal);
      return { type: 'success', data: result };
    } catch (err) {
      return { type: 'error', error: err as Error };
    }
  }, [fetchFn]);

  useEffect(() => {
    const controller = new AbortController();
    abortControllerRef.current = controller;
    setIsPending(true);
    setError(null);

    executeRequest(controller.signal, 0)
      .then(result => {
        if (result.type === 'success') {
          // 处理成功逻辑
        } else {
          // 处理错误逻辑
        }
      })
      .finally(() => setIsPending(false));

    return () => controller.abort();
  }, [executeRequest]);

  const retry = useCallback(() => {
    const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); // 最大延迟 30秒
    executeRequest(abortControllerRef.current!.signal, delay);
    setRetryCount(prev => prev + 1);
  }, [executeRequest, retryCount]);

  return { data: null, error, isPending, retry };
}

第十三部分:并发模式与错误处理

React 18 的并发模式意味着,错误可能会在渲染过程中发生。React 会暂停渲染,处理错误,然后恢复渲染。

这意味着,你可能会看到页面的一部分已经渲染出来了,然后突然又变回了“加载中”,因为一个子组件抛出了错误。

为了解决这个问题,我们需要使用 use hook 的 fallback 属性。它允许你在错误发生时,立即渲染一个 UI,而不会暂停整个页面的渲染。

第十四部分:总结——构建健壮的流式传输应用

构建一个健壮的 React 流式传输应用,需要你像一个建筑师一样思考。

  1. 隔离性:将你的应用分解成小的、独立的组件。每个组件都应该能够独立处理自己的错误。
  2. 降级:不要试图完美地渲染所有内容。如果某些内容失败了,显示一个回退 UI。
  3. 重试:为用户提供重试的选项。使用指数退避策略,避免对服务器造成压力。
  4. Suspense:正确使用 Suspense 来处理加载状态。
  5. use Hook:使用 use hook 的 fallback 属性来处理组件内部的错误。

最后的建议:

在流式传输中,错误是不可避免的。不要试图阻止它们,而是要优雅地处理它们。让你的用户知道发生了什么,给他们一个重试的机会,然后继续渲染页面的其余部分。

记住,一个好的错误处理策略,不仅仅是关于代码,更是关于用户体验。它能让你的应用在遇到困难时,依然保持优雅和强大。

好了,各位大师,今晚的讲座到此结束。现在,拿起你们的键盘,去构建那些坚不可摧的流式传输应用吧!别忘了在重试按钮上加点幽默感,比如“再试一次,也许上帝在微笑”。

发表回复

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