Skip to content

错误处理与重试

概述

在数据获取过程中,错误处理和重试机制是确保应用稳定性和用户体验的关键。从网络错误到服务器异常,合理的错误处理策略能够让应用更加健壮。本文将深入探讨React应用中的错误处理和重试策略。

错误类型

网络错误

jsx
import { useState, useEffect } from 'react';

function NetworkErrorHandling() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [errorType, setErrorType] = useState(null);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        const json = await response.json();
        setData(json);
      } catch (error) {
        // 网络错误判断
        if (error instanceof TypeError && error.message.includes('fetch')) {
          setErrorType('NETWORK_ERROR');
          setError('网络连接失败,请检查您的网络设置');
        }
        // HTTP错误
        else if (error.message.startsWith('HTTP')) {
          setErrorType('HTTP_ERROR');
          setError(error.message);
        }
        // 其他错误
        else {
          setErrorType('UNKNOWN_ERROR');
          setError('未知错误: ' + error.message);
        }
      }
    };
    
    fetchData();
  }, []);
  
  if (error) {
    return (
      <div className={`error error-${errorType.toLowerCase()}`}>
        <h3>错误</h3>
        <p>{error}</p>
        {errorType === 'NETWORK_ERROR' && (
          <button onClick={() => window.location.reload()}>
            重新加载
          </button>
        )}
      </div>
    );
  }
  
  return <div>{JSON.stringify(data)}</div>;
}

HTTP状态码错误

jsx
function HttpErrorHandling() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        
        // 根据状态码处理
        switch (response.status) {
          case 200:
            const json = await response.json();
            setData(json);
            break;
          
          case 400:
            const badRequest = await response.json();
            throw new Error(`请求参数错误: ${badRequest.message}`);
          
          case 401:
            // 未授权,跳转登录
            localStorage.removeItem('token');
            window.location.href = '/login';
            break;
          
          case 403:
            throw new Error('没有权限访问此资源');
          
          case 404:
            throw new Error('请求的资源不存在');
          
          case 429:
            throw new Error('请求过于频繁,请稍后再试');
          
          case 500:
            throw new Error('服务器内部错误');
          
          case 502:
          case 503:
          case 504:
            throw new Error('服务暂时不可用,请稍后再试');
          
          default:
            throw new Error(`未知错误: ${response.status}`);
        }
      } catch (error) {
        setError(error.message);
      }
    };
    
    fetchData();
  }, []);
  
  if (error) return <div className="error">{error}</div>;
  return <div>{JSON.stringify(data)}</div>;
}

超时错误

jsx
function TimeoutHandling() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  
  const fetchWithTimeout = async (url, timeout = 5000) => {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);
    
    try {
      const response = await fetch(url, {
        signal: controller.signal,
      });
      
      clearTimeout(timeoutId);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      return await response.json();
    } catch (error) {
      clearTimeout(timeoutId);
      
      if (error.name === 'AbortError') {
        throw new Error('请求超时,请检查网络连接');
      }
      
      throw error;
    }
  };
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const result = await fetchWithTimeout(
          'https://api.example.com/data',
          3000 // 3秒超时
        );
        setData(result);
      } catch (error) {
        setError(error.message);
      }
    };
    
    fetchData();
  }, []);
  
  if (error) {
    return (
      <div className="error">
        <p>{error}</p>
        <button onClick={() => window.location.reload()}>
          重试
        </button>
      </div>
    );
  }
  
  return <div>{JSON.stringify(data)}</div>;
}

错误边界

Error Boundary组件

jsx
import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null,
    };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
    
    this.setState({
      error,
      errorInfo,
    });
    
    // 发送错误到监控服务
    logErrorToService(error, errorInfo);
  }
  
  handleReset = () => {
    this.setState({
      hasError: false,
      error: null,
      errorInfo: null,
    });
  };
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>出错了</h2>
          <details>
            <summary>错误详情</summary>
            <pre>{this.state.error?.toString()}</pre>
            <pre>{this.state.errorInfo?.componentStack}</pre>
          </details>
          <button onClick={this.handleReset}>重试</button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 使用
function App() {
  return (
    <ErrorBoundary>
      <DataFetchingComponent />
    </ErrorBoundary>
  );
}

function logErrorToService(error, errorInfo) {
  // 发送到Sentry、LogRocket等服务
  console.log('Logging error:', error, errorInfo);
}

React Query错误边界

jsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="error-fallback">
      <h2>数据加载失败</h2>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>重试</button>
    </div>
  );
}

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          FallbackComponent={ErrorFallback}
          onReset={reset}
        >
          <DataComponent />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

重试机制

基础重试

jsx
function BasicRetry() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchWithRetry = async (url, retries = 3) => {
      for (let i = 0; i < retries; i++) {
        try {
          const response = await fetch(url);
          
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
          }
          
          return await response.json();
        } catch (error) {
          // 最后一次重试失败
          if (i === retries - 1) {
            throw error;
          }
          
          // 等待后重试
          await new Promise(resolve => setTimeout(resolve, 1000));
        }
      }
    };
    
    const fetchData = async () => {
      try {
        const result = await fetchWithRetry('https://api.example.com/data');
        setData(result);
      } catch (error) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchData();
  }, []);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{JSON.stringify(data)}</div>;
}

指数退避重试

jsx
function ExponentialBackoffRetry() {
  const [data, setData] = useState(null);
  const [retryCount, setRetryCount] = useState(0);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchWithExponentialBackoff = async (
      url,
      maxRetries = 5,
      baseDelay = 1000
    ) => {
      for (let i = 0; i < maxRetries; i++) {
        try {
          const response = await fetch(url);
          
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
          }
          
          return await response.json();
        } catch (error) {
          if (i === maxRetries - 1) {
            throw error;
          }
          
          // 指数退避: 1s, 2s, 4s, 8s, 16s
          const delay = baseDelay * Math.pow(2, i);
          
          // 添加抖动(jitter)避免同时重试
          const jitter = Math.random() * 0.3 * delay;
          const totalDelay = delay + jitter;
          
          setRetryCount(i + 1);
          
          await new Promise(resolve => setTimeout(resolve, totalDelay));
        }
      }
    };
    
    const fetchData = async () => {
      try {
        const result = await fetchWithExponentialBackoff(
          'https://api.example.com/data'
        );
        setData(result);
        setRetryCount(0);
      } catch (error) {
        setError(error.message);
      }
    };
    
    fetchData();
  }, []);
  
  if (error) {
    return (
      <div className="error">
        <p>Error: {error}</p>
        <p>已重试 {retryCount} 次</p>
      </div>
    );
  }
  
  if (retryCount > 0) {
    return <div>重试中... (第 {retryCount} 次)</div>;
  }
  
  return <div>{JSON.stringify(data)}</div>;
}

条件重试

jsx
class RetryStrategy {
  constructor(options = {}) {
    this.maxRetries = options.maxRetries || 3;
    this.baseDelay = options.baseDelay || 1000;
    this.retryableStatuses = options.retryableStatuses || [408, 429, 500, 502, 503, 504];
    this.retryableErrors = options.retryableErrors || ['ECONNRESET', 'ETIMEDOUT'];
  }
  
  shouldRetry(error, attempt) {
    if (attempt >= this.maxRetries) {
      return false;
    }
    
    // 网络错误总是重试
    if (error.name === 'TypeError' || error.message.includes('fetch')) {
      return true;
    }
    
    // HTTP状态码判断
    if (error.status && this.retryableStatuses.includes(error.status)) {
      return true;
    }
    
    // 错误代码判断
    if (error.code && this.retryableErrors.includes(error.code)) {
      return true;
    }
    
    return false;
  }
  
  getDelay(attempt) {
    const exponentialDelay = Math.min(
      this.baseDelay * Math.pow(2, attempt),
      30000 // 最大30秒
    );
    
    const jitter = Math.random() * 0.3 * exponentialDelay;
    
    return exponentialDelay + jitter;
  }
}

function ConditionalRetry() {
  const [data, setData] = useState(null);
  const [status, setStatus] = useState({ loading: true, error: null, attempt: 0 });
  
  useEffect(() => {
    const strategy = new RetryStrategy({
      maxRetries: 3,
      baseDelay: 1000,
    });
    
    const fetchWithStrategy = async (url, attempt = 0) => {
      try {
        const response = await fetch(url);
        
        if (!response.ok) {
          const error = new Error(`HTTP ${response.status}`);
          error.status = response.status;
          throw error;
        }
        
        return await response.json();
      } catch (error) {
        if (strategy.shouldRetry(error, attempt)) {
          const delay = strategy.getDelay(attempt);
          
          setStatus({
            loading: true,
            error: null,
            attempt: attempt + 1,
          });
          
          await new Promise(resolve => setTimeout(resolve, delay));
          return fetchWithStrategy(url, attempt + 1);
        }
        
        throw error;
      }
    };
    
    const fetchData = async () => {
      try {
        const result = await fetchWithStrategy('https://api.example.com/data');
        setData(result);
        setStatus({ loading: false, error: null, attempt: 0 });
      } catch (error) {
        setStatus({ loading: false, error: error.message, attempt: status.attempt });
      }
    };
    
    fetchData();
  }, []);
  
  if (status.loading) {
    return (
      <div>
        Loading...
        {status.attempt > 0 && ` (重试 ${status.attempt}/${3})`}
      </div>
    );
  }
  
  if (status.error) {
    return <div>Error: {status.error}</div>;
  }
  
  return <div>{JSON.stringify(data)}</div>;
}

自定义Hook

useRetry Hook

jsx
import { useState, useCallback } from 'react';

function useRetry(maxRetries = 3, baseDelay = 1000) {
  const [retryCount, setRetryCount] = useState(0);
  const [isRetrying, setIsRetrying] = useState(false);
  
  const executeWithRetry = useCallback(async (fn) => {
    setRetryCount(0);
    setIsRetrying(false);
    
    for (let i = 0; i < maxRetries; i++) {
      try {
        const result = await fn();
        setRetryCount(0);
        setIsRetrying(false);
        return result;
      } catch (error) {
        if (i === maxRetries - 1) {
          setIsRetrying(false);
          throw error;
        }
        
        const delay = baseDelay * Math.pow(2, i);
        setRetryCount(i + 1);
        setIsRetrying(true);
        
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }, [maxRetries, baseDelay]);
  
  return {
    executeWithRetry,
    retryCount,
    isRetrying,
  };
}

// 使用示例
function DataComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const { executeWithRetry, retryCount, isRetrying } = useRetry(3, 1000);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const result = await executeWithRetry(async () => {
          const response = await fetch('https://api.example.com/data');
          if (!response.ok) throw new Error('Failed');
          return response.json();
        });
        
        setData(result);
      } catch (error) {
        setError(error.message);
      }
    };
    
    fetchData();
  }, [executeWithRetry]);
  
  if (isRetrying) {
    return <div>重试中... ({retryCount}/3)</div>;
  }
  
  if (error) return <div>Error: {error}</div>;
  return <div>{JSON.stringify(data)}</div>;
}

useFetchWithRetry Hook

jsx
import { useState, useEffect } from 'react';

function useFetchWithRetry(url, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    retryOnStatus = [408, 429, 500, 502, 503, 504],
  } = options;
  
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [retryInfo, setRetryInfo] = useState({ count: 0, isRetrying: false });
  
  useEffect(() => {
    const controller = new AbortController();
    let cancelled = false;
    
    const fetchWithRetry = async (attempt = 0) => {
      if (cancelled) return;
      
      try {
        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });
        
        if (!response.ok) {
          if (retryOnStatus.includes(response.status) && attempt < maxRetries) {
            const delay = baseDelay * Math.pow(2, attempt);
            setRetryInfo({ count: attempt + 1, isRetrying: true });
            
            await new Promise(resolve => setTimeout(resolve, delay));
            return fetchWithRetry(attempt + 1);
          }
          
          throw new Error(`HTTP ${response.status}`);
        }
        
        const json = await response.json();
        
        if (!cancelled) {
          setData(json);
          setLoading(false);
          setRetryInfo({ count: 0, isRetrying: false });
        }
      } catch (error) {
        if (error.name === 'AbortError' || cancelled) return;
        
        if (attempt < maxRetries) {
          const delay = baseDelay * Math.pow(2, attempt);
          setRetryInfo({ count: attempt + 1, isRetrying: true });
          
          await new Promise(resolve => setTimeout(resolve, delay));
          return fetchWithRetry(attempt + 1);
        }
        
        if (!cancelled) {
          setError(error);
          setLoading(false);
          setRetryInfo({ count: 0, isRetrying: false });
        }
      }
    };
    
    fetchWithRetry();
    
    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [url, JSON.stringify(options), maxRetries, baseDelay, retryOnStatus.join()]);
  
  return { data, loading, error, retryInfo };
}

// 使用示例
function UserList() {
  const { data, loading, error, retryInfo } = useFetchWithRetry(
    'https://api.example.com/users',
    {
      maxRetries: 3,
      baseDelay: 1000,
    }
  );
  
  if (loading) {
    return (
      <div>
        Loading...
        {retryInfo.isRetrying && ` (重试 ${retryInfo.count}/3)`}
      </div>
    );
  }
  
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

错误恢复

自动恢复

jsx
function AutoRecovery() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [recovering, setRecovering] = useState(false);
  
  const fetchData = async () => {
    try {
      setRecovering(true);
      setError(null);
      
      const response = await fetch('https://api.example.com/data');
      if (!response.ok) throw new Error('Failed');
      
      const json = await response.json();
      setData(json);
      setRecovering(false);
    } catch (error) {
      setError(error.message);
      setRecovering(false);
      
      // 5秒后自动重试
      setTimeout(fetchData, 5000);
    }
  };
  
  useEffect(() => {
    fetchData();
  }, []);
  
  if (error) {
    return (
      <div className="error-recovery">
        <p>Error: {error}</p>
        {recovering ? (
          <p>正在自动重试...</p>
        ) : (
          <button onClick={fetchData}>手动重试</button>
        )}
      </div>
    );
  }
  
  return <div>{JSON.stringify(data)}</div>;
}

降级处理

jsx
function FallbackData() {
  const [data, setData] = useState(null);
  const [useFallback, setUseFallback] = useState(false);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        
        if (!response.ok) {
          throw new Error('Primary API failed');
        }
        
        const json = await response.json();
        setData(json);
      } catch (error) {
        console.error('Primary API failed, using fallback');
        
        try {
          // 尝试备用API
          const fallbackResponse = await fetch('https://backup-api.example.com/data');
          const fallbackJson = await fallbackResponse.json();
          
          setData(fallbackJson);
          setUseFallback(true);
        } catch (fallbackError) {
          // 使用本地缓存数据
          const cachedData = localStorage.getItem('cached-data');
          if (cachedData) {
            setData(JSON.parse(cachedData));
            setUseFallback(true);
          }
        }
      }
    };
    
    fetchData();
  }, []);
  
  return (
    <div>
      {useFallback && (
        <div className="warning">使用降级数据</div>
      )}
      {JSON.stringify(data)}
    </div>
  );
}

错误监控

错误日志

jsx
class ErrorLogger {
  constructor() {
    this.errors = [];
  }
  
  log(error, context = {}) {
    const errorLog = {
      message: error.message,
      stack: error.stack,
      timestamp: new Date().toISOString(),
      context,
      userAgent: navigator.userAgent,
      url: window.location.href,
    };
    
    this.errors.push(errorLog);
    
    // 发送到监控服务
    this.sendToMonitoring(errorLog);
    
    // 本地存储
    this.saveToLocalStorage(errorLog);
  }
  
  sendToMonitoring(errorLog) {
    // 发送到Sentry、LogRocket等
    fetch('https://monitoring.example.com/errors', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(errorLog),
    }).catch(err => console.error('Failed to send error log:', err));
  }
  
  saveToLocalStorage(errorLog) {
    try {
      const logs = JSON.parse(localStorage.getItem('error-logs') || '[]');
      logs.push(errorLog);
      
      // 只保留最近100条
      if (logs.length > 100) {
        logs.shift();
      }
      
      localStorage.setItem('error-logs', JSON.stringify(logs));
    } catch (err) {
      console.error('Failed to save error log:', err);
    }
  }
  
  getErrors() {
    return this.errors;
  }
}

const errorLogger = new ErrorLogger();

// 使用
function DataComponent() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        if (!response.ok) throw new Error('Failed');
        setData(await response.json());
      } catch (error) {
        errorLogger.log(error, {
          component: 'DataComponent',
          action: 'fetchData',
        });
      }
    };
    
    fetchData();
  }, []);
  
  return <div>{JSON.stringify(data)}</div>;
}

总结

错误处理与重试要点:

  1. 错误类型:网络错误、HTTP错误、超时错误
  2. 错误边界:React Error Boundary捕获组件错误
  3. 重试机制:基础重试、指数退避、条件重试
  4. 自定义Hook:useRetry、useFetchWithRetry封装
  5. 错误恢复:自动恢复、降级处理、备用方案
  6. 错误监控:日志记录、监控服务集成

合理的错误处理和重试策略能够显著提升应用的稳定性和用户体验。