Cascadas de solicitudes ocultas: Asociar código asíncrono con el gráfico real de dependencias
Aprenda a identificar las esperas secuenciales que crean dependencias falsas, a solucionarlas con Promise.all y a reestructurar un panel de control de React para que cada componente obtenga únicamente lo que necesita.
Fuentes: Promises Make Async Code Look More Sequential Than It Really Is
async/await hace que el código asíncrono se lea de arriba hacia abajo, y esa legibilidad tiene un costo: cada await declara silenciosamente que la línea siguiente depende de la anterior. Cuando esa declaración es falsa, se genera una secuencia de solicitudes que parece perfectamente ordenada en las revisiones, pero solo se manifiesta como latencia en la pestaña de red. A continuación, se muestra un cargador de panel de control con ese problema, corregido primero con Promise.all, y luego dividiendo el componente para que el orden correcto se establezca por sí mismo.
Un cargador de panel de control que lo hace todo
Los editores de paneles de control parecen sencillos, pero un único componente suele encargarse de cargar el panel, obtener todos los widgets, cargar sus opciones y filtros, determinar su tipo, buscar sus datos y, finalmente, renderizar la interfaz de usuario. A continuación se presenta una versión simplificada de la parte de carga de datos de dicho componente:
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]);
Más allá de ser código imperativo dentro de un efecto, implica una suposición errónea.
Dos problemas separados en un solo bucle
El primer problema es fácil de ver: el bucle for...of procesa los widgets uno por uno, aunque estos son independientes. Promise.all es la solución obvia, pero solo aborda la mitad del problema.
El segundo problema es más sutil. Dentro del bucle, el código solicita opciones y espera, luego solicita filtros y espera, después solicita el widget y espera, y solo entonces solicita los datos. Esos await describen dependencias que en realidad no existen. Las opciones no necesitan filtros, los filtros no necesitan el widget, y la solicitud de datos nunca utiliza el widget en absoluto. El verdadero grafo de dependencias para un único widget se ve así:
widgetId → options ─┐
→ filters ─┴→ data
→ widget
Lo que el código implementa en realidad es una cadena directa:
widgetId → options → filters → widget → data
En un panel de control con N widgets, el cargador original realiza aproximadamente 4N viajes de ida y vuelta en secuencia. La pestaña de red lo muestra claramente: las solicitudes se envían una tras otra, widget por widget.
Primera solución: codificar el gráfico real con Promise.all
La solución rápida es hacer que el código siga el gráfico real. Los widgets se procesan en paralelo, y dentro de cada widget las tres solicitudes independientes se inician al mismo tiempo. Solo la solicitud de datos espera, ya que realmente necesita opciones y filtros:
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]);
La ruta crítica pasa de una cadena de cuatro solicitudes por widget, multiplicada por la cantidad de widgets, a la solicitud del panel de control más la llamada más lenta de las tres en paralelo, además de la solicitud de datos.
Hay dos cosas que vale la pena mencionar antes de enviarlo. Promise.all rechaza la solicitud en cuanto alguna falla, por lo que un widget defectuoso hace que todo el panel de control quede en blanco; Promise.allSettled o el manejo de errores por widget mantienen visible el resto. Además, este enfoque no incluye mecanismos de limpieza, así que si el id cambia rápidamente, una respuesta lenta del panel de control anterior puede sobrescribir al nuevo. Una bandera de ignorar o un AbortController en la función de limpieza evita esto.
Mejor solución: dejar que los componentes se encarguen de sus propias necesidades
Incluso después de aplicar la solución, un solo componente sigue siendo responsable de todas las solicitudes y todos los modelos que necesita el panel de control. Un cambio más duradero consiste en dividir la interfaz según sus responsabilidades y permitir que cada parte obtenga sus propios datos a través de hooks de datos:
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 al primer panel de control (se debe proteger contra una lista vacía en el código real). Dashboard solo conoce los IDs de los widgets y renderiza un WidgetSkeleton asociado a cada uno. WidgetSkeleton necesita opciones y filtros para el encabezado y el widget, por lo que estos son simplemente dos requisitos del mismo elemento de interfaz, sin ningún orden entre ellos.
La definición del widget, que determina el tipo de visualización, es responsabilidad del componente 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}
/>
);
};
Elije la visualización a partir de MAP_WIDGET_TYPE_TO_VISUALIZATION, y la visualización solicita sus datos de los tres valores de los que realmente depende:
const LineChart = ({
widgetId,
options,
filters,
}: LineChartProps) => {
const data = useWidgetData(widgetId, filters, options);
// ...
};
No se ajustaron promesas aquí. Al separar las responsabilidades, el grafo de dependencias se volvió visible en el árbol de componentes: el panel de control debe llegar antes de que se conozcan los IDs de los widgets; cada ID de widget desbloquea opciones, filtros y la definición del widget; los datos solo esperan sus entradas reales.
Una advertencia sobre la suspensión de hooks
Si estas solicitudes realmente se ejecutan en paralelo depende de cómo estén implementados los ganchos. Los ganchos que inician una solicitud y devuelven un estado de carga permiten que cada componente se renderice al mismo tiempo, por lo que todas las solicitudes se envían juntas. Si los ganchos se suspenden (por ejemplo, consultas en modo suspense que devuelven datos directamente, lo cual parecen asumir los fragmentos ya que leen widget.type sin verificar el estado de carga), la situación cambia. Dos ganchos en suspensión llamados uno tras otro dentro del mismo componente pueden ejecutarse en serie, y un hijo como Widget no se renderiza, ni inicia su propia solicitud, hasta que su padre deja de suspenderse. En esa configuración se puede volver a introducir un orden secuencial en el árbol. La precarga en el padre, una API de consultas combinada o un cargador de rutas evitan esto. Para un tratamiento específico relacionado con Suspense, consulte Obtención paralela en React Suspense.
Puntos clave
- Cada
awaitrepresenta la afirmación de que el siguiente paso depende del resultado anterior; verifica que cada afirmación sea cierta. - Dibuja el grafo de dependencias antes de optimizar, y luego haz que el código se ajuste a él en lugar del orden en que escribiste las líneas.
Promise.allsoluciona los problemas de solicitudes independientes, pero úsalo junto con la isolación de errores y la limpieza de efectos.- Dividir los componentes según lo que gestionan suele generar el orden correcto de solicitudes, a menos que los hooks de suspensión lo serialicen.
Lecturas relacionadas
- Arreglando errores en el manejo de errores de Async/Await en código producción de Node.js — Conozca cinco errores comunes al manejar errores con async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.
- Seis errores de temporización asíncrona que se manifiestan como problemas en la renderización de React — Aprenda a identificar seis patrones asíncronos en JavaScript, desde valores capturados obsoletos hasta ausencia de devoluciones de Promise, que hacen que los componentes de React muestren un estado incorrecto o imposible.