隐藏的请求瀑布图:将异步代码与真实的依赖关系图对应起来
学会识别那些制造虚假依赖的顺序等待问题,使用 Promise.all 解决这些问题,并重构 React 仪表板,让每个组件仅获取其所需的数据。
来源: Promises Make Async Code Look More Sequential Than It Really Is
async/await 使得异步代码从上到下依次执行,而这种可读性也有其代价:每个 await 都在默默表明下一行代码依赖于前一行。一旦这种假设不成立,就会出现请求级联现象,审查时看起来非常正常,但实际会在网络标签页中体现为延迟。下图展示了存在该问题的控制面板加载器,首先通过 Promise.all 解决问题,随后再拆分组件,使其按正确顺序自动执行。
功能完备的控制面板加载器
控制面板编辑器看似简单,但实际上一个组件往往需要负责加载整个控制面板、获取所有小部件的信息、加载每个小部件的选项和筛选条件、确定小部件类型、获取数据,最后才渲染用户界面。以下是此类组件中数据加载部分的简化版本:
useEffect(() => {
const loadDashboard = async () => {
const dashboard = await getDashboard(id);
const widgets = [];
for (const widgetId of dashboard.widgetIds) {
const options = await getWidgetOptions(widgetId);
const filters = await getWidgetFilters(widgetId);
const widget = await getWidget(widgetId);
const data = await getWidgetData(
widgetId,
filters,
options,
);
widgets.push({
widget,
options,
filters,
data,
});
}
setWidgets(widgets);
};
loadDashboard();
}, [id]);
这不仅是在效应函数中的命令式代码,还隐含了一个错误的假设。
一个循环中的两个独立问题
第一个问题很容易看出:尽管各个组件是独立的,但for...of循环却是一次处理一个组件。Promise.all显然是解决方案,但它只能解决一半的问题。
第二个问题更为隐蔽。在循环中,代码先请求选项并等待,再请求过滤器并等待,接着请求组件并等待,最后才请求数据。这些await语句描述的是并不存在的依赖关系——选项不需要过滤器,过滤器也不需要组件,而数据请求根本就不使用组件。单个组件的实际依赖关系图应如下所示:
widgetId → options ─┐
→ filters ─┴→ data
→ widget
这段代码实际上实现的是一条简单的链式结构:
widgetId → options → filters → widget → data
对于包含N个组件的仪表板,原有的加载机制会依次进行大约4N次往返请求。网络标签页可以清晰地显示这一情况:请求会一个接一个地发送,每个组件对应一次请求。
首个解决方案:使用Promise.all对实际数据结构进行编码
简单的解决办法是让代码遵循真实的处理流程。各个组件会同时被处理,而在每个组件内部,三个独立的请求会同步开始。只有数据请求需要等待,因为它确实需要相关的选项和过滤条件:
useEffect(() => {
const loadDashboard = async () => {
const dashboard = await getDashboard(id);
const widgets = await Promise.all(
dashboard.widgetIds.map(async (widgetId) => {
const [options, filters, widget] = await Promise.all([
getWidgetOptions(widgetId),
getWidgetFilters(widgetId),
getWidget(widgetId),
]);
const data = await getWidgetData(
widgetId,
filters,
options,
);
return {
widget,
options,
filters,
data,
};
}),
);
setWidgets(widgets);
};
loadDashboard();
}, [id]);
关键路径从每个组件需要四次请求,再乘以组件数量,变为仪表板请求加上三个并行请求中最慢的那个加上数据请求。
在发送此内容之前,有两点值得补充。一旦有任何请求失败,Promise.all就会立即拒绝,从而导致整个控制面板显示空白;而使用Promise.allSettled或为每个组件单独处理错误则能让其余部分依然可见。此外,该方案没有清理机制,因此如果id变化较快,旧控制面板的缓慢响应可能会覆盖新内容。在清理函数中添加忽略标志或AbortController即可避免这种情况。
更好的解决方案:让组件自行管理需求
即便进行了修复,仍有一个组件负责处理控制面板所需的全部请求和模型。更为持久的改进方式是按功能划分界面,让每个组件通过数据钩子自行获取所需数据:
const DashboardsPage = () => {
const dashboards = useDashboards();
return <Navigate to={`/dashboards/${dashboards[0].id}`} />;
};
const Dashboard = () => {
const { id } = useParams();
const dashboard = useDashboard(id);
return dashboard.widgetIds.map((widgetId) => (
<WidgetSkeleton key={widgetId} widgetId={widgetId} />
));
};
const WidgetSkeleton = ({ widgetId }: { widgetId: string }) => {
const options = useWidgetOptions(widgetId);
const filters = useWidgetFilters(widgetId);
return (
<>
<WidgetHeader options={options} filters={filters} />
<Widget
widgetId={widgetId}
options={options}
filters={filters}
/>
</>
);
};
DashboardsPage会重定向到第一个控制面板(实际代码中需防止列表为空的情况)。Dashboard仅知晓各个组件的ID,并为每个ID渲染对应的WidgetSkeleton。由于WidgetSkeleton需要用于标题和组件的相关选项及过滤器,因此这些其实只是同一界面元素的两个需求而已,彼此之间并无顺序要求。
决定可视化类型的组件定义则是Widget组件的职责:
const Widget = ({
widgetId,
options,
filters,
}: {
widgetId: string;
options: WidgetOptions;
filters: WidgetFilters;
}) => {
const widget = useWidget(widgetId);
const Component =
MAP_WIDGET_TYPE_TO_VISUALIZATION[widget.type];
return (
<Component
widgetId={widgetId}
options={options}
filters={filters}
/>
);
};
它会从MAP_WIDGET_TYPE_TO_VISUALIZATION中选择对应的可视化类型,而该可视化类型则会从其实际依赖的三个值中获取数据:
const LineChart = ({
widgetId,
options,
filters,
}: LineChartProps) => {
const data = useWidgetData(widgetId, filters, options);
// ...
};
此处并未调整任何承诺。通过分离职责,依赖关系在组件树中变得清晰可见:在知晓小部件ID之前,仪表板必须先加载;每个小部件ID会解锁相应的选项、过滤器以及小部件定义;数据则只需等待其真实的输入数据。
关于暂停使用钩子的注意事项
这些请求是否真的能并行执行取决于钩子的实现方式。那些启动请求并返回加载状态的钩子会让所有组件同时渲染,因此所有请求会一起发起。但如果这些钩子处于挂起状态(例如那些直接返回数据的悬停模式查询,从代码片段来看它们似乎就属于这种情况,因为它们在未进行加载检查的情况下就读取了widget.type),情况就会发生变化。在同一组件中依次调用的两个处于挂起状态的钩子会按顺序执行,像Widget这样的子组件不会被渲染,也不会发起自己的请求,直到其父组件停止挂起状态。在这种架构下,整个组件树又会重新出现串行处理的状况。而在父组件中进行预取、使用组合查询API或路由加载器则可以避免这种情况。关于针对Suspense的特殊处理方式,请参阅相关文章。
核心要点
- 每个
await都表示下一步操作依赖于前一步的结果;需确保每个假设都是正确的。 - 在优化之前先绘制依赖图,然后让代码符合该依赖图结构,而非按照编写顺序来组织。
Promise.all适用于处理独立的请求,但需配合错误隔离和副作用清理机制使用。- 根据组件的职责来拆分组件通常能得到正确的请求顺序,除非使用了挂起钩子将其序列化。