React 数据获取
1. 基础: 使用 Fetch 获取数据
组件加载时获取数据
最常见的场景是在组件首次加载(mount)时获取数据。我们可以通过向 useEffect
传递一个空依赖数组 []
来实现这一点。空数组告诉 React,这个 effect 只应该运行一次,即在组件挂载后。
import { useEffect, useState } from "react";
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
// 发起 fetch 请求
fetch("https://api.github.com/users/octocat")
.then(response => response.json())
.then(data => setUser(data))
.catch(error => console.error("Error fetching data:", error));
}, []); // 空依赖数组,确保只在挂载时运行一次
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
使用 async/await
语法
async/await
是处理 Promise 的现代语法糖,它可以让异步代码看起来更像同步代码,从而提高可读性。由于 useEffect
的回调函数本身不能是 async
的(因为这会返回一个 Promise,而 React 期望它返回一个清理函数或 undefined
),我们需要在内部定义一个 async
函数并立即调用它。
useEffect(() => {
// 在 effect 内部定义一个 async 函数
const fetchData = async () => {
try {
const response = await fetch("https://api.github.com/users/octocat");
const data = await response.json();
setUser(data);
} catch (error) {
console.error("Error fetching data:", error);
}
};
fetchData(); // 调用该函数
}, []);
2. 管理请求状态
状态类型
对于任何数据请求,通常存在三种关键状态:
loading
: 请求正在进行中。此时应向用户显示加载指示(如加载动画)。error
: 请求失败。应向用户显示错误信息。data
: 请求成功,数据已返回。此时可以渲染数据。
我们可以使用 useState
来管理这三种状态:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
实现完整的状态管理流程
结合 async/await
,一个包含完整状态管理的 useEffect
如下所示:
useEffect(() => {
const fetchData = async () => {
setLoading(true); // 开始请求,设置 loading 为 true
setError(null); // 重置错误状态
try {
const response = await fetch("https://api.github.com/users/octocat");
// fetch 不会因 4xx/5xx 响应而抛出错误,需要手动检查
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (e) {
setError(e); // 捕获网络错误或我们手动抛出的错误
} finally {
setLoading(false); // 请求结束(无论成功或失败),设置 loading 为 false
}
};
fetchData();
}, []);
// 在 JSX 中根据状态进行条件渲染
if (loading) return <p>Loading...</p>;
if (error) return <p>A network error was encountered: {error.message}</p>;
// ... 渲染 data ...
为什么需要
if (!response.ok)
?因为fetch
API 的一个特性是,它只在发生网络层面的失败(如无法连接服务器)时才会reject
Promise。对于服务器返回的错误状态码(如 404 Not Found, 500 Internal Server Error),fetch
仍然会resolve
Promise。因此,我们必须手动检查response.ok
属性(当状态码在 200-299 范围内时为true
)并抛出错误,才能被catch
块捕获。
3. 进阶模式
抽象为自定义 Hook
当多个组件都需要类似的数据获取逻辑时,重复编写 useEffect
会变得非常繁琐。我们可以将这个逻辑抽象成一个可复用的自定义 Hook。自定义 Hook 是一个以 use
开头的函数,它可以在内部调用其他 Hook。
// useData.js
import { useState, useEffect } from 'react';
function useData(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]); // 依赖数组包含 url,当 url 变化时重新获取数据
return { data, loading, error };
}
export default useData;
// 在组件中使用
// MyComponent.js
import useData from './useData';
function MyComponent() {
const { data, loading, error } = useData('https://api.example.com/data');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{JSON.stringify(data)}</div>;
}
这种模式极大地简化了组件代码,并实现了逻辑的复用。
处理组件卸载
一个常见的 Bug 是:当一个请求正在进行时,如果组件被卸载(unmounted),请求完成后仍然会尝试调用 setState
,这会导致 React 报错:“Can’t perform a React state update on an unmounted component.”
为了解决这个问题,我们需要在 useEffect
的清理函数中取消请求。AbortController
是现代浏览器中用于中止 fetch
请求的标准 API。
useEffect(() => {
const controller = new AbortController(); // 创建 AbortController
const signal = controller.signal; // 获取其 signal
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(url, { signal }); // 将 signal 传递给 fetch
// ...
} catch (e) {
if (e.name === 'AbortError') {
console.log('Fetch aborted'); // 请求被中止,是正常行为
} else {
setError(e);
}
} finally {
setLoading(false);
}
};
fetchData();
// 返回一个清理函数
return () => {
controller.abort(); // 当组件卸载或 effect 重新运行时,中止请求
};
}, [url]);
避免请求瀑布
当一个组件的请求依赖于另一个父组件的请求结果时,就会产生请求瀑布 (Request Waterfalls)。子组件必须等待父组件完成请求、渲染后,才能开始自己的请求,导致加载时间变长。
- 不良实践(瀑布):
function Profile() {
const { data: user, loading } = useData('/api/user');
if (loading) return <p>Loading profile...</p>;
// Bio 组件只有在 user 加载后才渲染和获取数据
return (
<>
<h1>{user.name}</h1>
<Bio userId={user.id} />
</>
);
}
解决的方案是将多个独立的请求尽可能地提升到组件树的更高层级,并使用 Promise.all
来并行触发它们。
function ProfilePage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState(null);
// ... loading, error states
useEffect(() => {
const fetchAllData = async () => {
setLoading(true);
try {
const [userResponse, postsResponse] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts')
]);
const userData = await userResponse.json();
const postsData = await postsResponse.json();
setUser(userData);
setPosts(postsData);
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
};
fetchAllData();
}, []);
// ...
}
4. 数据获取库
虽然手动管理 fetch
是可行的,但随着应用变得复杂,缓存、重新验证、请求去重等需求会使代码变得异常复杂。这时,专门的数据获取库就派上了用场。
Axios:更强大的 HTTP 客户端
Axios 是一个流行的、基于 Promise 的 HTTP 客户端。它不是一个数据同步库,而是一个 fetch
的替代品,提供了更强大和便捷的 API。它有如下的特性:
- 自动转换 JSON: 无需手动调用
response.json()
。 - 更好的错误处理: 对 4xx/5xx 状态码自动
reject
Promise,可以直接在.catch
中捕获。 - 请求和响应拦截器 (Interceptors): 这是 Axios 的王牌功能。可以在请求发送前或响应返回后进行全局处理。
- 创建实例: 可以创建具有预设配置(如
baseURL
)的 Axios 实例。 - 内置 CSRF 保护。
下面是一个创建实例与拦截器的例子。
// api/client.js
import axios from 'axios';
// 1. 创建一个 Axios 实例
const apiClient = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000, // 请求超时时间
});
// 2. 添加请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么,例如添加认证 Token
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// 对请求错误做些什么
return Promise.reject(error);
}
);
// 3. 添加响应拦截器
apiClient.interceptors.response.use(
(response) => {
// 对响应数据做点什么,例如直接返回 response.data
return response.data;
},
(error) => {
// 处理常见的错误,例如 401 未授权
if (error.response && error.response.status === 401) {
// 跳转到登录页
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
之后,在组件中就可以使用这个配置好的实例了:
import apiClient from './api/client';
// TanStack Query 结合 apiClient
useQuery({
queryKey: ['user', userId],
queryFn: () => apiClient.get(`/users/${userId}`)
});
TanStack Query:现代数据同步方案
TanStack Query 是一个强大的服务端状态管理库,它将数据获取、缓存、同步和更新的复杂逻辑封装得非常好。它有如下的核心概念扩展:
-
queryKey
(查询键): 这是 TanStack Query 的核心。它是一个数组,不仅是缓存的唯一标识,还能包含动态参数。当键中的任何部分变化时,查询都会自动重新执行。['todos']
- 获取待办事项列表['todos', 5]
- 获取 ID 为 5 的单个待办事项['todos', { status: 'done', page: 2 }]
- 获取状态为 “done” 的第二页待办事项
-
queryFn
(查询函数): 一个返回 Promise 的函数,负责实际的数据获取。通常在这里使用fetch
或 Axios。 -
staleTime
与cacheTime
:staleTime
: 数据保持“新鲜”的时间。在此期间,组件重新挂载不会触发新的网络请求。默认为0
。cacheTime
: 数据在没有观察者(即没有组件正在使用此查询)后,在缓存中保留的时间。默认为 5 分钟。到期后,数据会被垃圾回收。
对于创建、更新或删除数据的操作,可以使用 useMutation
Hook。
import { useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from './api/client';
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => {
// 这里的 newTodo 是从 mutate() 调用时传来的变量
return apiClient.post('/todos', newTodo);
},
// 当 mutation 成功时...
onSuccess: () => {
// ...让所有以 ['todos'] 开头的查询失效,从而触发自动重新获取
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
const handleAddTodo = () => {
mutation.mutate({ title: '新待办事项', completed: false });
};
return (
<div>
<button onClick={handleAddTodo} disabled={mutation.isPending}>
{mutation.isPending ? '正在添加...' : '添加待办'}
</button>
{mutation.isError && <p>错误: {mutation.error.message}</p>}
</div>
);
}
查询失效 (Query Invalidation) 是 TanStack Query 的一个关键特性。如上例所示,当一个 mutation
成功后,我们不再需要手动去更新 todos
列表的状态。通过调用 queryClient.invalidateQueries
,我们告诉 TanStack Query “这个数据已经过时了”,它会自动找到所有相关的 useQuery
并重新获取最新数据,确保 UI 与服务端状态同步。这种模式极大地简化了状态管理和数据同步的逻辑。