Top-level await поддержка

Появление top-level await в ECMAScript

До появления top-level await оператор await разрешалось использовать только внутри асинхронных функций. Любая попытка написать асинхронный код на верхнем уровне модуля приводила к синтаксической ошибке:

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

Для обхода ограничения использовались конструкции вида:

(async () => {
    const response = await fetch('/api/data');
})();

или:

fetch('/api/data')
    .then(response => response.json())
    .then(data => {
        console.log(data);
    });

С внедрением top-level await ECMAScript-модули получили возможность выполнять асинхронный код непосредственно на верхнем уровне файла:

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

console.log(data);

Это существенно изменило модель инициализации модулей и повлияло на архитектуру сборщиков, включая Webpack.


Что происходит при использовании top-level await

Когда модуль содержит await на верхнем уровне, он становится асинхронным модулем.

Например:

// config.js

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

export const config = await response.json();

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

import { config } from './config.js';

console.log(config);

Webpack строит граф зависимостей с учетом асинхронности. Если хотя бы один модуль в цепочке использует top-level await, все зависимые модули становятся частью асинхронного execution graph.


Поддержка top-level await в Webpack

Полноценная поддержка появилась в Webpack 5.

Ранее использование await на верхнем уровне либо полностью запрещалось, либо требовало экспериментальных флагов.

Базовая конфигурация:

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

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


Требования к модульной системе

top-level await работает только в ECMAScript Modules.

Корректные варианты:

import { api } from './api.js';

const data = await api.load();

или:

export const result = await fetch('/data');

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

const fs = require('fs');

const data = await readFile(); // ошибка

Webpack должен обрабатывать файл как ES Module.


Влияние на граф зависимостей

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

Обычная цепочка:

index.js
 ├── app.js
 └── utils.js

Все модули выполняются последовательно и синхронно.


Асинхронный граф

При появлении top-level await:

index.js
 └── config.js (await)
      └── api.js

Webpack вынужден:

  1. Приостановить выполнение index.js
  2. Дождаться завершения config.js
  3. Продолжить инициализацию

Фактически runtime превращается в систему асинхронной загрузки модулей.


Как Webpack трансформирует такие модули

Исходный код:

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

export const data = await response.json();

Webpack преобразует во внутреннюю структуру, напоминающую:

__webpack_require__.a(module, async (__webpack_handle_async_dependencies__) => {
    const response = await fetch('/data');

    const data = await response.json();

    __webpack_exports__.data = data;
});

Каждый асинхронный модуль получает специальную обертку.


Асинхронные boundaries

Webpack формирует специальные async boundaries — точки, где выполнение может быть приостановлено.

Например:

import './config.js';

console.log('start');

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

console.log('start')

не выполнится до завершения асинхронной инициализации.


Порядок выполнения модулей

Без top-level await

// a.js
console.log('A');

// b.js
console.log('B');

// index.js
import './a';
import './b';

Результат:

A
B

С top-level await

// a.js
await new Promise(resolve => setTimeout(resolve, 1000));

console.log('A');

// b.js
console.log('B');

Теперь порядок зависит от графа зависимостей и асинхронного состояния.

Если b.js зависит от a.js, выполнение будет ожидать завершения таймера.


Использование для загрузки конфигурации

Один из наиболее распространённых сценариев:

export const config = await fetch('/config.json')
    .then(r => r.json());

или:

const env = await loadEnvironment();

export default env;

Это позволяет:

  • загружать настройки до старта приложения;
  • инициализировать SDK;
  • подготавливать соединения;
  • загружать локализации;
  • получать feature flags.

Инициализация WebAssembly

Webpack активно использует top-level await при работе с WebAssembly.

Пример:

import wasmModule from './math.wasm';

const instance = await wasmModule();

export const add = instance.exports.add;

Без top-level await приходилось строить сложные цепочки промисов.


Динамическая инициализация SDK

const firebase = await import('./firebase-init.js');

export default firebase;

или:

const analytics = await createAnalytics();

analytics.start();

Lazy initialization

Иногда требуется отложенная инициализация тяжелых компонентов:

const hugeLibrary = await import('./huge-library');

export default hugeLibrary;

Webpack может вынести такую зависимость в отдельный chunk.


Совместимость с code splitting

top-level await хорошо интегрируется с dynamic import.

Пример:

const page = await import('./pages/admin.js');

page.render();

Webpack создаст:

  • отдельный chunk;
  • асинхронную загрузку;
  • ожидание выполнения модуля.

Взаимодействие с import()

Обычный import()

import('./module.js').then(module => {
    module.start();
});

С top-level await

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

module.start();

Код становится линейным и проще для чтения.


Проблема waterfall execution

Неправильное использование top-level await может создать последовательную загрузку.

Плохой пример:

const config = await loadConfig();
const user = await loadUser();
const settings = await loadSettings();

Каждая операция ожидает предыдущую.

Лучше:

const [config, user, settings] = await Promise.all([
    loadConfig(),
    loadUser(),
    loadSettings()
]);

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

Основная проблема

top-level await блокирует выполнение зависимых модулей.

Если модуль долго инициализируется:

await new Promise(resolve => setTimeout(resolve, 5000));

вся цепочка импорта будет ожидать завершения.


Увеличение времени bootstrap

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

  • увеличению startup time;
  • задержке hydration;
  • медленному initial render;
  • задержке выполнения entrypoint.

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

Пример:

App
 ├── Router
 ├── Store
 │    └── Config (await)
 └── UI

Пока Config не завершится, Store не будет готов, а значит App тоже не сможет полностью инициализироваться.


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

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

Пример цикла

// a.js
import { valueB } from './b.js';

export const valueA = await getA(valueB);
// b.js
import { valueA } from './a.js';

export const valueB = await getB(valueA);

Это может привести к deadlock-сценарию.


Как Webpack обрабатывает циклы

Webpack пытается:

  • анализировать async graph;
  • строить dependency ordering;
  • предотвращать бесконечное ожидание.

Но сложные циклические зависимости всё равно способны вызвать runtime errors.


Отличие от обычных async функций

Асинхронная функция

async function init() {
    await load();
}

init();

Модуль загружается сразу.


top-level await

await load();

Модуль считается незавершённым до окончания операции.

Разница фундаментальна:

Подход Блокирует импорт
async function Нет
top-level await Да

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

Webpack позволяет писать:

await initializeApp();

import('./bootstrap');

или:

const config = await loadConfig();

start(config);

Entry module становится асинхронным.


Runtime Webpack и Promise-based execution

Webpack 5 внедрил promise-based runtime для async modules.

Внутренне runtime:

  • отслеживает pending modules;
  • управляет очередями выполнения;
  • разрешает async dependencies;
  • координирует chunk loading.

Совместимость с Babel

Babel долгое время не имел полноценной поддержки top-level await.

Современные версии используют:

{
    "plugins": [
        "@babel/plugin-syntax-top-level-await"
    ]
}

Важно понимать:

  • syntax plugin лишь разрешает синтаксис;
  • трансформацию выполняет Webpack;
  • Babel не эмулирует поведение async modules самостоятельно.

TypeScript и top-level await

Для TypeScript требуется:

{
    "compilerOptions": {
        "module": "esnext",
        "target": "es2017"
    }
}

или выше.


Ограничения target environment

Поддержка зависит от среды выполнения.

Современные браузеры

Поддерживаются:

  • Chromium-based browsers;
  • Firefox;
  • Safari;
  • Node.js ESM.

Старые браузеры

Проблемы возникают в:

  • Internet Explorer;
  • старых Safari;
  • старых Android WebView.

Webpack не может полностью полифилить семантику async modules.


Влияние на tree shaking

Асинхронность усложняет анализ зависимостей.

Webpack должен учитывать:

  • side effects;
  • async execution order;
  • потенциальные runtime dependencies.

Иногда tree shaking становится менее агрессивным.


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

В Module Federation top-level await особенно полезен.

Пример:

const remote = await import('remoteApp/bootstrap');

или:

await __webpack_init_sharing__('default');

Асинхронная инициализация remote containers становится естественной частью execution flow.


SSR и top-level await

В server-side rendering использование требует осторожности.

Проблема

Если серверный entrypoint содержит:

await connectDatabase();

рендеринг будет заблокирован до завершения подключения.


Потенциальные проблемы в SSR

Возможны:

  • медленный first render;
  • блокировка response stream;
  • задержка hydration state;
  • каскадное ожидание модулей.

Рекомендации по использованию

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

top-level await хорошо подходит для:

  • загрузки конфигурации;
  • инициализации SDK;
  • подготовки WebAssembly;
  • dynamic imports;
  • federation runtime;
  • получения feature flags.

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

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

  • тяжелые сетевые запросы в core modules;
  • долгие вычисления;
  • множественные последовательные await;
  • асинхронность внутри базовых utility modules.

Оптимизация async initialization

Параллельное выполнение

Вместо:

const a = await loadA();
const b = await loadB();

лучше:

const [a, b] = await Promise.all([
    loadA(),
    loadB()
]);

Минимизация async graph

Желательно ограничивать количество async modules.

Плохая архитектура:

App
 ├── Config (await)
 ├── Store (await)
 ├── Router (await)
 ├── UI (await)
 └── Theme (await)

Лучше концентрировать асинхронность ближе к entrypoint.


Отделение bootstrap-логики

Хороший паттерн:

// index.js

import('./bootstrap');
// bootstrap.js

const config = await loadConfig();

startApp(config);

Асинхронность локализуется в bootstrap-фазе.


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

При использовании top-level await полезно анализировать:

  • waterfall loading;
  • startup latency;
  • async chunk graph;
  • execution timing;
  • dependency cycles.

Отладка async modules

Webpack DevTools могут показывать:

  • async chunks;
  • pending modules;
  • timing загрузки;
  • execution order.

Влияние на HMR

Hot Module Replacement становится сложнее.

Если модуль:

await initializeSomething();

то при hot reload требуется повторная асинхронная инициализация.

Это может приводить к:

  • race conditions;
  • duplicated state;
  • stale promises;
  • повторным подключениям.

Побочные эффекты и sideEffects

Асинхронные модули с побочными эффектами требуют особого внимания.

Например:

await initializeGlobalState();

Webpack не сможет безопасно удалить такой модуль при tree shaking.


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

При persistent caching Webpack кэширует:

  • dependency graph;
  • async boundaries;
  • compilation metadata.

Но runtime-await всё равно выполняется заново при запуске приложения.


Особенности chunk loading

Если chunk содержит async module:

chunk.js
 └── module-with-await.js

Webpack:

  1. Загружает chunk;
  2. Выполняет модуль;
  3. Ожидает завершения Promise;
  4. Продолжает выполнение зависимостей.

Runtime ошибки

Типичные проблемы:

Uncaught (in promise)
Circular dependency detected
Module execution failed

Архитектурные последствия

top-level await превращает модульную систему из синхронной в частично асинхронную.

Это влияет на:

  • порядок инициализации;
  • время старта приложения;
  • dependency graph;
  • lazy loading;
  • SSR;
  • federation;
  • runtime execution model.

Webpack 5 фактически внедрил полноценную поддержку асинхронных модулей на уровне всего bundler runtime.