Пошук уроків, статей та іншого контенту
Застосуєте top-level await, розберете асинхронне завантаження модулів і врахуєте його вплив на залежності програми.
top-level await — це можливість використовувати await безпосередньо на верхньому рівні ES-модуля, поза межами async-функції.
const response = await fetch("/api/config");
const config = await response.json();
export default config;Такий синтаксис дозволений лише в асинхронному контексті:
у ES-модулях із type="module";
у файлах, які Node.js трактує як ES-модулі;
у модулях, завантажених через import().
У звичайному скрипті або CommonJS-модулі цей код буде синтаксично некоректним:
// Не працює у звичайному script або CommonJS-модулі
const value = await getValue();Раніше для такого коду потрібно було створювати асинхронну функцію:
async function main() {
const value = await getValue();
console.log(value);
}
main();top-level await дає змогу виконувати асинхронну ініціалізацію самого модуля.
Модуль може завантажити конфігурацію, підготувати з’єднання або отримати інші дані ще до того, як його використають залежні модулі.
Структура проєкту:
project/
├── app.mjs
├── config.mjs
└── settings.jsonФайл settings.json:
{
"apiUrl": "https://api.example.com",
"timeout": 5000
}Файл config.mjs:
import { readFile } from "node:fs/promises";
const fileContent = await readFile(
new URL("./settings.json", import.meta.url),
"utf8"
);
const config = JSON.parse(fileContent);
export default config;Файл app.mjs:
import config from "./config.mjs";
console.log("Конфігурацію завантажено:");
console.log(config);Запуск:
node app.mjsКоли Node.js обробляє app.mjs, він бачить залежність від config.mjs. Спочатку Node.js завантажує та виконує config.mjs. Оскільки в ньому є top-level await, виконання цього модуля призупиняється до завершення readFile.
Тільки після цього app.mjs отримує доступ до готового значення config.
Модулі утворюють граф залежностей:
app.mjs
└── config.mjs
└── node:fs/promisesБез top-level await виконання модуля зазвичай можна розглядати як синхронну фазу після завантаження залежностей.
З top-level await модуль стає асинхронним. Його оцінювання має завершитися до того, як продовжиться оцінювання модулів, що від нього залежать.
// database.mjs
const connection = await connectToDatabase();
export { connection };// repository.mjs
import { connection } from "./database.mjs";
export function getUsers() {
return connection.query("SELECT * FROM users");
}// app.mjs
import { getUsers } from "./repository.mjs";
const users = await getUsers();
console.log(users);У цьому прикладі:
database.mjs очікує завершення connectToDatabase().
repository.mjs не може завершити ініціалізацію раніше, ніж готовий database.mjs.
app.mjs отримує готовий експорт після завершення всіх необхідних асинхронних залежностей.
Важливо: імпортоване значення не стає Promise лише через використання await у модулі.
// config.mjs
const value = await Promise.resolve(42);
export { value };// app.mjs
import { value } from "./config.mjs";
console.log(value); // 42Модуль очікує завершення операції, але після цього експортує звичайне значення 42.
Є два основні способи асинхронно працювати з модулями.
import config from "./config.mjs";
console.log(config);Сам import залишається статичним, але config.mjs може мати асинхронну ініціалізацію.
Це доречно, коли залежність потрібна для запуску всієї програми:
import { initializeApplication } from "./application.mjs";
import config from "./config.mjs";
initializeApplication(config);Динамічний імпорт викликається як функція й повертає Promise:
const module = await import("./feature.mjs");
module.start();Динамічний імпорт корисний, коли модуль потрібен лише за певної умови:
if (process.env.ENABLE_REPORTS === "true") {
const { generateReport } = await import("./reports.mjs");
await generateReport();
}Різниця:
статичний import є частиною графа залежностей ще до виконання основного коду;
import() запускається під час виконання;
статичний імпорт впливає на старт програми;
динамічний імпорт можна виконати лише тоді, коли функціональність справді потрібна.
Помилка в top-level await робить асинхронне оцінювання модуля невдалим.
// config.mjs
const response = await fetch("https://example.invalid/config.json");
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
export const config = await response.json();Якщо цей модуль імпортувати статично, помилка буде передана в процес завантаження модуля:
// app.mjs
import { config } from "./config.mjs";
console.log(config);У багатьох випадках це означає, що програма не зможе нормально запуститися.
Для контрольованої обробки помилки можна використати динамічний імпорт:
try {
const { config } = await import("./config.mjs");
console.log("Конфігурацію завантажено:", config);
} catch (error) {
console.error("Не вдалося завантажити конфігурацію:", error);
}Альтернативний варіант — обробити помилку безпосередньо в модулі:
let config;
try {
config = await loadConfig();
} catch (error) {
console.error("Використовуємо конфігурацію за замовчуванням");
config = {
apiUrl: "http://localhost:3000",
timeout: 3000
};
}
export { config };Стратегію потрібно обирати залежно від критичності залежності:
без конфігурації програма не може працювати — краще завершити запуск із помилкою;
сервіс необов’язковий — можна використати резервне значення;
функція потрібна рідко — краще завантажувати її через import().
top-level await додає асинхронну точку очікування до графа модулів.
Наприклад:
// translations.mjs
const response = await fetch("/translations/uk.json");
export const translations = await response.json();// app.mjs
import { translations } from "./translations.mjs";
startApplication(translations);Уся частина програми, яка залежить від translations.mjs, чекатиме завершення мережевого запиту.
Це може бути правильно для критичних даних, але небажано для необов’язкових функцій. Якщо переклади потрібні лише на окремій сторінці, краще використати відкладене завантаження:
async function openEditor() {
const { createEditor } = await import("./editor.mjs");
createEditor();
}Перед використанням top-level await потрібно поставити такі запитання:
Чи справді дані потрібні до запуску програми?
Чи можна завантажити модуль лише під час використання функції?
Що станеться, якщо операція завершиться помилкою?
Чи має операція тайм-аут?
Чи не очікують один одного модулі циклічно?
Для мережевих запитів бажано явно задавати тайм-аут:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch("https://api.example.com/config", {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
const config = await response.json();
exportConfig(config);
} finally {
clearTimeout(timeoutId);
}У реальному модулі зазвичай експортують змінну або функцію, а не викликають умовну exportConfig. Приклад структури з коректним експортом:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
let config;
try {
const response = await fetch("https://api.example.com/config", {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
config = await response.json();
} finally {
clearTimeout(timeoutId);
}
export { config };Якщо в модулі є кілька незалежних асинхронних операцій, послідовний await може бути повільнішим, ніж потрібно.
Неоптимальний варіант:
const users = await loadUsers();
const permissions = await loadPermissions();Другий запит починається лише після завершення першого.
Якщо операції незалежні, їх можна запустити одночасно:
const usersPromise = loadUsers();
const permissionsPromise = loadPermissions();
const [users, permissions] = await Promise.all([
usersPromise,
permissionsPromise
]);Або безпосередньо:
const [users, permissions] = await Promise.all([
loadUsers(),
loadPermissions()
]);Це важливо й під час асинхронної ініціалізації модуля:
const [config, translations] = await Promise.all([
loadConfig(),
loadTranslations()
]);
export { config, translations };Однак Promise.all слід використовувати лише для незалежних операцій. Якщо друга операція потребує результату першої, послідовний await є правильним:
const token = await loadToken();
const profile = await loadProfile(token);Циклічна залежність виникає, коли модулі прямо або опосередковано імпортують один одного:
a.mjs → b.mjs → a.mjsЗвичайні циклічні імпорти вже можуть створювати проблеми через порядок ініціалізації. top-level await робить такі проблеми складнішими.
// a.mjs
import { valueB } from "./b.mjs";
export const valueA = await Promise.resolve(valueB + 1);// b.mjs
import { valueA } from "./a.mjs";
export const valueB = await Promise.resolve(valueA + 1);Кожен модуль очікує дані, які залежать від іншого модуля. Така структура може призвести до помилки або зависання залежно від конкретного графа та середовища виконання.
винести спільні значення в третій модуль;
передавати залежності через аргументи функцій;
розділити ініціалізацію та використання;
не виконувати взаємозалежний асинхронний код під час завантаження модулів.
Наприклад, замість взаємного імпорту можна створити окремий модуль:
// shared.mjs
export const settings = {
featureEnabled: true
};// feature-a.mjs
import { settings } from "./shared.mjs";
export function runA() {
return settings.featureEnabled;
}// feature-b.mjs
import { settings } from "./shared.mjs";
export function runB() {
return settings.featureEnabled;
}У браузері top-level await доступний у скриптах-модулях:
<script type="module" src="/app.js"></script>Файл app.js:
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error(`Не вдалося завантажити конфігурацію: ${response.status}`);
}
const config = await response.json();
document.body.textContent = `API: ${config.apiUrl}`;Скрипт із type="module" виконується як модуль. Звичайний скрипт без цього атрибута не підтримує top-level await.
Модулі, які імпортують асинхронний модуль, чекатимуть на його завершення:
// app.js
import { config } from "./config.js";
console.log(config);// config.js
const response = await fetch("/api/config");
const config = await response.json();
export { config };Для браузерного коду потрібно враховувати:
мережеву затримку;
помилки HTTP;
недоступність сервера;
кешування;
сценарій, коли користувач може взаємодіяти зі сторінкою до завершення завантаження.
Якщо асинхронні дані не потрібні для першого відображення сторінки, часто краще завантажувати їх після початкового запуску або під час відкриття конкретного розділу.
top-level await є можливістю ES-модулів, а не CommonJS-модулів.
ES-модуль можна позначити розширенням .mjs:
// app.mjs
const value = await Promise.resolve("готово");
console.log(value);Або вказати тип модуля в package.json:
{
"type": "module"
}Після цього файли .js у цьому пакеті трактуються як ES-модулі.
У CommonJS-файлі такий код не працює:
// app.cjs
const value = await Promise.resolve("готово");Якщо CommonJS-коду потрібно завантажити ES-модуль, можна використати динамічний імпорт:
// app.cjs
async function main() {
const module = await import("./feature.mjs");
module.start();
}
main().catch((error) => {
console.error("Помилка запуску:", error);
process.exitCode = 1;
});Це дає змогу поступово інтегрувати ES-модулі в наявний CommonJS-проєкт.
Модуль із асинхронною ініціалізацією не можна вважати повністю готовим одразу після початку його завантаження.
Наприклад:
// service.mjs
const client = await createClient();
export function getClient() {
return client;
}Тести та інші модулі мають імпортувати його як ES-модуль і враховувати, що імпорт завершиться лише після створення клієнта.
Для тестованості часто корисно винести створення залежності в окрему функцію:
// service.mjs
let client;
export async function initializeService() {
client = await createClient();
}
export function getClient() {
if (!client) {
throw new Error("Сервіс ще не ініціалізовано");
}
return client;
}Такий підхід не завжди потрібен, але він дає більше контролю над життєвим циклом у тестах. Натомість top-level await зручніший, коли модуль повинен бути гарантовано готовим одразу після імпорту.
// Помилка
const data = await loadData();Потрібно або перетворити файл на ES-модуль, або перенести код в async-функцію.
Не кожну операцію потрібно виконувати під час завантаження модуля. Якщо функція використовується рідко, динамічний імпорт може бути кращим.
Мережевий запит, читання файлу або підключення до сервісу можуть завершитися невдало. Необроблена помилка під час ініціалізації може зупинити завантаження всього графа залежностей.
const first = await loadFirst();
const second = await loadSecond();Якщо операції незалежні, використовуйте Promise.all.
Взаємозалежні модулі з top-level await складно аналізувати й легко перетворити на цикл очікування. Архітектуру таких модулів краще спростити.
top-level await не робить статичний import умовним. Модуль усе одно є частиною графа залежностей і може впливати на запуск програми.
Модуль, який чекає на мережеву операцію без обмеження часу, може затримати старт програми на невизначений період.
top-level await дозволяє використовувати await безпосередньо в ES-модулі.
Модуль із top-level await має асинхронне оцінювання.
Модулі, що залежать від нього, очікують завершення його ініціалізації.
Експортоване значення після завершення await не є Promise, якщо його явно не оголошено як такий.
Для необов’язкових або рідко використовуваних функцій підходить динамічний import().
Незалежні асинхронні операції варто запускати паралельно через Promise.all.
Помилки, тайм-аути та циклічні залежності потрібно враховувати на рівні архітектури.
У Node.js top-level await працює в ES-модулях, але не в CommonJS-файлах.