Головна / Статті

Приховані водоспади запитів: узгодження асинхронного коду з реальною діаграмою залежностей

Навчіться розпізнавати послідовні await-оператори, які створюють фальшиві залежності, виправляти їх за допомогою Promise.all та реструктурувати панель керування React так, щоб кожен компонент отримував лише те, що йому потрібно.

Видавець: REACTAPP.TOP5 хв читання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 знає лише ідентифікатори віджетів та відображає для кожного з них 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);
  // ...
};

Тут не було налаштовано жодних обіцянок. Розділення обов’язків зробило діаграму залежностей видимою у дереві компонентів: панель керування має надійти раніше, ніж стануть відомі ідентифікатори віджетів; кожен ідентифікатор віджета розблоковує опції, фільтри та визначення віджета; дані чекають лише на свої справжні вхідні дані.

Зауваження щодо призупинення хуків

Чи справді ці запити виконуються паралельно, залежить від того, як реалізовані хуки. Хуки, які ініціюють запит та повертають стан завантаження, дозволяють кожному компоненту відображатися одночасно, тож усі запити виконуються разом. Якщо ж хуки призупиняють виконання (наприклад, запити у режимі suspense, які повертають дані безпосередньо, що, схоже, припускаються у наведених фрагментах через читання widget.type без перевірки стану завантаження), ситуація змінюється. Два хуки, які призупиняють виконання та викликаються один за одним у одному компоненті, можуть виконуватися послідовно, і дочірній елемент на кшталт Widget не відображатиметься та не ініціюватиме власний запит, доки його батьківський елемент не припинить призупинення. У такій конфігурації можна знову створити послідовну обробку через дерево компонентів. Попереднє завантаження у батьківському елементі, комбінований API запитів чи завантажувач маршрутів допомагають уникнути цього. Щодо специфічного підходу до Suspense, див. паралельне отримання даних у React Suspense.

Ключові моменти

  • Кожен await є твердженням про те, що наступний крок залежить від попереднього результату; перевіряйте, чи є кожне твердження правдивим.
  • Спочатку намалюйте граф залежностей перед оптимізацією, а потім приведіть код у відповідність до нього, а не до порядку, у якому ви написали рядки.
  • Promise.all допомагає вирішувати проблеми з незалежними запитами, але його потрібно поєднувати з ізоляцією помилок та очищенням ефектів.
  • Розділення компонентів за їхніми обов’язками зазвичай забезпечує правильний порядок запитів, якщо тільки хуки suspending не серіалізують його.