首页 / 文章

隐藏的请求瀑布图:将异步代码与真实的依赖关系图对应起来

学会识别那些制造虚假依赖的顺序等待问题,使用 Promise.all 解决这些问题,并重构 React 仪表板,让每个组件仅获取其所需的数据。

发布者:REACTAPP.TOP阅读约 5 分钟1238 词

来源: 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的特殊处理方式,请参阅相关文章。

React Suspense中的并行获取。

核心要点

  • 每个await都表示下一步操作依赖于前一步的结果;需确保每个假设都是正确的。
  • 在优化之前先绘制依赖图,然后让代码符合该依赖图结构,而非按照编写顺序来组织。
  • Promise.all适用于处理独立的请求,但需配合错误隔离和副作用清理机制使用。
  • 根据组件的职责来拆分组件通常能得到正确的请求顺序,除非使用了挂起钩子将其序列化。