从API获取数据然后渲染成列表,是React应用里最常见的任务之一。看似只要发个请求、存进state、map一下就完事,但实际写起来坑不少:请求重复发送、组件卸载后更新state导致警告、列表key用索引引发渲染错乱、loading状态闪烁等等。这篇文章把数据获取和列表渲染拆开来讲清楚,并给出一套可以直接抄进项目的最佳实践写法。

数据获取的正确姿势:useEffect中的异步请求
先说最容易出错的地方:很多人习惯把async关键字直接加在useEffect的回调函数上,然后发现控制台直接报错。这是因为useEffect的回调函数要求返回一个清理函数或者undefined,而async函数返回的是Promise,类型不匹配。正确做法是在useEffect内部再定义一个异步函数,然后调用它。
另一个常见问题是忽略请求竞态。假如用户快速切换筛选条件,第一次请求还没返回,第二次请求已经发出,如果第一次响应晚到,页面展示的就是过期的旧数据。解决办法是用一个标志变量配合组件卸载逻辑,或者干脆使用AbortController在请求发出前取消上一次未完成的请求。
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 用于标记组件是否已卸载,避免卸载后更新state
let ignore = false;
// AbortController 用于取消未完成的请求
const controller = new AbortController();
async function fetchData() {
try {
setLoading(true);
setError(null);
const res = await fetch('https://api.ipipp.com/users', {
signal: controller.signal
});
if (!res.ok) {
throw new Error(`请求失败,状态码:${res.status}`);
}
const data = await res.json();
if (!ignore) {
setUsers(data);
}
} catch (err) {
// AbortError 是主动取消导致的,不算真正的错误
if (err.name !== 'AbortError' && !ignore) {
setError(err.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchData();
// 清理函数:取消请求并标记忽略后续更新
return () => {
ignore = true;
controller.abort();
};
}, []);
// ...渲染逻辑
}这段代码有几个关键点值得注意。第一,清理函数里既设置了ignore标志又调用了abort,双重保险:abort让浏览器中断网络请求,ignore让即便响应已经到达也不会写入state。第二,fetch在遇到404、500这类HTTP错误时并不会抛异常,只有网络断开才会reject,所以必须手动检查res.ok并抛出错误。第三,loading和error分开存储,渲染时逻辑会更清晰。
fetch还是axios?请求方案的对比与选择
fetch是浏览器原生API,零依赖,对简单场景完全够用。但它有几个体验不太好的地方:不会自动抛HTTP错误、不支持自动JSON序列化请求体、默认没有超时控制。axios在这些方面都做得更好,而且拦截器机制可以在全局统一处理token和错误码。
下面对比一下两者的典型差异:
| 特性 | fetch | axios |
|---|---|---|
| HTTP错误处理 | 需手动判断res.ok | 状态码非2xx自动reject |
| 响应数据解析 | 调用res.json()异步解析 | 自动解析到data字段 |
| 请求超时 | 需配合AbortController | 直接配置timeout选项 |
| 请求取消 | signal加AbortController | CancelToken或AbortController |
| 包体积 | 零依赖 | 约十几KB |
如果项目里请求逻辑复杂、需要统一鉴权和错误上报,axios通常是更好的选择;如果只是几个简单的GET请求,fetch加上简单的封装就足够了,没必要为此引入额外依赖。用axios改写上面的请求大致是这样:
async function fetchData(signal) {
try {
const res = await axios.get('https://api.ipipp.com/users', {
signal, // axios 新版同样支持 AbortController
timeout: 10000 // 10秒超时
});
setUsers(res.data); // 已经自动解析为对象
} catch (err) {
if (!axios.isCancel(err)) {
setError(err.response?.data?.message || err.message);
}
}
}列表渲染与key的正确使用
拿到数据后,渲染列表通常用map。这里最大的坑是key的选择。用数组索引当key,在列表只做一次性静态展示时问题不大,可一旦涉及排序、删除、插入,React会因为key对不上而复用错误的DOM节点,导致输入框内容错位、动画闪烁等诡异问题。最佳实践是使用数据中稳定且唯一的业务id作为key,比如用户ID、商品编号。
如果后端返回的数据确实没有唯一标识,可以在前端为每条数据生成一次性的稳定id(比如在获取数据后立即用uuid处理一遍),但要注意这个id只在一次会话内有效,不能作为跨请求的持久标识。
function UserList({ users, loading, error }) {
if (loading) return <p>加载中...</p>;
if (error) return <p style={{ color: 'red' }}>出错了:{error}</p>;
if (users.length === 0) return <p>暂无数据</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>
<strong>{user.name}</strong> — {user.email}
</li>
))}
</ul>
);
}上面这个渲染组件把loading、error、空数据三种边界状态都处理了,这在实际项目里非常必要。很多初学者只写正常路径,结果一遇到网络错误页面就白屏或卡在加载状态。另外注意,条件渲染的顺序也有讲究:先判断loading再判断error,可以避免请求失败时页面同时闪现加载中骨架和错误提示。
进阶优化:抽离自定义Hook与分页加载
当数据获取逻辑在多个组件里重复出现时,把它抽成一个自定义Hook是值得的。这样组件本身只关心渲染,请求细节全部封装起来,复用和测试都更方便。
function useFetchList(url) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { if (!ignore) setData(json); })
.catch(err => { if (!ignore) setError(err.message); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [url]);
return { data, loading, error };
}
// 组件中使用
function ProductList() {
const { data, loading, error } = useFetchList('https://api.ipipp.com/products');
if (loading) return <p>加载中...</p>;
if (error) return <p>{error}</p>;
return data.map(p => <div key={p.id}>{p.name}</div>);
}除了自己封装,社区里成熟的请求库如React Query(现名TanStack Query)和SWR解决了更多问题:内置缓存、自动重新请求、窗口聚焦刷新、分页和无限滚动支持等。如果项目里请求场景复杂,引入这类库比手写useEffect更省心,也能避免大部分竞态和缓存问题。不过对于学习阶段或者小项目,理解手写方案的每一个细节仍然很有价值,因为这些库底层处理的就是同样的这些问题。
总结一下核心要点:请求逻辑放在useEffect里独立的异步函数中,配合清理函数防止卸载后更新state;用稳定的业务id作为列表key;loading、error、空数据三种状态都要处理;逻辑复杂时抽成自定义Hook或交给专业请求库。把这些习惯养成,写出来的React组件会稳定得多。