Обработка синтаксиса top-level await

top-level await — возможность использовать оператор await непосредственно на верхнем уровне ES-модуля без необходимости оборачивать код в асинхронную функцию. Поддержка этой конструкции появилась в ECMAScript 2022 и стала важной частью современного JavaScript.

Webpack поддерживает обработку top-level await, начиная с Webpack 5. Эта возможность особенно востребована при:

  • динамической загрузке конфигурации;
  • асинхронной инициализации приложения;
  • подключении WebAssembly;
  • получении удалённых данных до запуска логики;
  • ленивой инициализации SDK;
  • загрузке переводов и пользовательских настроек.

Пример стандартного использования:

const response = await fetch('/config.json');
const config = await response.json();

console.log(config);

Без top-level await подобный код приходилось помещать в асинхронную функцию:

async function bootstrap() {
    const response = await fetch('/config.json');
    const config = await response.json();

    console.log(config);
}

bootstrap();

Поддержка в Webpack

Webpack 5 умеет анализировать и компилировать top-level await внутри ES-модулей. При этом система сборки автоматически преобразует граф зависимостей в асинхронный.

Минимальная конфигурация:

module.exports = {
    experiments: {
        topLevelAwait: true
    }
};

В более поздних версиях Webpack возможность может быть включена по умолчанию, однако явное указание остаётся хорошей практикой для совместимости и читаемости конфигурации.


Как Webpack обрабатывает top-level await

Когда Webpack обнаруживает await на верхнем уровне модуля, происходят серьёзные изменения в механизме загрузки зависимостей.

Обычный ES-модуль:

import { api } from './api';

console.log(api);

загружается синхронно.

Модуль с top-level await:

const response = await fetch('/api/data');
export const data = await response.json();

становится асинхронным. Любой модуль, импортирующий его, также начинает ожидать завершения выполнения.

Пример:

import { data } from './data';

console.log(data);

Webpack преобразует такой граф зависимостей в цепочку промисов.

Фактически:

import('./data').then(module => {
    console.log(module.data);
});

создаётся автоматически на уровне runtime.


Асинхронный граф модулей

top-level await влияет не только на конкретный файл, но и на все связанные модули.

Структура:

main.js
 └── config.js
      └── await fetch(...)

Если config.js содержит top-level await, тогда:

  • config.js становится асинхронным;
  • main.js тоже становится асинхронным;
  • runtime Webpack начинает ожидать завершения всей цепочки.

Это влияет:

  • на порядок выполнения;
  • на инициализацию модулей;
  • на скорость старта приложения;
  • на загрузку чанков;
  • на SSR;
  • на lazy-loading.

Пример асинхронной инициализации

config.js

const response = await fetch('/config.json');

export const config = await response.json();

app.js

import { config } from './config';

console.log(config.apiUrl);

Webpack гарантирует:

  1. завершение fetch;
  2. преобразование JSON;
  3. экспорт config;
  4. запуск app.js.

Использование с динамическим импортом

top-level await хорошо сочетается с import().

const module = await import('./feature');

module.run();

Webpack создаёт отдельный chunk и загружает его асинхронно.


Использование с WebAssembly

Одна из ключевых причин появления top-level await — поддержка WebAssembly.

Пример:

import wasmModule from './math.wasm';

const instance = await wasmModule();

console.log(instance.exports.sum(1, 2));

Webpack может автоматически ожидать инициализации .wasm-модуля.


Поддержка Babel

Babel долгое время не поддерживал полноценную трансформацию top-level await, поскольку конструкция требует изменений в системе модулей, а не только синтаксического преобразования.

Для работы необходимы:

  • современный Webpack;
  • type: "module" либо ES-модули;
  • актуальная версия Babel parser.

Плагин:

npm install @babel/plugin-syntax-top-level-await

Конфигурация:

module.exports = {
    plugins: [
        '@babel/plugin-syntax-top-level-await'
    ]
};

Важно понимать различие:

  • plugin-syntax-top-level-await только распознаёт синтаксис;
  • он не преобразует код;
  • фактическую поддержку обеспечивает Webpack runtime.

Ограничения CommonJS

top-level await работает только в ES-модулях.

Неправильно:

const data = await fetch('/api');

в CommonJS:

module.exports = {};
require('./module');

Правильно:

export {};
import './module.js';

Webpack может смешивать CommonJS и ESM, однако top-level await применяется исключительно к ESM-графу.


Влияние на производительность

top-level await способен замедлять старт приложения.

Причина — блокировка выполнения зависимых модулей до завершения асинхронной операции.

Проблемный пример:

const translations = await fetch('/translations');

Если загрузка занимает 2 секунды, всё приложение может ждать завершения.


Асинхронная блокировка дерева зависимостей

Особенно опасна ситуация, когда корневой модуль содержит долгий await.

await initializeDatabase();

Тогда:

  • задерживается bootstrap;
  • откладывается hydration;
  • позже появляется UI;
  • ухудшается TTI;
  • увеличивается время запуска SSR.

Разделение критического и некритического кода

Рекомендуется избегать длительных операций на верхнем уровне.

Плохой подход:

const hugeData = await fetch('/big.json');

Лучший вариант:

export async function loadData() {
    const response = await fetch('/big.json');

    return response.json();
}

Тогда загрузка станет ленивой и управляемой.


Циклические зависимости

top-level await усложняет обработку циклических импортов.

Пример:

a.js

import './b';

await initializeA();

b.js

import './a';

await initializeB();

Возможны:

  • deadlock;
  • частичная инициализация;
  • ошибки runtime;
  • неопределённый порядок выполнения.

Webpack пытается обнаруживать подобные проблемы, но сложные циклы всё равно могут приводить к нестабильному поведению.


Ошибки выполнения

Если top-level await завершается ошибкой:

const response = await fetch('/missing-file');

ошибка распространяется на весь модульный граф.

Webpack отклоняет соответствующий Promise.

Пример:

import('./app')
    .catch(error => {
        console.error(error);
    });

Обработка ошибок через try/catch

Наиболее безопасный вариант:

let config = {};

try {
    const response = await fetch('/config.json');
    config = await response.json();
} catch (error) {
    console.error(error);
}

Использование в entry-файлах

top-level await часто применяется в entry-point.

index.js

await initializeAnalytics();
await initializeAuth();

import('./bootstrap');

Webpack преобразует entry в асинхронную точку входа.


Взаимодействие с code splitting

Webpack умеет совмещать:

  • top-level await;
  • lazy loading;
  • dynamic import;
  • splitChunks.

Пример:

const chartModule = await import('./charts');

Chunk загружается только при необходимости.


Особенности tree shaking

Асинхронные модули сложнее оптимизировать.

Причины:

  • меняется порядок выполнения;
  • возникают побочные эффекты;
  • runtime обязан сохранять зависимости.

Иногда top-level await уменьшает эффективность tree shaking.


Работа с Module Federation

В Module Federation top-level await особенно полезен для:

  • загрузки remote-модулей;
  • асинхронной инициализации контейнеров;
  • подключения shared-зависимостей.

Пример:

const remote = await import('remote/App');

Webpack корректно ожидает готовности удалённого контейнера.


SSR и top-level await

На сервере top-level await используется для:

  • загрузки конфигурации;
  • подключения БД;
  • чтения файлов;
  • инициализации сервисов.

Пример:

const templates = await loadTemplates();

export default templates;

Однако длительные операции могут замедлять первый запрос.


Поддержка Node.js

Для корректной работы необходимы:

  • ES-модули;
  • современная версия Node.js;
  • "type": "module" в package.json.

Пример:

{
    "type": "module"
}

Сравнение top-level await и async bootstrap

top-level await

const config = await loadConfig();

Преимущества:

  • компактность;
  • читаемость;
  • линейный код;
  • меньше boilerplate.

Недостатки:

  • блокировка модулей;
  • асинхронный граф;
  • сложность отладки.

Async bootstrap

async function bootstrap() {
    const config = await loadConfig();
}

bootstrap();

Преимущества:

  • более предсказуемый контроль;
  • явная точка запуска;
  • меньше скрытых зависимостей.

Недостатки:

  • дополнительная обёртка;
  • больше шаблонного кода.

Практические рекомендации

Не использовать длительные await в корневых модулях

Плохо:

await fetch('/huge-data');

Лучше:

export function loadHugeData() {
    return fetch('/huge-data');
}

Изолировать асинхронную инициализацию

Хорошая практика:

export async function initialize() {
    await connectDatabase();
}

Не смешивать CommonJS и ESM

Проблемный вариант:

require('./module-with-await');

Лучше полностью перейти на ES-модули.


Избегать циклических импортов

Особенно при наличии:

await something();

в обоих модулях.


Использовать top-level await только там, где действительно нужна блокирующая инициализация

Подходящие сценарии:

  • загрузка конфигурации;
  • получение токенов;
  • WebAssembly;
  • инициализация SDK;
  • remote-модули.

Неподходящие:

  • обычные HTTP-запросы UI;
  • получение списков данных;
  • загрузка контента страницы;
  • аналитика;
  • некритичные ресурсы.

Пример полной конфигурации Webpack

const path = require('path');

module.exports = {
    mode: 'development',

    entry: './src/index.js',

    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },

    experiments: {
        topLevelAwait: true
    },

    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader'
                }
            }
        ]
    }
};

Пример структуры проекта

src/
├── index.js
├── config.js
├── api.js
└── bootstrap.js

config.js

const response = await fetch('/config.json');

export default await response.json();

bootstrap.js

import config from './config';

console.log(config);

index.js

import('./bootstrap');

Внутренний runtime Webpack

Webpack внедряет специальный runtime-код для:

  • отслеживания статуса модулей;
  • ожидания Promise;
  • синхронизации chunk-загрузки;
  • предотвращения повторной инициализации.

Асинхронный модуль получает внутренний статус:

pending
fulfilled
rejected

Runtime гарантирует корректный порядок исполнения даже при сложных графах зависимостей.


Совместимость с браузерами

top-level await поддерживается современными браузерами:

  • Chrome;
  • Edge;
  • Firefox;
  • Safari.

При использовании старых браузеров требуется:

  • транспиляция;
  • fallback-логика;
  • отказ от top-level await.

Полностью полифилить механизм невозможно, поскольку он связан с моделью модулей JavaScript.


Диагностика проблем

Типичные ошибки:

Unexpected reserved word ‘await’

Причины:

  • файл не является ES-модулем;
  • старая версия Webpack;
  • отключён experiments.topLevelAwait;
  • Babel parser не поддерживает синтаксис.

Module parse failed

Причины:

  • неправильная конфигурация Babel;
  • старый loader;
  • конфликт CommonJS и ESM.

Circular dependency detected

Причина — циклические асинхронные импорты.


Когда top-level await действительно полезен

Наиболее оправданные сценарии:

  • инициализация WebAssembly;
  • remote-модули;
  • SSR bootstrap;
  • загрузка критической конфигурации;
  • подключение cloud SDK;
  • криптографические ключи;
  • runtime-конфигурация приложения;
  • асинхронные feature flags.

Когда лучше отказаться от top-level await

Нежелательные сценарии:

  • UI-запросы после рендера;
  • аналитика;
  • обычные REST API;
  • загрузка больших данных;
  • пользовательский контент;
  • тяжёлые сетевые операции;
  • зависимости с высоким риском таймаута.

В подобных случаях эффективнее:

  • lazy loading;
  • deferred initialization;
  • async bootstrap;
  • React Suspense;
  • отдельные сервисы загрузки данных.