Chutes de demande cachées : correspondance du code asynchrone au véritable graphe de dépendances
Apprenez à repérer les appels séquentiels qui créent des dépendances fictives, à les corriger avec Promise.all, et à restructurer un tableau de bord React afin que chaque composant ne récupère que ce dont il a besoin.
Sources: Promises Make Async Code Look More Sequential Than It Really Is
async/await permet au code asynchrone d’être lu de haut en bas, et cette lisibilité a un coût : chaque await indique discrètement que la ligne suivante dépend de la précédente. Lorsque cette indication est fausse, on obtient une séquence de requêtes qui semble parfaite lors de l’examen et ne se manifeste que sous forme de latence dans l’onglet réseau. Ci-dessous, un chargeur de tableau de bord présentant ce problème est d’abord corrigé à l’aide de Promise.all, puis en divisant le composant afin que l’ordre correct apparaisse automatiquement.
Un chargeur de tableau de bord qui fait tout
Les éditeurs de tableaux de bord semblent simples, mais un seul composant finit souvent par charger le tableau de bord, récupérer chaque widget, charger les options et filtres de chacun d’eux, déterminer le type du widget, récupérer ses données et enfin afficher l’interface utilisateur. Voici une version simplifiée de la partie chargement des données d’un tel composant :
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]);
En plus d’être du code impératif à l’intérieur d’un effet, il repose sur une hypothèse erronée.
Deux problèmes distincts dans une seule boucle
Le premier problème est facile à identifier : la boucle for...of traite chaque widget un par un, alors que ces derniers sont indépendants. Promise.all semble être la solution évidente, mais elle ne résout qu’une partie du problème.
Le deuxième problème est plus subtil. À l’intérieur de la boucle, le code demande d’abord des options et attend, puis des filtres et attend à nouveau, ensuite le widget et attend encore, avant de demander enfin les données. Ces instructions await décrivent des dépendances qui n’existent pas en réalité. Les options n’ont pas besoin de filtres, les filtres n’ont pas besoin du widget, et la demande de données ne fait jamais usage du widget. Le véritable graphe de dépendances pour un seul widget est le suivant :
widgetId → options ─┐
→ filters ─┴→ data
→ widget
Ce que le code met réellement en œuvre, c’est une chaîne simple :
widgetId → options → filters → widget → data
Dans un tableau de bord contenant N widgets, le chargeur initial effectue approximativement 4N allers-retours successifs. L’onglet Réseau le montre clairement : les requêtes sont envoyées une après l’autre, un widget après l’autre.
Première solution : encoder le véritable graphe avec Promise.all
La solution rapide consiste à faire en sorte que le code suive le véritable graphe. Les widgets sont traités en parallèle, et au sein de chaque widget les trois requêtes indépendantes sont lancées en même temps. Seule la requête de données attend, car elle a réellement besoin d’options et de filtres :
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]);
Le chemin critique passe d’une chaîne de quatre requêtes par widget, multiplié par le nombre de widgets, à la requête du tableau de bord plus la plus lente des trois appels en parallèle plus la requête de données.
Deux points méritent d’être mentionnés avant d’envoyer ce code. Promise.all rejette immédiatement dès qu’une requête échoue, ce qui fait que le simple dysfonctionnement d’un composant rend tout le tableau de bord inutilisable ; en revanche, Promise.allSettled ou un traitement des erreurs par composant permet de conserver la visibilité du reste. De plus, cet effet ne prévoit aucune fonction de nettoyage, si bien que si l’id change rapidement, une réponse lente pour l’ancien tableau de bord peut écraser la nouvelle. Un indicateur d’ignorance ou un AbortController dans la fonction de nettoyage permet d’éviter cela.
Méthode plus efficace : laisser les composants gérer leurs propres besoins
Même après cette correction, un seul composant reste responsable de toutes les requêtes et de tous les modèles nécessaires au tableau de bord. Une solution plus durable consiste à diviser l’interface en fonction des responsabilités et à permettre à chaque élément de récupérer ses propres données via des hooks :
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 redirige vers le premier tableau de bord (il faut se prémunir contre une liste vide dans le code réel). Dashboard ne connaît que les IDs des widgets et affiche un WidgetSkeleton correspondant pour chacun d’eux. WidgetSkeleton a besoin de paramètres et de filtres pour l’en-tête ainsi que pour le widget ; il s’agit donc simplement de deux exigences liées à la même interface utilisateur, sans ordre particulier entre elles.
La définition du widget, qui détermine le type de visualisation, relève de la responsabilité du composant 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}
/>
);
};
Celui-ci sélectionne la visualisation à partir de MAP_WIDGET_TYPE_TO_VISUALIZATION, et la visualisation demande ses données aux trois valeurs sur lesquelles elle dépend réellement :
const LineChart = ({
widgetId,
options,
filters,
}: LineChartProps) => {
const data = useWidgetData(widgetId, filters, options);
// ...
};
Aucune promesse n’a été ajustée ici. La séparation des responsabilités a rendu le graphe de dépendances visible dans l’arbre des composants : le tableau de bord doit être prêt avant que les identifiants des widgets ne soient connus ; chaque identifiant de widget déverrouille les options, les filtres ainsi que la définition du widget ; les données n’attendent que leurs vraies entrées.
Une précaution concernant la suspension des hooks
Le fait que ces requêtes s’exécutent réellement en parallèle dépend de la manière dont les hooks sont implémentés. Les hooks qui lancent une requête et retournent un état de chargement permettent à chaque composant d’être rendu en même temps, ce qui fait que toutes les requêtes sont envoyées simultanément. Si les hooks entrent en suspension (par exemple, des requêtes en mode suspense qui retournent directement les données, ce que semblent supposer les exemples puisqu’ils lisent widget.type sans vérifier l’état de chargement), la situation change. Deux hooks en suspension appelés l’un après l’autre au sein du même composant peuvent s’exécuter en série, et un enfant comme Widget ne sera pas rendu, et donc ne lancera pas sa propre requête, tant que son parent n’aura pas cessé de être en suspension. Dans ce cas, on peut réintroduire une séquence linéaire dans l’arborescence. La précharge dans le parent, une API de requête combinée ou un chargeur de route permet d’éviter cela. Pour une approche spécifique à Suspense, consultez la récupération parallèle dans React Suspense.
Points clés
- Chaque
awaitindique que l’étape suivante dépend du résultat précédent ; assurez-vous que chaque affirmation est vraie. - Dessinez le graphe de dépendances avant d’optimiser, puis faites en sorte que le code corresponde à ce graphe plutôt qu’à l’ordre dans lequel vous avez écrit les lignes.
Promise.allpermet de gérer des requêtes indépendantes, mais il doit être associé à l’isolation des erreurs et au nettoyage des effets.- Diviser les composants en fonction de ce qu’ils gèrent tend à donner l’ordre correct des requêtes, sauf si les hooks de suspension les sérialisent.