Strona główna / Artykuły

Ukryte wodospady żądań: dopasowywanie asynchronicznego kodu do rzeczywistego grafu zależności

Naucz się rozpoznawać sekwencyjne oczekiwanie, które tworzy fałszywe zależności, naprawiać je za pomocą Promise.all oraz przeprojektować panel sterowania w React tak, aby każdy komponent pobierał tylko to, czego potrzebuje.

Wydawca: REACTAPP.TOP5 min czytania1238 słów

Źródła: Promises Make Async Code Look More Sequential Than It Really Is

async/await sprawiają, że kod asynchroniczny jest czytelny od góry do dołu, ale ta czytelność ma swoją cenę: każde await po cichu informuje, że następny wiersz zależy od poprzedniego. Gdy to stwierdzenie jest fałszywe, powstaje struktura zleceń, która wygląda doskonale podczas przeglądania, a problemy pojawiają się dopiero w zakładce sieci jako opóźnienia. Poniżej przedstawiono ładowarkę panelu sterowania, w której to problem został najpierw rozwiązany za pomocą Promise.all, a następnie poprzez podzielenie komponentu, dzięki czemu prawidłowy porządek działania powstaje automatycznie.

Ładowarka panelu sterowania, która robi wszystko

Edytory paneli sterowania wydają się proste, jednak jeden komponent często musi ładować cały panel, pobierać każdy element interfejsu, ładować jego ustawienia i filtry, określać typ elementu, pobierać dane do niego i w końcu renderować interfejs. Poniżej znajduje się uproszczona wersja części odpowiedzialnej za ładowanie danych w takim komponencie:

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]);

Poza tym, że jest to kod imperatywny wewnątrz efektu, zawiera on błędne założenie.

Dwa oddzielne problemy w jednym pętli

Pierwszy problem jest łatwy do zauważenia: pętla for...of obsługuje elementy po jednym, mimo że są one niezależne. Promise.all to oczywista odpowiedź, ale rozwiązuje tylko połowę problemu.

Drugi problem jest subtelniejszy. Wewnątrz pętli kod żąda opcji i czeka, potem żąda filtrów i czeka, następnie żąda samego elementu i czeka, a dopiero wtedy żąda danych. Te instrukcje await opisują zależności, których w rzeczywistości nie ma. Opcje nie wymagają filtrów, filtry nie potrzebują elementu, a żądanie danych w ogóle nie wykorzystuje tego elementu. Rzeczywisty graf zależności dla pojedynczego elementu wygląda tak:

widgetId → options ─┐
         → filters ─┴→ data
         → widget

To, co kod faktycznie implementuje, to prosta łańcuchowa struktura:

widgetId → options → filters → widget → data

W przypadku panelu sterowania z N elementami interfejsu, oryginalny mechanizm ładowania wykonywał mniej więcej 4N sekwencyjnych przejść. Karta sieci pokazuje to wyraźnie: żądania wysyłane są jedno po drugim, po kolei dla każdego elementu.

Pierwsza poprawka: kodowanie rzeczywistego grafu za pomocą Promise.all

Szybkim rozwiązaniem jest dostosowanie kodu do rzeczywistego schematu działania. Elementy interfejsu są przetwarzane równolegle, a w obrębie każdego z nich trzy niezależne żądania startują jednocześnie. Tylko żądanie danych czeka, ponieważ faktycznie wymaga określonych opcji i filtrów:

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]);

Krytyczna ścieżka przekształca się z łańcucha czterech żądań na każdy element interfejsu, pomnożonego przez liczbę tych elementów, w żądanie dotyczące panelu sterowania plus naj wolniejsze z trzech równoległych żądań plus żądanie danych.

Warto dodać dwie rzeczy przed wysłaniem tego rozwiązania. Promise.all odrzuca wynik natychmiast po niepowodzeniu jakiegokolwiek żądania, więc jeden uszkodzony element sprawia, że cała karta sterowania staje się pusta; Promise.allSettled lub obsługa błędów na poziomie poszczególnych elementów sprawia, że reszta pozostaje widoczna. Ponadto ten mechanizm nie zawiera funkcji czyszczenia, więc jeśli id zmienia się szybko, wolna odpowiedź dotycząca starej karty sterowania może nadpisać nową. Flaga ignorowania lub AbortController w funkcji czyszczenia zapobiega temu.

Lepsze rozwiązanie: niech komponenty same określają swoje wymagania

Nawet po wprowadzeniu poprawki jeden komponent nadal kontroluje wszystkie żądania oraz wszystkie modele potrzebne do działania karty sterowania. Bardziej trwałą zmianą jest podział interfejsu według zadań i umożliwienie każdemu elementowi pobierania własnych danych za pomocą hooków danych:

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 kieruje użytkownika do pierwszej tablicy kontrolnej (w prawdziwym kodzie należy zabezpieczyć się przed pustą listą). Dashboard zna jedynie identyfikatory widgetów i renderuje dla każdego z nich WidgetSkeleton oparty na tych identyfikatorach. WidgetSkeleton wymaga opcji oraz filtrów dla nagłówka i samego widgetu, więc są to po prostu dwa wymagania dotyczące tej samej części interfejsu, bez określonego porządku między nimi.

Definicja widgetu, która określa typ wizualizacji, należy do komponentu 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}
    />
  );
};

Komponent ten wybiera odpowiednią wizualizację na podstawie tabeli MAP_WIDGET_TYPE_TO_VISUALIZATION, a sama wizualizacja pobiera dane z trzech wartości, od których faktycznie zależy:

const LineChart = ({
  widgetId,
  options,
  filters,
}: LineChartProps) => {

  const data = useWidgetData(widgetId, filters, options);
  // ...
};

Tutaj nie dostosowano żadnych obietnic. Rozdzielenie odpowiedzialności umożliwiło widoczność grafu zależności w drzewie komponentów: panel sterowania musi zostać przygotowany przed poznaniem identyfikatorów widgetów; każdy identyfikator widgetu odblokowuje opcje, filtry oraz definicję widgetu; dane czekają jedynie na swoje rzeczywiste dane wejściowe.

Uwaga dotycząca wstrzymywania hooków

Czy te żądania faktycznie są wykonywane równolegle, zależy od sposobu implementacji hooków. Hooki, które uruchamiają żądanie i zwracają stan ładowania, pozwalają każdemu komponentowi zostać wyrenderowanemu jednocześnie, dzięki czemu wszystkie żądania są wysyłane naraz. Jeśli hooki wstrzymują wykonywanie (na przykład zapytania w trybie suspense, które zwracają dane bezpośrednio – co wydaje się być założeniem przykładów, ponieważ czytają widget.type bez sprawdzenia stanu ładowania), sytuacja się zmienia. Dwa hooki, które wstrzymują wykonywanie i są wywoływane jeden po drugim w tym samym komponencie, mogą być wykonywane sekwencyjnie; wtedy komponent potomny, taki jak Widget, nie jest wyrenderowywany i tym samym nie uruchamia własnego żądania, dopóki jego komponent nadrzędny nie przestanie wstrzymywać wykonywanie. W takiej konfiguracji można ponownie wprowadzić sekwencyjne wykonywanie zadań w drzewie komponentów. Prefetching w komponencie nadrzędnym, zintegrowana API do zapytań lub loader tras unikają tego problemu. Aby zapoznać się z rozwiązaniem specyficznym dla Suspense, patrz pobieranie równoległe w React Suspense.

Główne wnioski

  • Każde await oznacza stwierdzenie, że następny krok zależy od poprzedniego wyniku; sprawdź, czy każde takie stwierdzenie jest prawdziwe.
  • Narysuj graf zależności przed optymalizacją, a następnie dostosuj kod do niego, a nie do kolejności, w jakiej napisałeś linie.
  • Promise.all rozwiązuje problem niezależnych żądań, ale należy go łączyć z izolacją błędów i czyszczeniem efektów.
  • Dzielenie komponentów według ich właściwości zazwyczaj zapewnia odpowiednią kolejność żądań, chyba że haki suspensji ją sekwencjonują.

Pozycje pokrewne

  • React bez meta-frameworku: Skryte koszty backendu w Pythonie — Dlaczego React w połączeniu z Vite na backendzie nieopartym na JavaScriptu omija dyskusję o Next.js, jakie funkcje frameworka musisz odbudować od nowa i jak zarządzać połączeniami między komponentami.