Startseite / Artikel

Versteckte Anfragswasserfälle: Asynchrone Codeblöcke dem tatsächlichen Abhängigkeitsgraphen zuordnen

Lernen Sie, auf sequentielle Abfragen zu achten, die falsche Abhängigkeiten erzeugen, diese mit Promise.all zu beheben und ein React-Dashboard umzustrukturieren, sodass jede Komponente nur das lädt, was sie benötigt.

Verlag: REACTAPP.TOP5 Min. Lesezeit1238 Wörter

Quellen: Promises Make Async Code Look More Sequential Than It Really Is

async/await sorgt dafür, dass asynchrone Codeabschnitte von oben nach unten gelesen werden können, und diese Lesbarkeit hat ihren Preis: Jeder await gibt stumm an, dass die nächste Zeile von der vorherigen abhängt. Wenn diese Annahme falsch ist, entsteht eine Abfolge von Anfragen, die in der Überprüfung perfekt ordentlich erscheint, aber erst im Netzwerk-Tab als Latenz sichtbar wird. Im Folgenden ist ein Dashboard-Lader mit diesem Problem zunächst mit Promise.all behoben, anschließend durch Aufteilung der Komponente, sodass die richtige Reihenfolge von selbst entsteht.

Ein Dashboard-Lader, der alles erledigt

Dashboard-Editor scheinen einfach zu sein, doch eine einzige Komponente muss oft das Dashboard laden, jedes Widget abrufen, die Optionen und Filter jedes Widgets laden, den Typ des Widgets bestimmen, seine Daten abrufen und schließlich die Benutzeroberfläche darstellen. Hier ist eine vereinfachte Version des Datenauslade-Teils einer solchen Komponente:

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

Neben der Tatsache, dass es sich um imperativen Code innerhalb eines Effects handelt, beinhaltet er außerdem eine falsche Annahme.

Zwei getrennte Probleme in einer Schleife

Das erste Problem ist leicht zu erkennen: Die for...of-Schleife verarbeitet die Widgets nacheinander, obwohl sie unabhängig voneinander sind. Promise.all ist die offensichtliche Lösung, adressiert aber nur die Hälfte des Problems.

Das zweite Problem ist subtiler. Innerhalb der Schleife wird zunächst nach Optionen gefragt und gewartet, anschließend nach Filtern und wieder gewartet, danach wird das Widget angefordert und erneut gewartet, erst danach werden die Daten abgerufen. Diese await-Anweisungen beschreiben Abhängigkeiten, die nicht existieren. Optionen benötigen keine Filter, Filter benötigen kein Widget, und der Datenabruf verwendet das Widget überhaupt nicht. Der tatsächliche Abhängigkeitsgraph für ein einzelnes Widget sieht so aus:

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

Was der Code tatsächlich umsetzt, ist eine einfache Kette von Schritten:

widgetId → options → filters → widget → data

Bei einer Anzeige mit N Widgets führt der ursprüngliche Lader in etwa 4N sequenzielle Hin- und Rückreisen durch. Die Netzwerkeinstellungen zeigen das deutlich: Die Anfragen werden nacheinander, Widget für Widget, gesendet.

Erste Lösung: Das tatsächliche Diagramm mit Promise.all kodieren

Die schnelle Lösung besteht darin, den Code so anzupassen, dass er dem eigentlichen Ablauf des Diagramms folgt. Die Widgets werden parallel abgebildet, und innerhalb jedes Widgets starten die drei unabhängigen Anfragen gleichzeitig. Nur die Datenanfrage wartet, da sie tatsächlich Optionen und Filter benötigt:

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

Der kritische Pfad reduziert sich von einer Kette aus vier Anfragen pro Widget, multipliziert mit der Anzahl der Widgets, auf die Anfrage zur Anzeige plus die langsamste der drei parallelen Aufrufe plus die Datenanfrage.

Zwei Dinge sollten vor dem Versand hinzugefügt werden. Promise.all lehnt ab, sobald eine Anfrage fehlschlägt, wodurch ein defekter Komponente den gesamten Dashboard leer macht; Promise.allSettled oder fehlerbehandelnde Funktionen pro Komponente sorgen dafür, dass der Rest weiterhin sichtbar bleibt. Zudem gibt es keine Aufräumfunktion, sodass bei schnellen Änderungen des id-Werts eine langsame Antwort für das alte Dashboard die neue überschreiben kann. Ein Ignorieren-Flag oder ein AbortController in der Aufräumfunktion verhindert das.

Bessere Lösung: Lassen Sie die Komponenten ihre eigenen Anforderungen steuern

Auch nach der Korrektur ist weiterhin eine Komponente für jede Anfrage sowie jedes Modell verantwortlich, das der Dashboard benötigt. Eine nachhaltigere Lösung besteht darin, die Benutzeroberfläche nach Verantwortungsbereichen aufzuteilen und jede Komponente dazu zu bringen, ihre eigenen Eingaben über Daten-Hooks abzurufen:

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 leitet zum ersten Dashboard um (um im echten Code ein leeres Array zu vermeiden). Dashboard kennt nur die Widget-IDs und rendernt für jede ein WidgetSkeleton mit entsprechender Schlüsselung. WidgetSkeleton benötigt Optionen und Filter für den Header sowie das Widget; daher handelt es sich dabei um zwei Anforderungen desselben Benutzeroberflächenteils, wobei keine Reihenfolge zwischen ihnen besteht.

Die Definition des Widgets, die den Visualisierungstyp bestimmt, fällt in die Verantwortung des Widget-Komponenten:

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}
    />
  );
};

Dieser wählt die Visualisierung aus MAP_WIDGET_TYPE_TO_VISUALIZATION aus, und die Visualisierung holt ihre Daten von den drei Werten, von denen sie tatsächlich abhängt:

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

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

Hier wurden keine Zusagen angepasst. Die Trennung der Verantwortlichkeiten machte den Abhängigkeitsgraphen im Komponentenbaum sichtbar: Das Dashboard muss vor dem Kenntnisstand der Widget-IDs bereitstehen; jede Widget-ID gibt Zugriff auf Optionen, Filter sowie die Widget-Definition; die Daten warten lediglich auf ihre tatsächlichen Eingaben.

Eine Einschränkung bezüglich des Aussetzens von Hooks

Ob diese Anfragen tatsächlich parallel ausgeführt werden, hängt davon ab, wie die Hooks implementiert sind. Hooks, die eine Anfrage starten und einen Ladezustand zurückgeben, ermöglichen es jedem Komponenten gleichzeitig angezeigt zu werden, sodass alle Anfragen gemeinsam abgefeuert werden. Wenn die Hooks pausiert werden (zum Beispiel Abfragen im Suspense-Modus, die direkt Daten zurückgeben – was die Beispiele offenbar voraussetzen, da sie widget.type ohne Ladeprüfung lesen), ändert sich die Situation. Zwei hintereinander aufgerufene, pausierende Hooks im selben Komponenten können nacheinander ausgeführt werden; ein Kindkomponent wie Widget wird nicht angezeigt und startet daher auch keine eigene Anfrage, bis sein Elternkomponente mit der Pausierung aufhört. In dieser Konfiguration kann man wieder eine sequenzielle Abfolge im Baum einfügen. Vorabladevorgänge im Elternkomponenten, eine kombinierte Abfrageschnittstelle oder ein Routenlader verhindern das. Für eine spezielle Behandlung im Suspense-Modus siehe paralleles Abrufen in React Suspense.

Wichtige Erkenntnisse

  • Jedes await drückt aus, dass der nächste Schritt vom vorherigen Ergebnis abhängt; überprüfen Sie, ob jede dieser Annahmen zutrifft.
  • Ziehen Sie vor der Optimierung den Abhängigkeitsgraphen auf, und passen Sie den Code danach an, statt ihn nach der Reihenfolge der geschriebenen Zeilen zu gestalten.
  • Promise.all löst das Problem unabhängiger Anfragen, sollte aber mit Fehlerisolierung und Bereinigung von Effekten kombiniert werden.
  • Die Aufteilung von Komponenten nach ihren Eigenschaften führt in der Regel zur richtigen Reihenfolge der Anfragen – es sei denn, Suspense-Hooks serialisieren diese Reihenfolge.