React 异步模式
问题陈述
React 中的异步错误非常隐蔽,因为它们通常在开发中可以工作,但在负载下或在边缘情况下会失败。最常见的问题:遗漏 await 的异步函数,状态更新之间的竞态条件,以及假设操作按顺序完成。
模式:悬浮承诺检测
问题: 调用异步函数而不使用 await 会导致它在后台运行。如果后续代码依赖于其完成,您将获得竞态条件。
// 之前(有bug)- saveData 是异步的但没有被等待
saveData(item); // 火然后忘记 ❌
await processData(item); // 在保存完成之前运行
// 之后(已修复)
await saveData(item); // 等待状态更新 ✅
await processData(item); // 现在按正确的顺序运行
为什么它很微妙: 两个函数的签名中可能都有 async,但只有一个被等待了。代码乍一看“看起来是对的”。
检测:
# 查找潜在的悬浮承诺 - 没有 await 的异步调用
grep -rn "^\s*[a-zA-Z]*\s*(" --include="*.ts" --include="*.tsx" | \
grep -v "await\|return\|const\|let\|if\|else\|=>"
预防:
- ESLint 规则
@typescript-eslint/no-floating-promises- 在 lint 时捕获此问题 - 代码审查触发器:任何调用可能为异步的函数而没有
await、return或赋值的行
模式:后置条件验证
问题: 假设异步调用成功而没有验证。调用可能会提前返回,静默抛出,或未能更新状态。
// 之前(有bug)- 假设加载工作了
await loadData(id);
// 盲目地进行下一步...
// 之后(防御性)
await loadData(id);
const loaded = useStore.getState().data;
if (Object.keys(loaded).length === 0) {
throw new Error(
`Failed to load data for ${id} - cannot proceed`
);
}
原则: 将每个异步调用视为可能失败,直到证明否则。
何时验证:
- 在加载后续操作依赖的数据后
- 在必须完成才能继续的状态更新后
- 在不可逆操作之前(提交、删除)
模式模板:
await someAsyncOperation();
const result = getRelevantState();
if (!isValid(result)) {
throw new Error(`[${functionName}] Post-condition failed: ${diagnosticContext}`);
}
模式:异步函数识别
问题: 不是所有异步函数看起来都是异步的。Zustand 动作、回调和返回承诺的函数可能没有明显的 async 关键字。
隐藏的异步模式:
// 明显的异步
async function fetchData() { ... }
// 较不明显 - 返回 Promise
function fetchData(): Promise<Data> { ... }
// 隐藏 - 实际上异步的 Zustand 动作
const useStore = create((set, get) => ({
// 这看起来是同步的,但内部调用了异步
enableFeature: (id: string) => {
someAsyncSetup().then(() => { // ← 隐藏的异步!
set({ features: [...get().features, id] });
});
},
}));
// 适当的异步 Zustand 动作
const useStore = create((set, get) => ({
enableFeature: async (id: string) => {
await someAsyncSetup();
set({ features: [...get().features, id] });
},
}));
检测: 检查函数签名和实现:
# 查找返回 Promise 的函数
grep -rn "): Promise<" --include="*.ts" --include="*.tsx"
# 查找可能需要 await 的 .then() 链
grep -rn "\.then(" --include="*.ts" --include="*.tsx"
模式:顺序与并行异步
问题: 当它们可以并行时顺序运行异步操作(慢),或者当它们必须是顺序时并行(竞态条件)。
// 顺序 - 当顺序重要时正确
await stepOne();
await stepTwo();
await stepThree();
// 并行 - 当操作独立时正确
const [user, settings, history] = await Promise.all([
fetchUser(id),
fetchSettings(id),
fetchHistory(id),
]);
// 错误 - 当顺序重要时并行
await Promise.all([
stepOne(), // 这些有依赖关系!
stepTwo(),
]);
决策框架:
| 操作共享状态? | 必须按顺序运行? | 模式 |
|---|---|---|
| 否 | 否 | Promise.all() |
| 是 | 是 | 顺序 await |
| 是 | 否 | 通常按顺序以安全为原则 |
模式:useEffect 中的异步
问题: useEffect 回调不能直接是异步的。常见的清理和竞态条件错误。
// 错误 - useEffect 不能是异步的
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// 正确 - 内部异步函数
useEffect(() => {
async function load() {
const data = await fetchData();
setData(data);
}
load();
}, []);
// 更好 - 用于竞态条件的清理
useEffect(() => {
let cancelled = false;
async function load() {
const data = await fetchData();
if (!cancelled) {
setData(data);
}
}
load();
return () => {
cancelled = true;
};
}, [dependency]);
// 最好 - 使用 AbortController 进行 fetch
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
}
}
load();
return () => controller.abort();
}, [url]);
模式:React Query / TanStack Query
问题: 手动异步状态管理容易出错。使用库。
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// 获取数据
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return <Profile user={data} />;
}
// 带有缓存失效的变异
function UpdateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
return (
<button onClick={() => mutation.mutate(userData)}>
保存
</button>
);
}
ESLint 配置
添加这些规则以在 lint 时捕获异步问题:
{
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": "error"
}
}
必需: @typescript-eslint/eslint-plugin 和正确的 TypeScript 配置。
代码审查清单
审查异步代码时,请检查:
- [ ] 每个异步函数调用要么被
await,要么被return,要么明确地作为火然后忘记,并带有注释 - [ ] 相互依赖的操作使用
await进行序列化 - [ ] 在关键异步操作后验证后置条件
- [ ] 使用异步的
useEffect使用内部函数模式 - [ ] 当组件可能在异步过程中卸载时考虑竞态条件
- [ ] 异步失败时存在错误处理
- [ ] 对于应该可以取消的 fetch 调用使用 AbortController
快速调试
当出现异步定时问题时:
// 添加时间戳以追踪执行顺序
console.log(`[${Date.now()}] 开始步骤 1`);
await stepOne();
console.log(`[${Date.now()}] 完成步骤 1`);
console.log(`[${Date.now()}] 开始步骤 2`);
await stepTwo();
console.log(`[${Date.now()}] 完成步骤 2`);
查找:
- 操作完成的顺序出乎意料
- 操作在前一个操作完成之前开始
- 异常快速的“完成”(可能没有等待)