Mastra React 工程实践:用 Promise.all 消除独立异步操作的水瀑效应(Waterfall)
【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra
导读
本文讲解 Mastra 工程团队在 React 性能规范中评级为CRITICAL(关键)的第一优先级规则——Promise.all() for Independent Operations(对相互独立的异步操作使用Promise.all并发执行)。它解决的是 React 应用中最常见的性能杀手:一串互不依赖的await被顺序执行,形成网络请求瀑布(waterfall),白白叠加多次网络往返延迟。读完本文,你将掌握如何识别可并行的异步调用、如何在 Mastra Playground 这类真实代码库中落地该模式、如何处理并发下的错误与集合扇出,以及何时必须保留串行依赖。
该规则源自仓库内的最佳实践技能库:规则正文见 .claude/skills/react-best-practices/references/rules/async-parallel.md,规则优先级与分类见 .claude/skills/react-best-practices/SKILL.md 和 .claude/skills/react-best-practices/references/react-best-practices-reference.md。
为什么消除 Waterfall 是九大分类中的第一优先级
在 Mastra 的 React 最佳实践中,共有 26 条规则、9 个分类,而Eliminating Waterfalls(消除水瀑效应)以 CRITICAL 影响评级排在优先级第 1 位。规则元数据中给出的性能影响描述是2-10× 的提升(impactDescription: 2-10× improvement)。
原因很直接:规则目录 react-best-practices-reference.md 中明确写道:“Waterfalls are the #1 performance killer. Each sequential await adds full network latency.”——每个顺序await都会完整叠加一次网络延迟(一次完整的 RTT:请求发出 + 等待响应往返)。当并行执行时,多个请求共享同一时间窗口,总耗时约等于其中最慢的那一个,而非逐个累加。因此在所有优化手段中,消除不必要的串行等待带来的收益最大、成本最低。
同时,SKILL.md 规定该技能应用于“编写新 React 组件、实现数据获取、评审性能问题、重构既有代码、优化包体积与加载时间”等场景,并将该规则列在“Critical Patterns (Apply First)”(先应用的关键模式)清单首位。
规则核心:独立操作用Promise.all,而不是顺序await
规则正文的定义非常简洁:当异步操作之间没有相互依赖(interdependencies)时,应使用Promise.all()并发执行它们。
规则原文给出的反例与正例是:
// 错误写法:顺序执行,3 次网络往返(3 round trips) const user = await fetchUser(); const posts = await fetchPosts(); const comments = await fetchComments();// 正确写法:并行执行,仅 1 次往返(1 round trip) const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);关键在于:fetchUser、fetchPosts、fetchComments三个请求彼此不依赖对方的返回值——任何一个都不需要另一个的结果作为入参。此时 JS 引擎依然会逐个await、逐个发起请求、逐个等待响应,形成严格的“发起 → 等待完成 → 再发起”的串行链条。改用Promise.all后,三个请求几乎同时发出,总耗时从三个请求延迟之和收敛为三个请求延迟的最大值。
对网络请求而言,这一收敛在慢网络(高延迟)环境下收益最为显著。比如单个请求 RTT 为 200ms,顺序执行 3 个请求约需 600ms+,并行执行则约为 200ms+,这正是规则元数据声称“2-10× improvement”的现实来源。
何时并行、何时必须串行:依赖分析是关键
Promise.all不是银弹,使用前必须判断操作之间是否存在数据依赖。判断标准很简单:
- 如果 B 的入参来自 A 的返回值,那么 A → B 是强依赖,必须串行,这种 waterfall 是业务语义决定的、无法消除;
- 如果 A、B、C 各自独立,仅最终结果需要一起使用,那么它们就是
Promise.all的候选; - 如果只是“都发出去,但不需要聚合结果”,甚至可以直接发起后不
await(fire-and-forget),但这通常不是 React 数据获取场景的首选。
从源码结构看,Mastra Playground 中大量遵循了这一判断。以 packages/playground/src/domains/agents/hooks/use-agent-cms-form.ts 第 452-457 行为例,发布 Agent 草稿时需要同时读取 Agent 详情和最新版本列表,两个请求互不依赖,于是并行获取:
const [agentDetails, versionsResponse] = await Promise.all([ client.getStoredAgent(options.agentId).details(), client .getStoredAgent(options.agentId) .listVersions({ orderBy: { field: 'createdAt', direction: 'DESC' }, perPage: 1 }), ]);注意,即使details()与listVersions()都作用于同一个 Agent 实体、返回类型相关,只要两者的请求参数和结果不需要互相传递,就属于独立操作,可以并行。
而同一文件中“先保存草稿,再基于草稿激活版本”的流程(第 443-449 行)则是有依赖的串行:必须先拿到保存成功的版本响应,才能读取latestVersion.id并调用activateVersion。这种依赖链不属于本规则的消除范围。
错误处理:Promise.all的 fail-fast 与Promise.allSettled
Promise.all的语义是快速失败(fail-fast):只要其中一个 Promise 被 reject,整个Promise.all立即 reject,且不会等待其他 Promise 的结果。这在“所有请求必须全部成功才有意义”的场景下是合理的——例如上面发布 Agent 的校验逻辑,任何一个请求失败都应当中断后续流程并提示用户。
但当你的场景是“希望尽量拿到所有结果,个别失败不应拖垮整体”时,应改用Promise.allSettled。典型例子是 packages/playground/src/domains/agents/hooks/use-agent-cms-form.ts 第 191 行的批量删除 MCP 客户端:
await Promise.all(mcpClientsToDelete.map(id => client.getStoredMCPClient(id).delete()));这里使用Promise.all意味着任一删除失败会导致整体失败——这在批量操作需要“要么全成功、要么全失败”的事务语义时是正确的选择;而如果你希望逐个删除、允许部分失败并分别收集结果,则应替换为Promise.allSettled后遍历results检查每个status。
选择建议:
- 所有结果都必需、任一失败即整体失败 →
Promise.all; - 需要全部结果、允许个别失败并自行处理 →
Promise.allSettled; - 需要按完成顺序流式处理 →
Promise.all+ 结果数组索引即可(Promise.all保证结果顺序与输入数组一致)。
集合扇出:Promise.all+map与并发上限控制
当独立操作来自一组动态集合(而非三个固定的调用)时,标准写法是Promise.all(arr.map(fn))。仓库中有两处代表性实现:
一处是批量解析 MCP 客户端详情,见 packages/playground/src/domains/agents/hooks/use-agent-cms-form.ts 第 100 行:
Promise.all(ids.map(id => client.getStoredMCPClient(id).details())) .then(results => { /* 逐个映射回表单字段 */ });另一处是附件转消息,见 packages/playground/src/lib/ai-ui/attachments/composer-attachments.tsx 第 144 行:
return Promise.all(attachments.map(attachmentToCoreUserMessage));Promise.all+map的代价是所有元素同时并发。当集合规模很小(几个 MCP 客户端、几张附件)时完全没问题;但当集合可能达到成百上千项、且每个请求都打到远端服务时,无上限并发可能压垮服务端或被限流。此时应当做分块限流(batching):把大数组切成固定大小(如每次 20 个)的块,逐块Promise.all,块内并行、块间串行。判断依据是集合大小的上界:固定且很小(个位数)直接用Promise.all;动态且可能很大(上百)建议加并发上限。
仓库实战印证:Mastra Playground 中的并行数据获取
最能体现该规则实战价值的例子在 packages/playground/src/domains/experiments/hooks/use-experiment-metrics.ts 第 34-56 行。这是一个 React Query 的queryFn,一次要聚合三类实验指标——Token 用量(sum)、平均耗时(avg)、运行次数(count),三者彼此独立,于是并行发起:
queryFn: async (): Promise<ExperimentMetrics> => { if (!experimentId) throw new Error('experimentId is required'); const filters = { experimentId }; const [tokens, avgDuration, runs] = await Promise.all([ client.getMetricAggregate({ name: ['mastra_model_total_input_tokens', 'mastra_model_total_output_tokens'], aggregation: 'sum', filters, }), client.getMetricAggregate({ name: ['mastra_agent_duration_ms'], aggregation: 'avg', filters }), client.getMetricAggregate({ name: ['mastra_agent_duration_ms'], aggregation: 'count', filters }), ]); return { totalTokens: tokens.value ?? null, estimatedCost: tokens.estimatedCost ?? null, costUnit: tokens.costUnit ?? null, avgAgentDurationMs: avgDuration.value ?? null, agentRuns: runs.value ?? null, }; },这段代码同时展示了该规则的三个要点:
- 并行发起:三个
getMetricAggregate互不依赖,一次性并发,指标面板渲染前的等待时间约等于最慢的那一个聚合请求; - 解构聚合结果:
const [tokens, avgDuration, runs] = ...,Promise.all保证结果数组顺序与输入一致,可以直接按位置解构,无需额外关联; - 在数据获取层内应用:并行发生在
queryFn内部,对外仍是一个 React Query 查询键(['experiment-metrics', experimentId]),配合refetchInterval: isActive ? 2000 : false实现实验运行期间的轮询刷新——这正是 SKILL.md 中“依赖查询参数保持严格、用 TanStack Query 管理去重与缓存”等配套规则(见 client-request-dedupe 规则)的协同用法。
另一个值得注意的用法是并行缓存失效。同一文件第 468-474 行在发布 Agent 后,一次性失效多个相关查询键:
await Promise.all([ queryClient.invalidateQueries({ queryKey: ['agent-versions', agentId] }), queryClient.invalidateQueries({ queryKey: ['stored-agent', agentId] }), queryClient.invalidateQueries({ queryKey: ['agent', agentId] }), queryClient.invalidateQueries({ queryKey: ['agents'] }), queryClient.invalidateQueries({ queryKey: ['stored-agents'] }), ]);虽然invalidateQueries本身是本地操作、延迟很低,但这种写法同样遵循“独立操作并行化”的规则:多个互不依赖的缓存刷新一次性触发,而不是逐个await串行等待,避免了无谓的等待链条。
在 React 数据获取中的两种落地姿势
结合仓库实践与规则库的整体建议,Promise.all在 React 应用中有两种典型落地姿势:
姿势一:在单个queryFn内并行(一个查询键聚合多个请求)
如上文的use-experiment-metrics。适合“一组独立请求共同驱动一个 UI 区域”的场景——一个 hook 返回一个包含多字段结果的对象,调用方只关心聚合后的数据。注意保持 hook 返回的 API 窄而明确,符合 structure-narrow-apis 规则 对“拆分过大返回对象”的要求。
姿势二:多个独立useQuery各自请求(多个查询键并行)
适合“多个请求分别驱动不同 UI 区块”的场景。React Query 会为每个查询键独立管理缓存、去重与重新验证,多个useQuery同时挂载时会自动并行发出请求,无需手动Promise.all——这正是 SKILL.md 中“使用 TanStack Query 实现自动请求去重”的规则(client-request-dedupe 规则)所覆盖的范畴。
两种姿势的选择取决于结果的消费方式:统一消费则合并在一个queryFn里用Promise.all,分块消费则拆成多个查询键。但无论哪种,都应当避免在组件顶层写一连串裸await的顺序请求——那正是本规则要消灭的水瀑。
评审清单:如何在代码评审中发现 Waterfall
把本规则落地为可执行的评审习惯,可以对照以下检查项(这也是规则文件被设计为“供 Agent 与工程师快速检索”的用途):
- 寻找连续裸
await:一段函数内出现多个顶层await,逐一看后面的await是否使用了前面await的返回值。如果后一个请求的入参与前一个请求无关,它就是水瀑候选; - 计算往返次数:把顺序
await数一遍——3 个独立请求顺序执行就是 3 次往返。注释或提交信息中可直接标注“由 3 round trips 收敛为 1 round trip”; - 检查
Promise.all的输入是否为独立操作:确认列表中的每个 Promise 不依赖列表中的其他 Promise 的结果; - 确认失败语义:需要 fail-fast 用
Promise.all,需要容错用Promise.allSettled,不要在Promise.all里写catch后返回undefined来模拟容错(这会丢失失败信息且难以定位); - 检查集合扇出的规模:
Promise.all(arr.map(...))前确认集合规模上界,避免无上限并发; - 在数据获取层而非渲染层做并行:并行逻辑放在
queryFn或 service/hook 内部,组件只消费结果,保持渲染函数干净。
关于测试,规则库的整体测试导向是 BDD 风格、只 mock 网络层(见 testing-bdd-no-mocks 规则)——这意味着你可以在测试中真实驱动并发逻辑,通过断言“多个请求同时发出”来验证并行实现,而不是用 mock 掩盖请求时序。
总结
Promise.all()for Independent Operations 是 Mastra React 最佳实践库中评级最高(CRITICAL、优先级第 1)的规则,因为它直接对抗日渐成为前端性能头号杀手的水瀑效应。核心方法论只有一条:凡是互不依赖的异步操作,就并行执行;凡是依赖前序结果的,才允许串行。
仓库中的 Playground 代码(use-experiment-metrics.ts 的指标聚合、use-agent-cms-form.ts 的版本发布与 MCP 批量操作、composer-attachments.tsx 的附件转换)是这条规则在真实 React 数据获取场景中的直接印证:并行发起、顺序解构、配合 TanStack Query 去重与失效,即可在几乎不增加代码复杂度的前提下,把多次串行网络往返收敛为一次并行的等待。在编写、评审或重构 React 代码时,把它当作第一优先级检查项——先消除 Waterfall,再谈其他优化。
【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考