主题
Day 7:异步数据——认真对付 loading / error / empty 三态
今天完成什么
- 用
useEffect拉异步数据,把 Day 6 的假列表换成"从 API 来" - 认真处理四种状态:加载中 / 出错 / 空列表 / 有数据
- 解决"请求竞态"(快速切换导致旧请求覆盖新数据)
- 把这套逻辑抽成一个
useAsync自定义 Hook
接在昨天哪里
Day 6 的组件列表是写死的 mockComponents。真实项目里,这个列表是异步从后端拉的——而异步就意味着:
- 数据还没到(加载中,得转圈)
- 请求失败了(出错,得提示 + 重试)
- 拉回来是空的(空列表,得引导"去新建")
- 拉到了(成功,渲染列表)
新手最常犯的错就是只写"成功"那条路,另外三种一概不管。今天把四态一次性做扎实——这是区分玩具和产品的分水岭。
核心概念
1. 异步数据的四种状态
任何一次异步请求,界面都可能处在这四种之一:
text
loading → 加载中(转圈)
error → 失败(红字 + 重试按钮)
empty → 成功但没数据(空状态引导)
success → 成功且有数据(正常渲染)代码里通常用几个 state 表示:
typescript
const [data, setData] = useState<T[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);2. 在 useEffect 里发请求
请求是副作用,放 useEffect。空依赖 [] 表示"进页面拉一次":
typescript
useEffect(() => {
setLoading(true);
fetchData()
.then((res) => setData(res))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);3. 请求竞态(race condition)
这是异步的经典坑。设想:你切到 A 组件(发请求 A),A 还没回来又切到 B(发请求 B)。如果 A 比 B 晚回来,就会用过期的 A 数据覆盖掉 B——界面和 URL 对不上了。
解法:用 Day 2 学的清理函数打一个"作废标记"。effect 重跑或卸载时,把上一次请求标记为作废,回来了也不采用:
typescript
useEffect(() => {
let cancelled = false; // 作废标记
fetchData().then((res) => {
if (!cancelled) setData(res); // 没被作废才更新
});
return () => {
cancelled = true; // 清理:作废这次请求
};
}, [id]);4. async/await 在 effect 里的写法
useEffect 的回调不能直接是 async 函数(它得返回清理函数,不能返回 Promise)。要在里面定义一个 async 再调用:
typescript
useEffect(() => {
async function run() {
const res = await fetchData();
// ...
}
run();
}, []);动手前的目录
今天新增:
text
src/
├─ api/
│ └─ mockApi.ts # 模拟后端接口 [新增]
└─ hooks/
└─ useAsync.ts # 通用异步 Hook [新增]修改:
text
src/
└─ pages/
└─ ComponentListPage.tsx # 改用异步数据 + 四态 [修改]分步实现
第 1 步:写一个假后端
没有真后端也能练。用 Promise + setTimeout 模拟"网络延迟",还故意留个"随机失败"好测试出错态。
新建 src/api/mockApi.ts:
typescript
export interface ComponentItem {
id: string;
name: string;
}
const DB: ComponentItem[] = [
{ id: 'card-a', name: '用户信息卡片' },
{ id: 'card-b', name: '商品展示卡片' },
{ id: 'form-c', name: '联系表单' },
];
/**
* 模拟"拉组件列表"接口:延迟 800ms,10% 概率失败
*/
export function fetchComponents(): Promise<ComponentItem[]> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.1) {
reject(new Error('网络开小差了,请重试'));
} else {
resolve(DB);
}
}, 800);
});
}想测空态?临时把
resolve(DB)改成resolve([])。想必现出错?把0.1调成1。
第 2 步:先用"土办法"在页面里做四态
先不抽 Hook,直接在 ComponentListPage 里把四态写明白,理解透了再抽。
tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { fetchComponents, ComponentItem } from '../api/mockApi';
export function ComponentListPage() {
const navigate = useNavigate();
const [data, setData] = useState<ComponentItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 用来手动触发重新请求
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetchComponents()
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true; // 竞态保护
};
}, [reloadKey]); // reloadKey 变 → 重新拉
// ① 加载中
if (loading) return <div>加载中…</div>;
// ② 出错
if (error) {
return (
<div>
<p style={{ color: 'red' }}>出错了:{error}</p>
<button onClick={() => setReloadKey((k) => k + 1)}>重试</button>
</div>
);
}
// ③ 空列表
if (!data || data.length === 0) {
return (
<div>
<p>还没有任何组件</p>
<button onClick={() => navigate('/editor/new')}>去新建一个</button>
</div>
);
}
// ④ 成功且有数据
return (
<div>
<h1>我的组件</h1>
<ul>
{data.map((comp) => (
<li key={comp.id}>
{comp.name}
<button onClick={() => navigate(`/editor/${comp.id}`)}>编辑</button>
</li>
))}
</ul>
</div>
);
}注意这个"提前 return"的写法:加载中 return、出错 return、空 return,最后才是正常渲染。四条路各走各的,清清爽爽。重试靠
reloadKey自增触发 effect 重跑。
第 3 步:把异步逻辑抽成 useAsync Hook
上面那套 data/loading/error/cancelled 每个异步页面都要写一遍,抽成 Hook 复用。
新建 src/hooks/useAsync.ts:
typescript
import { useState, useEffect, useCallback } from 'react';
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* 通用异步数据 Hook:自动管理 loading/error/data,内置竞态保护和 reload
* @param asyncFn 返回 Promise 的函数
* @param deps 依赖变化时自动重新请求
*/
export function useAsync<T>(
asyncFn: () => Promise<T>,
deps: React.DependencyList = []
): AsyncState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
asyncFn()
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, reloadKey]);
return { data, loading, error, reload };
}第 4 步:用 Hook 重写页面
页面一下子清爽多了:
tsx
import { useNavigate } from 'react-router-dom';
import { useAsync } from '../hooks/useAsync';
import { fetchComponents } from '../api/mockApi';
export function ComponentListPage() {
const navigate = useNavigate();
const { data, loading, error, reload } = useAsync(fetchComponents, []);
if (loading) return <div>加载中…</div>;
if (error) {
return (
<div>
<p style={{ color: 'red' }}>出错了:{error}</p>
<button onClick={reload}>重试</button>
</div>
);
}
if (!data || data.length === 0) {
return (
<div>
<p>还没有任何组件</p>
<button onClick={() => navigate('/editor/new')}>去新建一个</button>
</div>
);
}
return (
<div>
<h1>我的组件</h1>
<ul>
{data.map((comp) => (
<li key={comp.id}>
{comp.name}
<button onClick={() => navigate(`/editor/${comp.id}`)}>编辑</button>
</li>
))}
</ul>
</div>
);
}完整代码
今天文件清单:
- 新增
src/api/mockApi.ts(第 1 步) - 新增
src/hooks/useAsync.ts(第 3 步) - 修改
src/pages/ComponentListPage.tsx(第 4 步,用 Hook 版)
运行效果
- 进列表页,先看到"加载中…"约 800ms
- 大概率加载出三张卡片
- 多刷新几次,偶尔(10%)会出现"出错了 + 重试",点重试能恢复
- 把
mockApi里resolve(DB)临时改成resolve([]),看到"还没有任何组件 + 去新建" - 把失败概率调成
1,稳定复现出错态,验证重试逻辑
常见错误
错误 1:Warning: Can't perform a React state update on an unmounted component
原因:组件卸载后,迟到的请求还在 setState。
解决:就是第 3 点的竞态保护——cancelled 标记 + 清理函数。抽好的 useAsync 已内置。
错误 2:effect 回调写成 async () => {} 报警告
原因:useEffect 回调不能返回 Promise(它要返回清理函数)。
解决:在里面定义 async 函数再调用(核心概念第 4 点),或用 .then() 链式写法。
错误 3:一直在加载,转圈不停
原因:setLoading(false) 没被调到(可能 catch 里漏了,或没用 finally)。
解决:用 .finally(() => setLoading(false)),保证成功失败都会关掉 loading。
错误 4:重试按钮点了没反应
原因:重试没有真正触发 effect 重跑。
解决:用 reloadKey 自增(放进依赖),或 useAsync 返回的 reload()。
动手改一改
- 编辑页也异步:给
EditorPage加一个fetchComponentById(id),用useAsync(() => fetchComponentById(id), [id]),亲手验证切换组件时的竞态保护 - 骨架屏:把"加载中…"换成灰色骨架块(skeleton),体验更高级
- 保留旧数据:重新加载时不清空旧列表(
keepPreviousData),只在上面盖一层 loading,避免闪烁 - 了解 React Query:查一下
@tanstack/react-query——它把今天这套(缓存、竞态、重试、后台刷新)都封装好了。真实项目基本都用它,但先自己手写一遍才知道它在替你做什么
验收清单
- [ ] 新建了
mockApi.ts(带延迟和随机失败) - [ ] 列表页处理了加载 / 出错 / 空 / 成功四种状态
- [ ] 有竞态保护(
cancelled+ 清理函数) - [ ] 抽出了
useAsync通用 Hook - [ ] 出错能重试,空态有引导
- [ ] 能说清"请求竞态"是什么、怎么防
今日总结
学到了什么
- 异步四态:loading / error / empty / success,一个都不能少
- 提前 return 分流:四条路各写各的,清爽
- 请求竞态:
cancelled标记 + 清理函数 - effect 里的 async:不能直接 async 回调
- 抽 Hook 复用:
useAsync一处封装,处处调用 - 认识 React Query:知道生产里有更强的轮子
关键代码
typescript
useEffect(() => {
let cancelled = false;
fetchData().then((res) => { if (!cancelled) setData(res); });
return () => { cancelled = true; }; // 竞态保护
}, [deps]);今天的限制
- 预览区如果渲染的组件代码有 bug,会整页白屏崩掉
- 一个小组件出错不该拖垮整个应用——需要"错误边界"兜底
明天做什么
Day 8(最后一课)学 Error Boundary:给预览区加错误边界,组件崩了只崩那一小块 + 显示"重试",其余照常。然后做全课程复盘。
四态处理是"前端工程素养"的直接体现。会不会写 loading/error/empty,一眼就能看出是新手还是熟手。你现在会了。
进行中