React: HTTP 请求与数据获取
最后更新:2026-08-26
Tom 在用户管理页面调用 API 获取用户列表时,暴露了三个问题:首先,快速切换页面导致前一个请求的响应覆盖后一个请求的数据(竞态条件);其次,网络错误时页面直接白屏,没有任何错误提示;最后,每个页面都重复写 loading + error + data 的三态逻辑。他意识到:需要一套统一的 HTTP 请求方案,覆盖请求生命周期管理。
1. 你将学到
- fetch API vs axios 的选型依据
- loading / error / data 三态管理的最佳实践
- AbortController 取消请求防止竞态条件
- axios 实例封装和拦截器配置
- 错误处理与自动重试策略
2. 概念图解
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 请求,最基础的模式是管理三个状态变量:
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 的三态管理
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">⚠</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>
)
}
重要细节: fetch() 只在网络错误时抛异常,HTTP 4xx/5xx 状态码不会触发 catch。因此需要在 then 中手动检查 response.ok(或 response.status),对非 2xx 响应主动抛出错误。
(2) 自定义 Hook 抽取
每个页面都重复写三态逻辑显然不现实。Tom 将三态逻辑抽取为自定义 Hook,一行代码即可在任意组件中使用。
▶ 示例 2:useFetch 自定义 Hook
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 化带来的好处:
- 组件代码大幅精简,专注于渲染逻辑
- 三态逻辑统一维护,修改行为只需改一处
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 的实例化和拦截器机制可以一次性解决这些问题。
npm install axios
▶ 示例 3:axios 实例封装
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)
})
}, [])
// ... 渲染逻辑
}
拦截器的威力: 请求拦截器自动注入 token,响应拦截器自动处理 401 跳转——组件和 API 调用方完全不需要关心这些横切关注点。
5. 请求取消与竞态条件
| 竞态场景 | 原因 | 解决方案 |
|---|---|---|
| 搜索输入竞态 | 快速输入触发多个请求,旧响应覆盖新 | AbortController 取消旧请求 |
| 页面切换竞态 | 切换页面后旧请求的响应仍到达 | useEffect cleanup 取消 |
| 按钮重复点击 | 用户多次点击提交按钮 | 请求期间禁用按钮 + 取消 |
| Tab 切换竞态 | 快速切换 Tab,数据错位 | 用 ignore flag 忽略旧响应 |
这是 Tom 踩过的最隐蔽的坑。当用户在输入框中快速搜索时——输入 "a" → "ab" → "abc" → "abcd"——如果网络速度不同,可能发生:"abcd" 的响应先回来,然后 "a" 的响应后回来(因为之前的请求没有取消)。最终页面显示的是 "a" 的结果,而不是最新的 "abcd"。
这种现象叫竞态条件(Race Condition)。解决方案:每次发起新请求前,取消上一个未完成的请求。
▶ 示例 4:AbortController 取消请求
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>
)
}
关键机制:
- 每次
query变化,useEffect 的清理函数会调用controller.abort(),取消上一次请求 - 被取消的请求进入 catch 分支,通过
err.name !== 'AbortError'过滤掉——不会错误地触发错误 UI - 最终只有最后一个请求的响应会触发
setResults,彻底消除竞态条件
▶ 示例 5:axios 的请求取消
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>
)
}
▶ 示例 6:自动重试策略
网络请求不可靠,Tom 希望请求失败时自动重试(例如重试 2 次,间隔逐步增加)。axios 本身不内置重试功能,但可以通过拦截器轻松实现:
// 重试拦截器
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) 是重试策略的标准做法:每次重试的等待时间递增,避免在服务器已经过载时继续施压。
❓ 常见问题
{ data, loading, error } 对象,任何字段变化都会导致订阅该对象的组件重渲染。但在实际开发中,两种方式差异不大,选自己习惯的即可。useFetch() 时都会创建独立的作用域(闭包),各自的 data/loading/error 互不干扰。如果要跨组件共享请求数据(如两个组件都显示用户列表),需要全局状态管理 + TanStack Query(见下一课)。📖 小节
- 三态管理(loading / error / data)是 React 数据请求的基石,四种 UI 状态覆盖所有场景
- 自定义 Hook 抽取三态逻辑,避免每个组件重复编写,统一维护
- axios 实例 + 拦截器实现 token 自动注入、401 自动跳转、统一错误处理
- AbortController(fetch)/ CancelToken(axios)取消未完成请求,彻底解决竞态条件
- 搜索、分页切换、Tab 切换等高频变更场景必须做请求取消
📝 作业
- 创建一个用户列表组件:用 fetch 请求
https://jsonplaceholder.typicode.com/users,实现加载态(spinner)、错误态(错误信息 + 重试按钮)、空数据态(暂无数据)、正常渲染四种 UI。 - 基于上述作业,将三态逻辑抽取为自定义 Hook
useFetch,然后在两个不同的组件中分别使用,验证状态是否独立。 - 创建一个搜索组件:输入框根据用户输入搜索(模拟 API 延迟 500ms),用 AbortController 取消上一次未完成的请求,验证快速输入时不会出现数据错乱。