React学习教程,从入门到精通,React AJAX 语法知识点与案例详解(18)

发布于:2025-09-13 ⋅ 阅读:(21) ⋅ 点赞:(0)

React AJAX 语法知识点与案例详解

一、React AJAX 核心知识点

1. AJAX 在 React 中的基本概念

在 React 中,AJAX 请求通常在组件生命周期方法或 Hooks 中发起,用于从服务器获取数据并更新组件状态。

2. 常用的 AJAX 请求方式

2.1 Fetch API(原生)
  • 现代浏览器内置的网络请求API
  • 基于 Promise,支持 async/await
  • 需要手动处理错误和 JSON 转换
2.2 Axios(第三方库)
  • 基于 Promise 的 HTTP 客户端
  • 自动转换 JSON 数据
  • 支持请求/响应拦截器
  • 支持取消请求
  • 浏览器和 Node.js 环境都可用
2.3 XMLHttpRequest(传统,不推荐)
  • 原生 JavaScript 对象
  • 回调函数风格,代码较复杂
  • 现代 React 项目中很少使用

3. 在 React 组件中使用 AJAX 的时机

3.1 Class Components
  • componentDidMount() - 组件挂载后发起请求
  • componentDidUpdate() - 组件更新后根据条件发起请求
  • componentWillUnmount() - 清理定时器或取消未完成的请求
3.2 Function Components with Hooks
  • useEffect() - 替代类组件的生命周期方法
  • 依赖数组控制何时发起请求
  • 清理函数用于取消请求或清理资源

4. 状态管理

  • 使用 useStatethis.state 存储加载状态、数据和错误信息
  • 通常需要管理三种状态:
    • loading: 请求是否正在进行
    • data: 请求成功后的数据
    • error: 请求失败的错误信息

5. 错误处理

  • 网络错误处理
  • 服务器返回错误状态码处理
  • 数据格式错误处理
  • 超时处理

6. 请求取消

  • 防止组件卸载后更新状态导致的内存泄漏
  • 使用 AbortController (Fetch) 或 CancelToken (Axios)

7. 并发请求处理

  • Promise.all() 处理多个并行请求
  • Promise.race() 获取最快响应的请求

8. 请求优化

  • 防抖和节流
  • 缓存机制
  • 懒加载

二、详细案例代码

案例1:使用 Fetch API 的用户列表组件(函数组件)

import React, { useState, useEffect } from 'react';
import './UserList.css'; // 可选的样式文件

const UserList = () => {
  // 定义状态
  const [users, setUsers] = useState([]);        // 存储用户数据
  const [loading, setLoading] = useState(true);  // 加载状态
  const [error, setError] = useState(null);      // 错误信息
  const [controller, setController] = useState(null); // AbortController 实例

  // 使用 useEffect 发起 AJAX 请求
  useEffect(() => {
    // 创建 AbortController 用于取消请求
    const abortController = new AbortController();
    setController(abortController);
    
    // 定义获取用户数据的异步函数
    const fetchUsers = async () => {
      try {
        // 更新加载状态
        setLoading(true);
        setError(null);
        
        // 发起 Fetch 请求
        const response = await fetch('https://jsonplaceholder.typicode.com/users', {
          method: 'GET',
          headers: {
            'Content-Type': 'application/json',
          },
          signal: abortController.signal // 关联 AbortController
        });
        
        // 检查响应状态
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        // 解析 JSON 数据
        const userData = await response.json();
        
        // 更新状态
        setUsers(userData);
        setLoading(false);
        
      } catch (err) {
        // 检查是否是取消请求导致的错误
        if (err.name === 'AbortError') {
          console.log('请求已取消');
        } else {
          // 处理其他错误
          setError(err.message);
          setLoading(false);
          console.error('获取用户数据失败:', err);
        }
      }
    };
    
    // 调用获取数据函数
    fetchUsers();
    
    // 清理函数:组件卸载时取消请求
    return () => {
      console.log('组件卸载,取消请求');
      abortController.abort();
    };
  }, []); // 空依赖数组,只在组件挂载时执行一次

  // 重新加载数据的函数
  const handleReload = () => {
    // 如果有正在进行的请求,先取消
    if (controller) {
      controller.abort();
    }
    // 重新设置状态并触发新的请求
    setLoading(true);
    setError(null);
    setUsers([]);
    
    // 创建新的 AbortController
    const newController = new AbortController();
    setController(newController);
    
    // 重新获取数据
    fetch('https://jsonplaceholder.typicode.com/users', {
      signal: newController.signal
    })
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    })
    .then(data => {
      setUsers(data);
      setLoading(false);
    })
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err.message);
        setLoading(false);
      }
    });
  };

  // 渲染加载状态
  if (loading) {
    return (
      <div className="user-list-container">
        <h2>用户列表</h2>
        <div className="loading">加载中...</div>
      </div>
    );
  }

  // 渲染错误状态
  if (error) {
    return (
      <div className="user-list-container">
        <h2>用户列表</h2>
        <div className="error">
          <p>加载失败: {error}</p>
          <button onClick={handleReload}>重试</button>
        </div>
      </div>
    );
  }

  // 渲染正常数据
  return (
    <div className="user-list-container">
      <h2>用户列表</h2>
      <button onClick={handleReload} className="reload-btn">
        刷新数据
      </button>
      <div className="user-grid">
        {users.map(user => (
          <div key={user.id} className="user-card">
            <h3>{user.name}</h3>
            <p><strong>用户名:</strong> {user.username}</p>
            <p><strong>邮箱:</strong> {user.email}</p>
            <p><strong>电话:</strong> {user.phone}</p>
            <p><strong>网站:</strong> {user.website}</p>
            <div className="user-address">
              <h4>地址:</h4>
              <p>{user.address.street}, {user.address.suite}</p>
              <p>{user.address.city}, {user.address.zipcode}</p>
            </div>
            <div className="user-company">
              <h4>公司:</h4>
              <p>{user.company.name}</p>
              <p>{user.company.catchPhrase}</p>
            </div>
          </div>
        ))}
      </div>
      <p className="user-count">共 {users.length} 个用户</p>
    </div>
  );
};

export default UserList;

案例2:使用 Axios 的天气查询组件(函数组件)

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './WeatherApp.css';

// 创建 axios 实例,设置默认配置
const api = axios.create({
  baseURL: 'https://api.openweathermap.org/data/2.5',
  timeout: 10000, // 10秒超时
});

// 你的 API key (实际使用时需要替换为真实的 key)
const API_KEY = 'your_api_key_here';

const WeatherApp = () => {
  // 状态定义
  const [city, setCity] = useState('');           // 输入的城市名
  const [weatherData, setWeatherData] = useState(null); // 天气数据
  const [loading, setLoading] = useState(false);  // 加载状态
  const [error, setError] = useState(null);       // 错误信息
  const [searchHistory, setSearchHistory] = useState([]); // 搜索历史
  const [currentCity, setCurrentCity] = useState(''); // 当前显示的城市

  // 组件挂载时,从 localStorage 读取搜索历史
  useEffect(() => {
    const savedHistory = localStorage.getItem('weatherSearchHistory');
    if (savedHistory) {
      setSearchHistory(JSON.parse(savedHistory));
    }
  }, []);

  // 当搜索历史变化时,保存到 localStorage
  useEffect(() => {
    localStorage.setItem('weatherSearchHistory', JSON.stringify(searchHistory));
  }, [searchHistory]);

  // 搜索天气的函数
  const searchWeather = async (searchCity) => {
    if (!searchCity.trim()) {
      setError('请输入城市名称');
      return;
    }

    setLoading(true);
    setError(null);

    try {
      // 并发请求:天气数据 + 预报数据
      const [weatherResponse, forecastResponse] = await Promise.all([
        api.get('/weather', {
          params: {
            q: searchCity,
            appid: API_KEY,
            units: 'metric', // 使用摄氏度
            lang: 'zh_cn'    // 中文描述
          }
        }),
        api.get('/forecast', {
          params: {
            q: searchCity,
            appid: API_KEY,
            units: 'metric',
            lang: 'zh_cn'
          }
        })
      ]);

      // 处理天气数据
      const currentWeather = {
        city: weatherResponse.data.name,
        country: weatherResponse.data.sys.country,
        temperature: Math.round(weatherResponse.data.main.temp),
        feelsLike: Math.round(weatherResponse.data.main.feels_like),
        description: weatherResponse.data.weather[0].description,
        icon: weatherResponse.data.weather[0].icon,
        humidity: weatherResponse.data.main.humidity,
        pressure: weatherResponse.data.main.pressure,
        windSpeed: weatherResponse.data.wind.speed,
        sunrise: new Date(weatherResponse.data.sys.sunrise * 1000),
        sunset: new Date(weatherResponse.data.sys.sunset * 1000)
      };

      // 处理预报数据(只取未来5天中午12点的数据)
      const dailyForecasts = [];
      const forecastList = forecastResponse.data.list;
      
      // 按天分组,取每天中午12点左右的数据
      const groupedByDay = {};
      forecastList.forEach(item => {
        const date = new Date(item.dt * 1000);
        const dayKey = date.toISOString().split('T')[0]; // YYYY-MM-DD
        
        // 优先选择中午12点左右的数据
        if (!groupedByDay[dayKey] || Math.abs(date.getHours() - 12) < Math.abs(new Date(groupedByDay[dayKey].dt * 1000).getHours() - 12)) {
          groupedByDay[dayKey] = item;
        }
      });

      // 转换为数组(排除今天)
      const today = new Date().toISOString().split('T')[0];
      Object.values(groupedByDay)
        .filter(item => item.dt_txt.split(' ')[0] !== today)
        .slice(0, 5) // 只取未来5天
        .forEach(item => {
          dailyForecasts.push({
            date: new Date(item.dt * 1000),
            temperature: Math.round(item.main.temp),
            description: item.weather[0].description,
            icon: item.weather[0].icon
          });
        });

      // 合并数据
      const combinedData = {
        current: currentWeather,
        forecast: dailyForecasts
      };

      setWeatherData(combinedData);
      setCurrentCity(searchCity);

      // 更新搜索历史
      setSearchHistory(prev => {
        const newHistory = prev.filter(item => item !== searchCity);
        newHistory.unshift(searchCity);
        return newHistory.slice(0, 5); // 只保留最近5个
      });

    } catch (err) {
      console.error('天气查询错误:', err);
      
      if (err.code === 'ECONNABORTED') {
        setError('请求超时,请稍后重试');
      } else if (err.response) {
        // 服务器返回了错误状态码
        switch (err.response.status) {
          case 404:
            setError('未找到该城市,请检查城市名称');
            break;
          case 401:
            setError('API密钥无效');
            break;
          case 429:
            setError('请求过于频繁,请稍后再试');
            break;
          default:
            setError(`服务器错误: ${err.response.status}`);
        }
      } else if (err.request) {
        // 请求已发出但没有收到响应
        setError('网络错误,请检查网络连接');
      } else {
        // 其他错误
        setError('未知错误,请稍后重试');
      }
    } finally {
      setLoading(false);
    }
  };

  // 处理表单提交
  const handleSubmit = (e) => {
    e.preventDefault();
    searchWeather(city);
  };

  // 从历史记录中选择城市
  const handleHistoryClick = (historicalCity) => {
    setCity(historicalCity);
    searchWeather(historicalCity);
  };

  // 格式化日期
  const formatDate = (date) => {
    return date.toLocaleDateString('zh-CN', { 
      month: 'numeric', 
      day: 'numeric',
      weekday: 'short'
    });
  };

  // 格式化时间
  const formatTime = (date) => {
    return date.toLocaleTimeString('zh-CN', { 
      hour: '2-digit', 
      minute: '2-digit'
    });
  };

  return (
    <div className="weather-app">
      <h1>天气预报</h1>
      
      {/* 搜索表单 */}
      <form onSubmit={handleSubmit} className="search-form">
        <input
          type="text"
          value={city}
          onChange={(e) => setCity(e.target.value)}
          placeholder="请输入城市名称"
          className="search-input"
        />
        <button 
          type="submit" 
          disabled={loading}
          className="search-button"
        >
          {loading ? '搜索中...' : '搜索'}
        </button>
      </form>

      {/* 搜索历史 */}
      {searchHistory.length > 0 && (
        <div className="search-history">
          <h3>搜索历史:</h3>
          <div className="history-list">
            {searchHistory.map((historicalCity, index) => (
              <button
                key={index}
                onClick={() => handleHistoryClick(historicalCity)}
                className="history-item"
              >
                {historicalCity}
              </button>
            ))}
          </div>
        </div>
      )}

      {/* 错误信息 */}
      {error && (
        <div className="error-message">
          {error}
        </div>
      )}

      {/* 天气数据显示 */}
      {weatherData && (
        <div className="weather-display">
          {/* 当前天气 */}
          <div className="current-weather">
            <div className="city-info">
              <h2>{weatherData.current.city}, {weatherData.current.country}</h2>
              <div className="date-time">
                {new Date().toLocaleString('zh-CN')}
              </div>
            </div>
            
            <div className="weather-main">
              <img 
                src={`https://openweathermap.org/img/wn/${weatherData.current.icon}@2x.png`} 
                alt={weatherData.current.description}
                className="weather-icon"
              />
              <div className="temperature">
                <span className="temp-value">{weatherData.current.temperature}°</span>
                <span className="temp-unit">C</span>
              </div>
              <div className="weather-description">
                {weatherData.current.description}
              </div>
              <div className="feels-like">
                体感温度: {weatherData.current.feelsLike}°C
              </div>
            </div>

            {/* 天气详情 */}
            <div className="weather-details">
              <div className="detail-item">
                <span className="detail-label">湿度:</span>
                <span className="detail-value">{weatherData.current.humidity}%</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">气压:</span>
                <span className="detail-value">{weatherData.current.pressure} hPa</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">风速:</span>
                <span className="detail-value">{weatherData.current.windSpeed} m/s</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">日出:</span>
                <span className="detail-value">{formatTime(weatherData.current.sunrise)}</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">日落:</span>
                <span className="detail-value">{formatTime(weatherData.current.sunset)}</span>
              </div>
            </div>
          </div>

          {/* 未来5天预报 */}
          {weatherData.forecast.length > 0 && (
            <div className="forecast">
              <h3>未来5天预报</h3>
              <div className="forecast-list">
                {weatherData.forecast.map((day, index) => (
                  <div key={index} className="forecast-day">
                    <div className="forecast-date">{formatDate(day.date)}</div>
                    <img 
                      src={`https://openweathermap.org/img/wn/${day.icon}.png`} 
                      alt={day.description}
                      className="forecast-icon"
                    />
                    <div className="forecast-temp">{day.temperature}°</div>
                    <div className="forecast-desc">{day.description}</div>
                  </div>
                ))}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
};

export default WeatherApp;

案例3:使用 Class Component 的商品管理组件

import React, { Component } from 'react';
import './ProductManager.css';

class ProductManager extends Component {
  constructor(props) {
    super(props);
    
    // 初始化状态
    this.state = {
      products: [],           // 商品列表
      loading: false,         // 加载状态
      error: null,            // 错误信息
      newProduct: {           // 新商品表单数据
        name: '',
        price: '',
        description: '',
        category: ''
      },
      editingProduct: null,   // 正在编辑的商品
      searchQuery: '',        // 搜索关键词
      categories: ['电子产品', '服装', '食品', '家居', '图书'], // 商品分类
      page: 1,               // 当前页码
      totalPages: 1,         // 总页数
      limit: 10              // 每页显示数量
    };

    // 绑定 this
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleSearchChange = this.handleSearchChange.bind(this);
    this.handlePageChange = this.handlePageChange.bind(this);
    this.handleAddProduct = this.handleAddProduct.bind(this);
    this.handleEditProduct = this.handleEditProduct.bind(this);
    this.handleUpdateProduct = this.handleUpdateProduct.bind(this);
    this.handleDeleteProduct = this.handleDeleteProduct.bind(this);
    this.cancelEdit = this.cancelEdit.bind(this);
  }

  // 组件挂载后获取商品数据
  componentDidMount() {
    this.fetchProducts();
  }

  // 当搜索关键词或页码变化时,重新获取数据
  componentDidUpdate(prevProps, prevState) {
    if (prevState.searchQuery !== this.state.searchQuery || 
        prevState.page !== this.state.page) {
      this.fetchProducts();
    }
  }

  // 获取商品数据
  fetchProducts = async () => {
    this.setState({ loading: true, error: null });
    
    try {
      // 模拟 API 请求(实际项目中替换为真实 API)
      const response = await fetch('/api/products', {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
        // 添加查询参数
        signal: this.abortController?.signal
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const data = await response.json();
      
      this.setState({
        products: data.products || [],
        totalPages: data.totalPages || 1,
        loading: false
      });
      
    } catch (err) {
      if (err.name !== 'AbortError') {
        this.setState({
          error: err.message,
          loading: false
        });
        console.error('获取商品数据失败:', err);
      }
    }
  };

  // 创建 AbortController
  createAbortController = () => {
    if (this.abortController) {
      this.abortController.abort();
    }
    this.abortController = new AbortController();
  };

  // 输入框变化处理
  handleInputChange = (e) => {
    const { name, value } = e.target;
    
    if (this.state.editingProduct) {
      // 编辑模式
      this.setState(prevState => ({
        editingProduct: {
          ...prevState.editingProduct,
          [name]: value
        }
      }));
    } else {
      // 添加模式
      this.setState(prevState => ({
        newProduct: {
          ...prevState.newProduct,
          [name]: value
        }
      }));
    }
  };

  // 搜索框变化处理
  handleSearchChange = (e) => {
    this.setState({ 
      searchQuery: e.target.value,
      page: 1 // 搜索时重置到第一页
    });
  };

  // 页码变化处理
  handlePageChange = (newPage) => {
    this.setState({ page: newPage });
  };

  // 添加商品
  handleAddProduct = async (e) => {
    e.preventDefault();
    
    const { newProduct } = this.state;
    
    // 表单验证
    if (!newProduct.name.trim() || !newProduct.price || !newProduct.category) {
      this.setState({ error: '请填写完整商品信息' });
      return;
    }
    
    this.setState({ loading: true, error: null });
    
    try {
      const response = await fetch('/api/products', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          ...newProduct,
          price: parseFloat(newProduct.price)
        })
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const newProductData = await response.json();
      
      // 更新状态
      this.setState(prevState => ({
        products: [newProductData, ...prevState.products],
        newProduct: {
          name: '',
          price: '',
          description: '',
          category: ''
        },
        loading: false
      }));
      
      // 重新获取数据以更新分页
      this.fetchProducts();
      
    } catch (err) {
      this.setState({
        error: err.message,
        loading: false
      });
      console.error('添加商品失败:', err);
    }
  };

  // 编辑商品
  handleEditProduct = (product) => {
    this.setState({
      editingProduct: { ...product },
      newProduct: {
        name: '',
        price: '',
        description: '',
        category: ''
      }
    });
  };

  // 更新商品
  handleUpdateProduct = async (e) => {
    e.preventDefault();
    
    const { editingProduct } = this.state;
    
    if (!editingProduct.name.trim() || !editingProduct.price || !editingProduct.category) {
      this.setState({ error: '请填写完整商品信息' });
      return;
    }
    
    this.setState({ loading: true, error: null });
    
    try {
      const response = await fetch(`/api/products/${editingProduct.id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          ...editingProduct,
          price: parseFloat(editingProduct.price)
        })
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const updatedProduct = await response.json();
      
      // 更新状态
      this.setState(prevState => ({
        products: prevState.products.map(p => 
          p.id === updatedProduct.id ? updatedProduct : p
        ),
        editingProduct: null,
        loading: false
      }));
      
    } catch (err) {
      this.setState({
        error: err.message,
        loading: false
      });
      console.error('更新商品失败:', err);
    }
  };

  // 删除商品
  handleDeleteProduct = async (productId) => {
    if (!window.confirm('确定要删除这个商品吗?')) {
      return;
    }
    
    this.setState({ loading: true, error: null });
    
    try {
      const response = await fetch(`/api/products/${productId}`, {
        method: 'DELETE',
        headers: {
          'Content-Type': 'application/json',
        }
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      // 更新状态
      this.setState(prevState => ({
        products: prevState.products.filter(p => p.id !== productId),
        loading: false
      }));
      
      // 重新获取数据以更新分页
      this.fetchProducts();
      
    } catch (err) {
      this.setState({
        error: err.message,
        loading: false
      });
      console.error('删除商品失败:', err);
    }
  };

  // 取消编辑
  cancelEdit = () => {
    this.setState({
      editingProduct: null,
      newProduct: {
        name: '',
        price: '',
        description: '',
        category: ''
      }
    });
  };

  // 组件卸载时清理
  componentWillUnmount() {
    if (this.abortController) {
      this.abortController.abort();
    }
  }

  render() {
    const { 
      products, 
      loading, 
      error, 
      newProduct, 
      editingProduct,
      searchQuery,
      categories,
      page,
      totalPages
    } = this.state;

    // 过滤搜索结果
    const filteredProducts = products.filter(product =>
      product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
      product.category.toLowerCase().includes(searchQuery.toLowerCase())
    );

    return (
      <div className="product-manager">
        <h1>商品管理</h1>
        
        {/* 错误信息 */}
        {error && (
          <div className="error-message">
            {error}
          </div>
        )}

        {/* 搜索框 */}
        <div className="search-bar">
          <input
            type="text"
            value={searchQuery}
            onChange={this.handleSearchChange}
            placeholder="搜索商品名称或分类..."
            className="search-input"
          />
        </div>

        {/* 添加/编辑商品表单 */}
        <div className="product-form">
          <h2>{editingProduct ? '编辑商品' : '添加新商品'}</h2>
          <form onSubmit={editingProduct ? this.handleUpdateProduct : this.handleAddProduct}>
            <div className="form-group">
              <label>商品名称:</label>
              <input
                type="text"
                name="name"
                value={editingProduct ? editingProduct.name : newProduct.name}
                onChange={this.handleInputChange}
                required
              />
            </div>
            
            <div className="form-group">
              <label>价格:</label>
              <input
                type="number"
                name="price"
                value={editingProduct ? editingProduct.price : newProduct.price}
                onChange={this.handleInputChange}
                min="0"
                step="0.01"
                required
              />
            </div>
            
            <div className="form-group">
              <label>分类:</label>
              <select
                name="category"
                value={editingProduct ? editingProduct.category : newProduct.category}
                onChange={this.handleInputChange}
                required
              >
                <option value="">请选择分类</option>
                {categories.map(category => (
                  <option key={category} value={category}>
                    {category}
                  </option>
                ))}
              </select>
            </div>
            
            <div className="form-group">
              <label>描述:</label>
              <textarea
                name="description"
                value={editingProduct ? editingProduct.description : newProduct.description}
                onChange={this.handleInputChange}
                rows="3"
              />
            </div>
            
            <div className="form-buttons">
              {editingProduct ? (
                <>
                  <button type="submit" disabled={loading}>
                    {loading ? '更新中...' : '更新商品'}
                  </button>
                  <button type="button" onClick={this.cancelEdit} className="cancel-btn">
                    取消
                  </button>
                </>
              ) : (
                <button type="submit" disabled={loading}>
                  {loading ? '添加中...' : '添加商品'}
                </button>
              )}
            </div>
          </form>
        </div>

        {/* 加载状态 */}
        {loading && !error && (
          <div className="loading-overlay">
            <div className="loading-spinner"></div>
            <p>处理中...</p>
          </div>
        )}

        {/* 商品列表 */}
        <div className="products-list">
          <h2>商品列表 (共 {products.length} 个)</h2>
          
          {filteredProducts.length === 0 ? (
            <div className="no-products">
              {searchQuery ? '没有找到匹配的商品' : '暂无商品数据'}
            </div>
          ) : (
            <table className="products-table">
              <thead>
                <tr>
                  <th>ID</th>
                  <th>商品名称</th>
                  <th>价格</th>
                  <th>分类</th>
                  <th>描述</th>
                  <th>操作</th>
                </tr>
              </thead>
              <tbody>
                {filteredProducts.map(product => (
                  <tr key={product.id}>
                    <td>{product.id}</td>
                    <td>{product.name}</td>
                    <td>¥{parseFloat(product.price).toFixed(2)}</td>
                    <td>{product.category}</td>
                    <td>{product.description || '-'}</td>
                    <td className="action-buttons">
                      <button 
                        onClick={() => this.handleEditProduct(product)}
                        className="edit-btn"
                      >
                        编辑
                      </button>
                      <button 
                        onClick={() => this.handleDeleteProduct(product.id)}
                        className="delete-btn"
                      >
                        删除
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>

        {/* 分页 */}
        {totalPages > 1 && (
          <div className="pagination">
            <button 
              onClick={() => this.handlePageChange(page - 1)}
              disabled={page === 1 || loading}
              className="page-btn"
            >
              上一页
            </button>
            
            <span className="page-info">
              第 {page} 页 / 共 {totalPages} 页
            </span>
            
            <button 
              onClick={() => this.handlePageChange(page + 1)}
              disabled={page === totalPages || loading}
              className="page-btn"
            >
              下一页
            </button>
          </div>
        )}
      </div>
    );
  }
}

export default ProductManager;

案例4:自定义 Hook 封装 AJAX 逻辑

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

// 自定义 Hook: useApi
const useApi = (url, options = {}) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [refreshCount, setRefreshCount] = useState(0);
  
  // 使用 useRef 保存最新的 options,避免 useEffect 依赖问题
  const optionsRef = useRef(options);
  optionsRef.current = options;
  
  // 使用 useRef 保存 AbortController
  const abortControllerRef = useRef(null);

  // 手动刷新函数
  const refresh = useCallback(() => {
    setRefreshCount(prev => prev + 1);
  }, []);

  // 取消请求函数
  const cancel = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
  }, []);

  // 主要的请求函数
  const fetchData = useCallback(async () => {
    // 如果没有 URL,直接返回
    if (!url) {
      return;
    }

    // 创建新的 AbortController
    abortControllerRef.current = new AbortController();
    
    setLoading(true);
    setError(null);

    try {
      // 合并默认选项和传入选项
      const config = {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
        ...optionsRef.current,
        signal: abortControllerRef.current.signal
      };

      const response = await fetch(url, config);
      
      // 检查响应状态
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      // 根据 Content-Type 决定如何解析响应
      const contentType = response.headers.get('content-type');
      let result;
      
      if (contentType && contentType.includes('application/json')) {
        result = await response.json();
      } else {
        result = await response.text();
      }
      
      setData(result);
      setLoading(false);
      
    } catch (err) {
      if (err.name === 'AbortError') {
        console.log('请求已取消');
      } else {
        setError(err.message);
        setLoading(false);
        console.error('API 请求失败:', err);
      }
    }
  }, [url, refreshCount]);

  // 使用 useEffect 执行请求
  useEffect(() => {
    fetchData();
    
    // 清理函数
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [fetchData]); // fetchData 已经包含了所有依赖

  return {
    data,
    loading,
    error,
    refresh,
    cancel,
    setData // 允许外部直接设置数据(用于乐观更新等场景)
  };
};

// 自定义 Hook: usePostApi (专门用于 POST 请求)
const usePostApi = (url, initialData = null) => {
  const [data, setData] = useState(initialData);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [success, setSuccess] = useState(false);
  
  const abortControllerRef = useRef(null);

  const post = useCallback(async (postData, config = {}) => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    
    abortControllerRef.current = new AbortController();
    
    setLoading(true);
    setError(null);
    setSuccess(false);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...config.headers
        },
        body: JSON.stringify(postData),
        signal: abortControllerRef.current.signal,
        ...config
      });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      const result = await response.json();
      
      setData(result);
      setSuccess(true);
      setLoading(false);
      
      return result;
      
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
        setLoading(false);
        setSuccess(false);
        console.error('POST 请求失败:', err);
      }
      throw err; // 重新抛出错误,让调用者可以处理
    }
  }, [url]);

  const reset = useCallback(() => {
    setData(initialData);
    setError(null);
    setSuccess(false);
  }, [initialData]);

  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  return {
    data,
    loading,
    error,
    success,
    post,
    reset
  };
};

// 使用自定义 Hook 的组件示例
const UserProfile = ({ userId }) => {
  // 使用 useApi Hook 获取用户数据
  const { 
    data: user, 
    loading, 
    error, 
    refresh 
  } = useApi(userId ? `/api/users/${userId}` : null);

  // 使用 usePostApi Hook 更新用户数据
  const { 
    post: updateUser, 
    loading: updateLoading, 
    success: updateSuccess,
    error: updateError,
    reset: resetUpdate
  } = usePostApi(`/api/users/${userId}`);

  const [editMode, setEditMode] = useState(false);
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    phone: ''
  });

  // 当用户数据加载完成后,初始化表单
  useEffect(() => {
    if (user) {
      setFormData({
        name: user.name || '',
        email: user.email || '',
        phone: user.phone || ''
      });
    }
  }, [user]);

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const handleUpdate = async (e) => {
    e.preventDefault();
    
    try {
      await updateUser(formData);
      setEditMode(false);
      // 自动刷新用户数据
      refresh();
    } catch (err) {
      // 错误已经在 usePostApi 中处理,这里可以添加额外的错误处理
      console.error('更新失败:', err);
    }
  };

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

  if (error) {
    return (
      <div>
        <p>加载失败: {error}</p>
        <button onClick={refresh}>重试</button>
      </div>
    );
  }

  if (!user) {
    return <div>用户不存在</div>;
  }

  return (
    <div className="user-profile">
      <h2>用户资料</h2>
      
      {updateSuccess && (
        <div className="success-message">
          更新成功!
          <button onClick={resetUpdate}>×</button>
        </div>
      )}
      
      {updateError && (
        <div className="error-message">
          更新失败: {updateError}
        </div>
      )}
      
      {!editMode ? (
        <div className="user-info">
          <p><strong>姓名:</strong> {user.name}</p>
          <p><strong>邮箱:</strong> {user.email}</p>
          <p><strong>电话:</strong> {user.phone}</p>
          <p><strong>注册时间:</strong> {new Date(user.createdAt).toLocaleString()}</p>
          <button onClick={() => setEditMode(true)}>编辑资料</button>
          <button onClick={refresh} disabled={loading}>
            {loading ? '刷新中...' : '刷新'}
          </button>
        </div>
      ) : (
        <form onSubmit={handleUpdate} className="edit-form">
          <div className="form-group">
            <label>姓名:</label>
            <input
              type="text"
              name="name"
              value={formData.name}
              onChange={handleInputChange}
              required
            />
          </div>
          
          <div className="form-group">
            <label>邮箱:</label>
            <input
              type="email"
              name="email"
              value={formData.email}
              onChange={handleInputChange}
              required
            />
          </div>
          
          <div className="form-group">
            <label>电话:</label>
            <input
              type="tel"
              name="phone"
              value={formData.phone}
              onChange={handleInputChange}
            />
          </div>
          
          <div className="form-buttons">
            <button type="submit" disabled={updateLoading}>
              {updateLoading ? '更新中...' : '保存'}
            </button>
            <button type="button" onClick={() => {
              setEditMode(false);
              resetUpdate();
            }}>
              取消
            </button>
          </div>
        </form>
      )}
    </div>
  );
};

export { useApi, usePostApi, UserProfile };

三、最佳实践和注意事项

1. 错误处理最佳实践

  • 始终处理网络错误和服务器错误
  • 提供用户友好的错误信息
  • 记录错误日志用于调试

2. 性能优化

  • 使用防抖处理频繁的搜索请求
  • 实现数据缓存避免重复请求
  • 使用分页或懒加载处理大量数据

3. 安全考虑

  • 验证用户输入
  • 处理敏感数据(如 API keys)
  • 使用 HTTPS

4. 代码组织

  • 将 API 调用逻辑提取到单独的文件或自定义 Hook
  • 使用环境变量管理 API 端点
  • 保持组件职责单一

5. 测试

  • 为 AJAX 调用编写单元测试
  • 使用 Mock 数据进行测试
  • 测试错误处理逻辑

这些案例涵盖了 React 中 AJAX 请求的主要使用场景和最佳实践,作为初学者,建议从简单的 Fetch API 开始,逐步学习更复杂的模式和优化技巧。


网站公告

今日签到

点亮在社区的每一天
去签到