各位未来的 React 服务器组件大师们,晚上好!
今晚,我们不谈那些枯燥的 API 文档,也不谈那些让你秃头的 useEffect 依赖数组。今晚,我们要聊的是 React 流式传输(Streaming)中的“暗黑料理”——错误处理。具体来说,就是当你的水流(数据流)在管道里流到一半,突然遇到一颗“炸药包”(某个组件挂起或报错)时,我们该如何优雅地处理,既不让整条管道爆裂,又能给用户一个体面的“重试”按钮。
想象一下,你正在给一位挑剔的顾客上菜。你端着一盘热气腾腾的“用户界面”,你告诉顾客:“先生,这道菜正在烹饪中,大约需要 5 秒钟。”顾客点点头,开始等待。
这 5 秒钟里,你正在通过流式传输把菜端上去。突然,你的后厨传来一声巨响——“数据库连接超时!”或者“某个第三方图表库崩溃了!”
这时候,如果你只是傻傻地站在那里,或者把整盘菜都掀翻在地,那这生意就没法做了。我们需要的是一种策略:降级。我们要把那盘炸了的菜撤下来,换上一盘备用的,或者把炸药包挖掉,重新做。
这就是我们今天要探讨的核心:Suspense 块失败后的降级与重试策略。
第一部分:流式传输的幻觉与现实的裂痕
首先,让我们回顾一下 React Server Components (RSC) 的美好愿景。它是如此纯粹,如此干净。你写一个组件,服务器渲染它,然后像水龙头放水一样,一点点地把 HTML、CSS 和 JS 滴进浏览器。用户不需要等待整个页面编译完成,他们可以立刻看到内容,然后随着水流逐渐丰满。
这就像是在看一部没有广告的剧,你一集一集地看,流畅无比。
但是,现实是残酷的。现实充满了网络波动、API 延迟、第三方服务的罢工。
在流式传输中,错误处理比传统渲染要棘手得多。在传统的 React 中,如果一个组件抛出了错误,React 会把它捕获并交给最近的 ErrorBoundary。但在流式传输中,情况变得更像是在跑马拉松。你在半路(流式传输中途)遇到了障碍物。如果障碍物处理不当,整个马拉松(页面渲染)就会停滞,甚至中断。
这就是为什么我们需要 Suspense。Suspense 是流式传输的救生圈。
第二部分:第一层防御——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 的状态。
- Pending:渲染
fallback。 - Resolved:渲染 Promise 解析后的值。
- 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),但杯子里的水还在继续流。UserProfile 的 fallback 会插入到 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 边界
让我们把所有东西组合起来。我们将构建一个场景:一个复杂的仪表盘页面,其中包含三个部分:
- 用户信息:可能失败。
- 股票行情:可能失败。
- 广告位:可能失败。
我们希望即使广告位挂了,用户信息和股票行情也能正常显示。
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>
);
}
代码分析:
- 隔离性:
AdComponent抛出错误,被外层的<Suspense fallback={<div>加载广告中...</div>}>捕获。它显示“加载广告中…”,而不会影响上面的股票和用户。 - 降级:
UserProfileComponent抛出错误,被它内部的usehook 的fallback捕获。它显示“用户加载失败”和重试按钮。 - 流式传输:注意,虽然广告加载失败了,但页面其他部分(股票、用户)依然在流式传输,依然在渲染。
但是!
如果你仔细观察上面的代码,你会发现一个性能问题。每次点击“重试”按钮,UserProfileComponent 会重新渲染,use hook 会重新执行。如果 fetchUserWithRetry 里面包含了复杂的逻辑(比如数据库查询),这会非常消耗资源。
而且,如果 fetchUserWithRetry 内部有副作用(比如发送日志),重试可能会导致副作用重复执行。
第八部分:高级技巧——use Hook 的 fallback 与 Suspense 的嵌套
React 官方推荐了一种更高效的模式:将 use hook 的 fallback 与 Suspense 结合使用。
如果你在 Suspense 内部使用 use hook,并且 use hook 抛出了错误,那么 Suspense 的 fallback 会覆盖 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>
);
}
在这个例子中:
- 如果
fetchUser正在加载,Suspense的 fallback(“正在加载…”)会被渲染。 - 如果
fetchUser抛出了错误,usehook 的fallback(“用户组件错误…”)会被渲染。 Suspense的 fallback 不会显示。
这非常方便,但我们需要结合重试逻辑。
第九部分:终极方案——带重试逻辑的 use Hook 封装
为了兼顾性能和易用性,我们需要一个封装好的 use hook,它能够:
- 接收一个 fetch 函数。
- 自动处理
AbortController。 - 提供重试逻辑。
- 返回
data,error,retry。 - 关键点:当
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>;
}
这又回到了传统模式。
看来,要在流式传输中完美实现“带重试的降级”,我们需要一种混合模式。
最佳实践总结:
- 对于简单的、非关键的数据:直接在组件内部使用
usehook,并给它一个fallback,里面显示错误信息和重试按钮。 - 对于复杂的、关键的组件:使用
Suspense来包裹它,Suspense的fallback显示加载状态。 - 重试逻辑:不要在
usehook 的fallback里写复杂的重试逻辑。使用useAsyncData这样的 hook 来管理状态和重试,然后在组件里根据状态渲染 UI。
第十部分:流式传输中的 Suspense fallback 陷阱
在流式传输中,Suspense 的行为很特殊。
当你在一个流式传输的组件树中使用 Suspense 时,如果 Suspense 内部的组件挂起,Suspense 的 fallback 会被渲染,并且流式传输会暂停,直到 Suspense 内部的组件完成挂起。
但是,如果你在 Suspense 内部使用了 use hook,并且 use hook 抛出了错误,use hook 的 fallback 会被渲染,并且流式传输会继续!
这意味着,如果 Suspense 内部有一个会失败的组件,Suspense 的 fallback 可能永远不会显示(或者显示一瞬间),因为 use hook 的 fallback 会接管渲染。
这是好事还是坏事?
这取决于你的需求。
- 如果你想让整个块都失败,就只用
Suspense。 - 如果你想让块内的某个组件失败,但其他组件继续渲染,就用
usehook。
例子:
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 会捕获这个错误,并渲染 Suspense 的 fallback(“正在加载组件…”)。页面底部会继续渲染。
这看起来没问题。
但是,如果 ErrorProneComponent 里面有一个 use hook,它捕获了错误并显示了一个 fallback 呢?
function ErrorProneComponent() {
const data = use(fetchData(), {
fallback: (err) => <div>组件内部错误: {err.message}</div>
});
return <div>{data}</div>;
}
现在,如果 fetchData 失败,use hook 的 fallback 会被渲染。Suspense 的 fallback 会被忽略。
所以,如果你想显示“加载中…”,然后如果加载失败显示“错误…”,你就不能在组件内部使用 use hook 的 fallback。
结论:
- 纯
Suspense:处理加载中 -> 处理错误。 usehook 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 流式传输应用,需要你像一个建筑师一样思考。
- 隔离性:将你的应用分解成小的、独立的组件。每个组件都应该能够独立处理自己的错误。
- 降级:不要试图完美地渲染所有内容。如果某些内容失败了,显示一个回退 UI。
- 重试:为用户提供重试的选项。使用指数退避策略,避免对服务器造成压力。
- Suspense:正确使用
Suspense来处理加载状态。 - use Hook:使用
usehook 的fallback属性来处理组件内部的错误。
最后的建议:
在流式传输中,错误是不可避免的。不要试图阻止它们,而是要优雅地处理它们。让你的用户知道发生了什么,给他们一个重试的机会,然后继续渲染页面的其余部分。
记住,一个好的错误处理策略,不仅仅是关于代码,更是关于用户体验。它能让你的应用在遇到困难时,依然保持优雅和强大。
好了,各位大师,今晚的讲座到此结束。现在,拿起你们的键盘,去构建那些坚不可摧的流式传输应用吧!别忘了在重试按钮上加点幽默感,比如“再试一次,也许上帝在微笑”。