Скрытые водопады запросов: сопоставление асинхронного кода с реальной диаграммой зависимостей
Научитесь распознавать последовательные ожидания, создающие фиктивную зависимость, исправлять их с помощью Promise.all, а также реструктурировать панель управления React так, чтобы каждый компонент загружал только то, что ему нужно.
Источники: 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 не сериализуют его.