React: HTTP 请求与数据获取

最后更新:2026-08-26

Tom 在用户管理页面调用 API 获取用户列表时,暴露了三个问题:首先,快速切换页面导致前一个请求的响应覆盖后一个请求的数据(竞态条件);其次,网络错误时页面直接白屏,没有任何错误提示;最后,每个页面都重复写 loading + error + data 的三态逻辑。他意识到:需要一套统一的 HTTP 请求方案,覆盖请求生命周期管理


1. 你将学到



2. 概念图解

100%
flowchart LR
    A[发起请求] --> B{loading = true}
    B --> C[请求进行中]
    C --> D{成功/失败?}
    D -->|成功| E[data = 响应<br/>loading = false<br/>error = null]
    D -->|失败| F[error = 错误信息<br/>loading = false<br/>data = null]
    E --> G[渲染数据]
    F --> H{能否重试?}
    H -->|是| A
    H -->|否| I[显示错误 UI]
    G --> J[组件卸载?]
    J -->|是| K[AbortController<br/>取消请求]
    style B fill:#fff3e0,stroke:#f57c00
    style D fill:#e1f5fe,stroke:#0288d1
    style K fill:#ffcdd2,stroke:#d32f2f

请求生命周期:loading 开始 → 请求执行 → 成功赋值 data / 失败赋值 error → 组件卸载时取消未完成请求。



3. 一个真实场景

Tom 的用户管理页面需要:进入页面时加载用户列表、加载过程中显示 spinner、加载失败显示错误信息和重试按钮、用户在列表和详情间快速切换时不能出现数据错乱、所有 API 请求在遇到 401 时自动跳转到登录页。

(1) 三态模式

React 中发起 HTTP 请求,最基础的模式是管理三个状态变量:

JSX
const [data, setData] = useState(null)     // 成功数据
const [loading, setLoading] = useState(true) // 加载中
const [error, setError] = useState(null)    // 错误信息
▶ 试一试

为什么需要三态分离? 因为 UI 需要在三种情况下显示完全不同的内容:

状态 data loading error UI 表现
加载中 null true null 显示 spinner 或骨架屏
成功 数据 false null 渲染数据列表
失败 null false 错误信息 显示错误提示和重试按钮
空数据 [] false null 显示"暂无数据"提示

▶ 示例 1:fetch + useEffect 的三态管理

JSX 📖 仅展示
import { useState, useEffect } from 'react'

function UserList() {
  const [users, setUsers] = useState([])     // 数据
  const [loading, setLoading] = useState(true) // 加载态
  const [error, setError] = useState(null)    // 错误态

  function fetchUsers() {
    setLoading(true)
    setError(null)

    fetch('https://jsonplaceholder.typicode.com/users')
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}:${response.statusText}`)
        }
        return response.json()
      })
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }

  useEffect(() => {
    fetchUsers()
  }, [])

  // --- 三态渲染 ---
  if (loading) {
    return (
      <div className="loading-state">
        <div className="spinner" />
        <p>正在加载用户数据...</p>
      </div>
    )
  }

  if (error) {
    return (
      <div className="error-state">
        <p className="error-icon">&#x26A0;</p>
        <p>加载失败:{error}</p>
        <button onClick={fetchUsers}>重试</button>
      </div>
    )
  }

  if (users.length === 0) {
    return (
      <div className="empty-state">
        <p>暂无用户数据</p>
      </div>
    )
  }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          <strong>{user.name}</strong> — {user.email}
        </li>
      ))}
    </ul>
  )
}
逻辑代码 61 行(超过 40 行限制,仅展示)

重要细节: fetch() 只在网络错误时抛异常,HTTP 4xx/5xx 状态码不会触发 catch。因此需要在 then 中手动检查 response.ok(或 response.status),对非 2xx 响应主动抛出错误。

(2) 自定义 Hook 抽取

每个页面都重复写三态逻辑显然不现实。Tom 将三态逻辑抽取为自定义 Hook,一行代码即可在任意组件中使用。

▶ 示例 2:useFetch 自定义 Hook

JSX
import { useState, useEffect } from 'react'

// 通用数据请求 Hook
function useFetch(fetchFn, deps = []) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  function execute() {
    setLoading(true)
    setError(null)

    fetchFn()
      .then(result => {
        setData(result)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }

  useEffect(() => {
    execute()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)

  return { data, loading, error, refetch: execute }
}

// ====== 使用 ======
function UserList() {
  const { data: users, loading, error, refetch } = useFetch(
    () => fetch('https://jsonplaceholder.typicode.com/users')
            .then(r => { if (!r.ok) throw new Error('请求失败'); return r.json() }),
    []
  )

  if (loading) return <p>加载中...</p>
  if (error) return <p>错误:{error} <button onClick={refetch}>重试</button></p>

  return (
    <ul>
      {users?.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  )
}
▶ 试一试

Hook 化带来的好处:

  1. 组件代码大幅精简,专注于渲染逻辑
  2. 三态逻辑统一维护,修改行为只需改一处
  3. refetch 函数暴露给组件,方便手动触发重新请求


4. axios 进阶封装

特性 fetch axios
安装 浏览器内置 npm install axios
响应解析 手动 res.json() 自动转换 JSON
请求/响应拦截 无内置 拦截器 interceptors
超时设置 需搭配 AbortController timeout 选项
错误处理 HTTP 4xx/5xx 不抛异常 HTTP 错误自动抛出
请求取消 AbortController CancelToken(旧)/ AbortController(新)
TypeScript 需手动类型断言 泛型 axios.get<T>()

随着项目增大,Tom 发现每个请求都需要手动添加 token、处理 401 跳转、设置超时时间——非常繁琐。axios 的实例化和拦截器机制可以一次性解决这些问题。

BASH
npm install axios

▶ 示例 3:axios 实例封装

JSX 📖 仅展示
import axios from 'axios'

// 获取 token(从 localStorage 或 auth store)
function getToken() {
  return localStorage.getItem('auth_token')
}

// 创建 axios 实例
const api = axios.create({
  baseURL: '/api/v1',          // 基础路径
  timeout: 10000,              // 超时时间(10 秒)
  headers: {
    'Content-Type': 'application/json',
  }
})

// ========== 请求拦截器 ==========
api.interceptors.request.use(
  config => {
    // 自动添加 Authorization 头
    const token = getToken()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }

    // 请求日志(开发环境)
    if (process.env.NODE_ENV === 'development') {
      console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.params || '')
    }

    return config
  },
  error => {
    console.error('[API] 请求配置错误:', error)
    return Promise.reject(error)
  }
)

// ========== 响应拦截器 ==========
api.interceptors.response.use(
  // 成功响应:直接返回 data 字段(剥离外层包装)
  response => response.data,

  // 失败响应:统一错误处理
  error => {
    if (error.response) {
      // 服务器返回了错误状态码
      const { status, data } = error.response

      switch (status) {
        case 401:
          // 未授权 → 清除 token,跳转登录页
          localStorage.removeItem('auth_token')
          window.location.href = '/login'
          break
        case 403:
          console.warn('[API] 无权限访问')
          break
        case 404:
          console.warn('[API] 资源不存在')
          break
        case 500:
          console.error('[API] 服务器内部错误')
          break
        default:
          console.error(`[API] HTTP ${status}:`, data?.message || '未知错误')
      }

      return Promise.reject(new Error(data?.message || `HTTP ${status}`))
    }

    if (error.code === 'ECONNABORTED') {
      // 请求超时
      return Promise.reject(new Error('请求超时,请检查网络'))
    }

    // 网络错误(断网、DNS 解析失败等)
    return Promise.reject(new Error('网络连接异常'))
  }
)

// ========== 导出封装好的 API 方法 ==========
export const userApi = {
  getList: (params) => api.get('/users', { params }),
  getById: (id) => api.get(`/users/${id}`),
  create: (data) => api.post('/users', data),
  update: (id, data) => api.put(`/users/${id}`, data),
  delete: (id) => api.delete(`/users/${id}`),
}

export const productApi = {
  getList: (params) => api.get('/products', { params }),
  getById: (id) => api.get(`/products/${id}`),
}

// ========== 组件中使用 ==========
function UserTable() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    userApi.getList({ page: 1, limit: 20 })
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])

  // ... 渲染逻辑
}
逻辑代码 84 行(超过 40 行限制,仅展示)

拦截器的威力: 请求拦截器自动注入 token,响应拦截器自动处理 401 跳转——组件和 API 调用方完全不需要关心这些横切关注点。



5. 请求取消与竞态条件

竞态场景 原因 解决方案
搜索输入竞态 快速输入触发多个请求,旧响应覆盖新 AbortController 取消旧请求
页面切换竞态 切换页面后旧请求的响应仍到达 useEffect cleanup 取消
按钮重复点击 用户多次点击提交按钮 请求期间禁用按钮 + 取消
Tab 切换竞态 快速切换 Tab,数据错位 用 ignore flag 忽略旧响应

这是 Tom 踩过的最隐蔽的坑。当用户在输入框中快速搜索时——输入 "a" → "ab" → "abc" → "abcd"——如果网络速度不同,可能发生:"abcd" 的响应先回来,然后 "a" 的响应后回来(因为之前的请求没有取消)。最终页面显示的是 "a" 的结果,而不是最新的 "abcd"。

这种现象叫竞态条件(Race Condition)。解决方案:每次发起新请求前,取消上一个未完成的请求。

▶ 示例 4:AbortController 取消请求

JSX 📖 仅展示
import { useState, useEffect } from 'react'

function SearchUsers() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (!query.trim()) {
      setResults([])
      return
    }

    // 创建 AbortController
    const controller = new AbortController()
    const signal = controller.signal

    setLoading(true)

    fetch(`/api/users/search?q=${encodeURIComponent(query)}`, { signal })
      .then(res => res.json())
      .then(data => {
        setResults(data)
        setLoading(false)
      })
      .catch(err => {
        // 只处理非取消的错误
        if (err.name !== 'AbortError') {
          console.error('搜索失败:', err)
          setLoading(false)
        }
      })

    // 清理函数:组件卸载或 query 变化时取消请求
    return () => {
      controller.abort()
    }
  }, [query])

  return (
    <div>
      <input
        placeholder="搜索用户..."
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <p>搜索中...</p>}
      <ul>
        {results.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}
逻辑代码 45 行(超过 40 行限制,仅展示)

关键机制:

  1. 每次 query 变化,useEffect 的清理函数会调用 controller.abort(),取消上一次请求
  2. 被取消的请求进入 catch 分支,通过 err.name !== 'AbortError' 过滤掉——不会错误地触发错误 UI
  3. 最终只有最后一个请求的响应会触发 setResults,彻底消除竞态条件

▶ 示例 5:axios 的请求取消

JSX 📖 仅展示
import { useState, useEffect } from 'react'
import axios from 'axios'

function SearchProducts() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (!query.trim()) {
      setResults([])
      return
    }

    // axios 的取消令牌
    const source = axios.CancelToken.source()

    setLoading(true)

    axios.get('/api/products/search', {
      params: { q: query },
      cancelToken: source.token
    })
      .then(res => {
        setResults(res.data)
        setLoading(false)
      })
      .catch(err => {
        if (!axios.isCancel(err)) {
          console.error('搜索失败:', err)
          setLoading(false)
        }
        // 取消的请求不处理
      })

    return () => {
      source.cancel('请求已取消')  // 取消原因
    }
  }, [query])

  return (
    <div>
      <input
        placeholder="搜索商品..."
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <p>搜索中...</p>}
      <ul>
        {results.map(p => (
          <li key={p.id}>{p.name} — ${p.price}</li>
        ))}
      </ul>
    </div>
  )
}
逻辑代码 47 行(超过 40 行限制,仅展示)

▶ 示例 6:自动重试策略

网络请求不可靠,Tom 希望请求失败时自动重试(例如重试 2 次,间隔逐步增加)。axios 本身不内置重试功能,但可以通过拦截器轻松实现:

JSX
// 重试拦截器
function setupRetryInterceptor(axiosInstance, maxRetries = 2) {
  axiosInstance.interceptors.response.use(
    response => response,
    async error => {
      const config = error.config

      // 不重试的情况:没有配置、已经重试过、不是网络错误
      if (!config || config._retryCount >= maxRetries) {
        return Promise.reject(error)
      }

      // 只在网络错误或 5xx 服务器错误时重试
      const status = error.response?.status
      if (status && status < 500) {
        return Promise.reject(error)
      }

      config._retryCount = (config._retryCount || 0) + 1

      // 指数退避:第 1 次等 1 秒,第 2 次等 2 秒
      const delay = config._retryCount * 1000
      await new Promise(r => setTimeout(r, delay))

      console.log(`[API] 重试 ${config._retryCount}/${maxRetries}: ${config.url}`)
      return axiosInstance(config)
    }
  )
}

// 使用时
setupRetryInterceptor(api, 2)
▶ 试一试

指数退避(Exponential Backoff) 是重试策略的标准做法:每次重试的等待时间递增,避免在服务器已经过载时继续施压。


❓ 常见问题

Q fetch 和 axios 到底选哪个?
A fetch 是浏览器内置 API,无需安装依赖,适合简单请求。但需要手动处理 HTTP 错误状态码(4xx/5xx 不触发 catch),不支持请求进度监听。axios 自动解析 JSON、支持请求/响应拦截器、请求取消更方便、支持上传进度。建议:小项目用 fetch,大项目用 axios。
Q 三态管理为什么用三个独立的 useState,而不是一个对象?
A 三个独立 useState 可以让组件精确订阅某一段状态的变化。如果用一个 { data, loading, error } 对象,任何字段变化都会导致订阅该对象的组件重渲染。但在实际开发中,两种方式差异不大,选自己习惯的即可。
Q AbortController 取消请求后,服务器还会处理请求吗?
A 会的。AbortController 只在前端取消了对响应的监听,服务器端仍然会收到并处理请求(无法阻止请求到达服务器)。如果是写操作(POST/PUT/DELETE),需要后端做好幂等性设计或在前端做好防重复提交。
Q 自定义 Hook useFetch 在多个组件中调用,会共享状态吗?
A 不会。每个组件调用 useFetch() 时都会创建独立的作用域(闭包),各自的 data/loading/error 互不干扰。如果要跨组件共享请求数据(如两个组件都显示用户列表),需要全局状态管理 + TanStack Query(见下一课)。
Q 响应拦截器中统一处理 401 跳转登录页,有什么注意点?
A 注意避免重定向循环——如果登录页本身也发 API 请求(检查 token 有效性),且该请求也返回 401,就会陷入"请求 → 401 → 重定向 → 请求 → 401"的死循环。解决方法:在拦截器中判断当前页面路径,如果已经在 /login 就不再做重定向。

📖 小节


📝 作业

  1. 创建一个用户列表组件:用 fetch 请求 https://jsonplaceholder.typicode.com/users,实现加载态(spinner)、错误态(错误信息 + 重试按钮)、空数据态(暂无数据)、正常渲染四种 UI。
  2. 基于上述作业,将三态逻辑抽取为自定义 Hook useFetch,然后在两个不同的组件中分别使用,验证状态是否独立。
  3. 创建一个搜索组件:输入框根据用户输入搜索(模拟 API 延迟 500ms),用 AbortController 取消上一次未完成的请求,验证快速输入时不会出现数据错乱。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏