3.7.4 错误重试
一句话破题
失败不可怕,可怕的是用户不知道怎么重试。给用户一个按钮,胜过一句"请稍后再试"。
核心价值
网络请求失败是常态。好的错误处理应该:告诉用户发生了什么、提供立即重试的方式、必要时自动重试。
错误状态组件
tsx
// components/ErrorState.tsx
import { ReactNode } from 'react'
interface ErrorStateProps {
title?: string
message?: string
onRetry?: () => void
retrying?: boolean
action?: ReactNode
}
export function ErrorState({
title = '加载失败',
message = '请检查网络连接后重试',
onRetry,
retrying = false,
action,
}: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-16 h-16 mb-4 text-red-500">
<AlertCircleIcon className="w-full h-full" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{title}
</h3>
<p className="text-sm text-gray-500 mb-6 text-center max-w-sm">
{message}
</p>
{action || (onRetry && (
<button
onClick={onRetry}
disabled={retrying}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{retrying ? '重试中...' : '重试'}
</button>
))}
</div>
)
}在列表中使用
tsx
function PostList() {
const { data, error, isLoading, refetch, isRefetching } = usePosts()
if (isLoading) {
return <PostListSkeleton />
}
if (error) {
return (
<ErrorState
title="加载文章失败"
message={error.message}
onRetry={refetch}
retrying={isRefetching}
/>
)
}
if (data.length === 0) {
return <Empty title="暂无文章" />
}
return (
<ul className="space-y-4">
{data.map((post) => (
<PostCard key={post.id} post={post} />
))}
</ul>
)
}自动重试
tsx
// hooks/useAutoRetry.ts
import { useState, useCallback } from 'react'
interface UseAutoRetryOptions {
maxRetries?: number
retryDelay?: number
onMaxRetriesReached?: () => void
}
export function useAutoRetry<T>(
fetcher: () => Promise<T>,
options: UseAutoRetryOptions = {}
) {
const { maxRetries = 3, retryDelay = 1000, onMaxRetriesReached } = options
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const execute = useCallback(async () => {
setIsLoading(true)
setError(null)
let lastError: Error | null = null
for (let i = 0; i <= maxRetries; i++) {
try {
const result = await fetcher()
setData(result)
setRetryCount(0)
setIsLoading(false)
return result
} catch (e) {
lastError = e as Error
setRetryCount(i)
if (i < maxRetries) {
await new Promise((r) => setTimeout(r, retryDelay * (i + 1)))
}
}
}
setError(lastError)
setIsLoading(false)
onMaxRetriesReached?.()
throw lastError
}, [fetcher, maxRetries, retryDelay, onMaxRetriesReached])
return { data, error, isLoading, retryCount, execute }
}带重试的 fetch
tsx
// lib/fetchWithRetry.ts
interface RetryOptions {
retries?: number
retryDelay?: number
retryOn?: number[]
}
export async function fetchWithRetry(
url: string,
options?: RequestInit,
retryOptions: RetryOptions = {}
) {
const {
retries = 3,
retryDelay = 1000,
retryOn = [500, 502, 503, 504]
} = retryOptions
let lastError: Error
for (let i = 0; i <= retries; i++) {
try {
const response = await fetch(url, options)
if (!response.ok && retryOn.includes(response.status)) {
throw new Error(`HTTP ${response.status}`)
}
return response
} catch (error) {
lastError = error as Error
if (i < retries) {
await new Promise((r) => setTimeout(r, retryDelay * Math.pow(2, i)))
}
}
}
throw lastError!
}
// 使用
const response = await fetchWithRetry('/api/posts', {}, {
retries: 3,
retryDelay: 1000,
retryOn: [500, 502, 503, 504],
})Toast 提示重试
tsx
// hooks/useMutationWithRetry.ts
import { toast } from 'sonner'
export function useMutationWithRetry<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<TData>
) {
const [isLoading, setIsLoading] = useState(false)
const mutate = async (variables: TVariables) => {
setIsLoading(true)
try {
const result = await mutationFn(variables)
toast.success('操作成功')
return result
} catch (error) {
toast.error('操作失败', {
action: {
label: '重试',
onClick: () => mutate(variables),
},
})
throw error
} finally {
setIsLoading(false)
}
}
return { mutate, isLoading }
}
// 使用
function SubmitButton() {
const { mutate, isLoading } = useMutationWithRetry(submitForm)
return (
<Button onClick={() => mutate(formData)} loading={isLoading}>
提交
</Button>
)
}离线状态处理
tsx
// hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true
)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
// 离线提示组件
export function OfflineBanner() {
const isOnline = useOnlineStatus()
if (isOnline) return null
return (
<div className="fixed bottom-4 left-4 right-4 bg-yellow-500 text-white p-4 rounded-lg shadow-lg">
<p className="font-medium">网络已断开</p>
<p className="text-sm">请检查网络连接,恢复后将自动重试</p>
</div>
)
}在线恢复后自动重试
tsx
// hooks/useRetryOnReconnect.ts
import { useEffect, useRef } from 'react'
import { useOnlineStatus } from './useOnlineStatus'
export function useRetryOnReconnect(callback: () => void) {
const isOnline = useOnlineStatus()
const wasOffline = useRef(false)
useEffect(() => {
if (!isOnline) {
wasOffline.current = true
} else if (wasOffline.current) {
wasOffline.current = false
callback()
}
}, [isOnline, callback])
}
// 使用
function PostList() {
const { refetch } = usePosts()
useRetryOnReconnect(refetch)
// ...
}错误恢复策略
AI 协作指南
核心意图:让 AI 帮你实现健壮的错误重试机制。
需求定义公式:
- 功能描述:为 [场景] 实现错误重试
- 重试策略:[自动/手动/混合]
- 用户反馈:[Toast/内联提示/页面状态]
示例 Prompt:
请实现一个带重试功能的数据获取 Hook:
1. 自动重试 3 次,每次间隔翻倍(指数退避)
2. 只对 5xx 错误和网络错误重试
3. 显示当前重试次数
4. 网络恢复后自动重试验收清单
- [ ] 失败请求有明确的错误提示
- [ ] 提供手动重试按钮
- [ ] 关键请求有自动重试
- [ ] 离线状态有提示
