Модулі JavaScript

Цей посібник дає всі необхідні знання, аби почати використовувати синтаксис модулів JavaScript.

Контекст появи модулів

Програми на JavaScript спершу були доволі дрібними: більшість застосування JavaScript полягала в ізольованих сценарних операціях, доданні дрібки інтерактивності до вебсторінок, де це необхідно, тож великі сценарії загалом не були потрібні. Перемотка на кілька років – і ось цілі застосунки працюють в браузерах, з купою JavaScript, а крім того, JavaScript використовується в інших контекстах (Node.js, наприклад).

Таким чином, протягом останніх років з'явився зміст подумати про надання механізмів розбиття програм на JavaScript на окремі модулі, котрі можуть бути імпортовані при потребі. Node.js вже тривалий час має таку можливість, крім того, є низка бібліотек і фреймворків JavaScript, що дають змогу користуватись модулями (наприклад, модульні системи на основі CommonJS і AMD, як то RequireJS (англ.), а геть нещодавно – Webpack (англ.) і Babel (англ.)).

Добрі новини полягають в тому, що сучасні браузери почали підтримувати функціональність модулів нативно, і це те, про що розповідає вся ця стаття. Це може бути лише доброю штукою: браузери можуть оптимізувати завантаження модулів, роблячи його більш ефективним, ніж потреба використати бібліотеку й виконувати всю надлишкову обробку на клієнтському боці й зайві ходки в мережу.

Використання нативних модулів JavaScript залежить від інструкцій import і export; їх підтримка в браузері показана в таблиці сумісності нижче.

Сумісність із браузерами

{{Compat}}

Введення прикладу

Для демонстрації використання модулів ми створили простий набір прикладів, доступний на GitHub. Ці приклади демонструють простий набір модулів, що створюють на вебсторінці елемент <canvas>, а потім малюють (і повідомляють про це інформацію) на полотні різні фігури.

Вони доволі тривіальні, але навмисно були залишені простими, щоб ясно демонструвати модулі.

Примітка: При потребі завантажити приклади й запустити їх локально – їх треба запускати через локальний вебсервер.

Структура базового прикладу

В нашому першому прикладі (дивіться basic-modules) присутня наступна файлова структура:

index.html
main.js
modules/
    canvas.js
    square.js

Примітка: Всі приклади цих настанов по суті мають однакову структуру; структура вище повинна стати доволі звичною.

Два модулі директорії модулів описані нижче:

  • canvas.js — містить функції, пов'язані з налаштуванням полотна:

    • create() — створює полотно із заданими width і height всередині обгортки <div> із заданим ID, котрий, своєю чергою, додається в кінець заданого батьківського елемента. Повертає об'єкт, що містить 2D контекст полотна й ID обгортки.
    • createReportList() — створює невпорядкований список, доданий в кінець вказаного елемента-обгортки, котрий може бути використаний для виведення звітних даних. Повертає ID списку.
  • square.js — містить:

    • name — сталу, що містить рядок 'square'.
    • draw() — малює на заданому полотні квадрат, що має задані розмір, положення й колір. Повертає об'єкт, що містить розмір, положення й колір квадрата.
    • reportArea() — вписує площу квадрата в особливий звітний список, отримавши його довжину.
    • reportPerimeter() — вписує периметр квадрата в особливий звітний список, отримавши його довжину.

Крок убік — .mjs проти .js

Протягом цієї статті ми використовували для модульних файлів розширення .js, однак в інших ресурсах можна зустріти використання розширення .mjs. Наприклад, документація V8 радить використовувати саме .mjs (англ.). Причини для цього:

  • Покращення очевидності, тобто таке розширення робить очевидним те, які файли є модулями, а які – звичайним JavaScript.
  • Певність щодо того, що модульні файли розбираються як модулі такими середовищами виконання, як Node.js (англ.), й інструментами складання, як то Babel (англ.).

Проте ми вирішили надалі використовувати .js, принаймні поки що. Аби модулі коректно працювали в браузері, треба пересвідчитися, що сервер видає їх з заголовком Content-Type, що містить MIME тип JavaScript, як то text/javascript. Без цього буде помилка строгої перевірки типу MIME і слова "The server responded with a non-JavaScript MIME type", і браузер такий JavaScript не запускатиме. Більшість серверів самі по собі встановлюють коректний тип для файлів .js, але не для файлів .mjs. Серед серверів, що уже видають файли .mjs коректно – GitHub Pages і http-server для Node.js.

У форматі .mjs немає проблеми, якщо ви вже використовуєте відповідне середовище, або якщо ще ні, але розумієте, що робите, і маєте доступ (тобто можете налаштувати свій сервер на встановлення коректного Content-Type для файлів .mjs). Утім, це може викликати спантеличення, якщо ви не контролюєте сервер, з котрого видаються файли, або публікуєте файли для публічного використання, як ми.

Для потреб навчання й переносності ми вирішили залишити розширення .js.

Якщо ви справді цінуєте очевидність використання .mjs для модулів на противагу .js для "звичайних" файлів JavaScript, але не хочете зіткнутися з проблемою, описаною вище, можна завжди використовувати .mjs під час розробки, перетворюючи на .js під час кроку складання.

Також варто зазначити, що:

  • Частина інструментів може ніколи не підтримувати .mjs.
  • Атрибут <script type="module"> використовується для позначення, що вказівка відбувається на модуль, як це видно нижче.

Експорт можливостей модуля

Перше, що слід зробити для отримання доступу до можливостей модуля, – експортувати їх. Це робиться за допомогою інструкції export.

Найлегший спосіб її застосувати – розташувати перед будь-якими сутностями, котрі хочеться експортувати з модуля, наприклад:

export const name = "square";

export function draw(ctx, length, x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x, y, length, length);

  return { length, x, y, color };
}

Можна експортувати, var, let, const, і – як побачимо згодом – класи. Вони мусять бути сутностями зовнішнього рівня; не можна застосувати export, наприклад, всередині функції.

Зручніший спосіб експортувати все, що хочеться експортувати, – використати єдину інструкцію експорту в кінці файлу модуля, після якої – розділений комами список можливостей до експорту, загорнутий в фігурні дужки. Наприклад:

export { name, draw, reportArea, reportPerimeter };

Імпорт можливостей до сценарію

Після експортування певних можливостей модуля треба імпортувати їх до сценарію, щоб мати змогу ці можливості використовувати. Найпростіший спосіб це зробити – такий:

import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";

Спершу інструкція import, далі – розділений комами список можливостей до імпорту, загорнутий в фігурні дужки, далі – ключове слово from, потім – модульний специфікатор.

Модульний специфікатор містить рядок, котрий середовище JavaScript може перетворити на шлях до файлу модуля. У браузері це може бути шлях, відносний щодо кореня сайту, котрий для нашого прикладу basic-modules був би /js-examples/module-examples/basic-modules. Проте тут натомість вжито синтаксис крапки (.), котра вказує на "поточне розташування", після крапки ж – решта шляху до шуканого файлу. Це набагато краще за написання щоразу всього абсолютного шляху, адже так коротше, і так URL стає більш переносним: приклад все одно працюватиме, якщо перенести його в інше місце в ієрархії сайту.

Тож, наприклад:

/js-examples/module-examples/basic-modules/modules/square.js

стає

./modules/square.js

В дії такі рядки можна побачити в main.js.

Примітка: У частині модульних систем можна вживати модульні специфікатори виду modules/square, котрі не є ані відносним, ані абсолютним шляхом, а також не містять розширення файлу. Специфікатори такого ґатунку можна вживати у браузерному середовищі, якщо спершу означити карту імпортування.

Після імпортування можливостей до сценарію їх можна використовувати так, ніби вони визначені в тому самому файлі. Наступний код розташований у main.js, під рядками імпорту:

const myCanvas = create("myCanvas", document.body, 480, 320);
const reportList = createReportList(myCanvas.id);

const square1 = draw(myCanvas.ctx, 50, 50, 100, "blue");
reportArea(square1.length, reportList);
reportPerimeter(square1.length, reportList);

Примітка: Імпортовані значення є доступними лише для прочитання представленнями експортованих можливостей. Подібно до змінних const, не можна присвоїти нове значення імпортованій змінній, але можна змінити властивості об'єктних значень. Значенню може бути присвоєно нове значення лише в тому модулі, що його експортував. Дивіться приклад у довідці import.

Імпорт модулів за допомогою карт імпортування

Вище показано, як браузер може імпортувати модуль за допомогою модульного специфікатора, котрий є або абсолютним URL, або відносним URL, що розв'язується від базового URL документа:

import { name as squareName, draw } from "./shapes/square.js";
import { name as circleName } from "https://example.com/shapes/circle.js";

Карти імпортування дають розробникам змогу натомість задати майже будь-який текст у модульному специфікаторі при імпорті модуля; карта надає відповідне значення, котре замінить текст при розв'язанні URL модуля.

Наприклад, ключ imports у карті імпортування нижче задає об'єкт JSON "карти модульних специфікаторів", де імена властивостей можуть вживатися як модульні специфікатори, а відповідні їм значення – будуть підставленні при розв'язанні браузером URL модуля. Значення повинні бути абсолютними або відносними URL. Відносні URL розв'язуються до абсолютних адрес URL за допомогою базового URL документа, що містить карту імпортування.

<script type="importmap">
  {
    "imports": {
      "shapes": "./shapes/square.js",
      "shapes/square": "./modules/shapes/square.js",
      "https://example.com/shapes/square.js": "./shapes/square.js",
      "https://example.com/shapes/": "/shapes/square/",
      "../shapes/square": "./shapes/square.js"
    }
  }
</script>

Карта імпортування означається за допомогою об'єкта JSON всередині елемента <script>, чий атрибут type має значення importmap. У документі може бути лише одна карта імпортування, і через те, що вона використовується для розв'язання того, які модулі завантажуються як при статичному, так при динамічному імпортуванні, вона повинна бути оголошена до всіх елементів <script>, що імпортують модулі. Зверніть увагу, що карта імпортування застосовується лише до документа – специфікація не охоплює те, як застосовувати карту імпортування до контексту воркера чи ворклета.

За допомогою цієї карти можна використовувати її імена властивостей як модульні специфікатори. Якщо в кінці ключа – модульного специфікатора – немає скісної риски, то увесь такий ключ дає збіг і замінюється. Наприклад, нижче відбувається збіг з простими іменами модулів, а також перенаправлення URL на інший шлях.

// Прості імена модулів як модульні специфікатори
import { name as squareNameOne } from "shapes";
import { name as squareNameTwo } from "shapes/square";
// Перенаправлення URL на іншу URL
import { name as squareNameThree } from "https://example.com/shapes/square.js";

Якщо модульний специфікатор містить пряму скісну риску в кінці, то відповідне йому значення також повинно мати таку риску, і такий ключ дає збіг як "префікс шляху". Це дає змогу перенаправляти цілі класи URL.

// Перенаправлення URL за допомогою префіксу ( https://example.com/shapes/)
import { name as squareNameFour } from "https://example.com/shapes/moduleshapes/square.js";

Декілька ключів карти імпортування можуть давати дійсний збіг для одного модульного специфікатора. Наприклад, модульний специфікатор shapes/circle/ може дати збіг з ключами модульних специфікаторів shapes/ і shapes/circle/. В такому випадку браузер обере найконкретніший (найдовший) ключ модульного специфікатора, що дає збіг.

Карти імпортування дають змогу імпортувати модулі за допомогою простих імен модулів (як в Node.js), а також імітують імпортування модулів з пакетів, як з розширеннями файлів, так і без них. Хоч це й не показано вище, також такі карти дають змогу імпортувати конкретні версії бібліотек, залежно від шляху до сценарію, що імпортує такий модуль. Загалом, вони дають розробникам більш ергономічний код імпортування, а також полегшують керування різними версіями й залежностями модулів, котрі використовуються сайтом. Це може зменшити зусилля, необхідні для використання одних і тих же бібліотек JavaScript і в браузері, і на сервері.

Наступні розділи розлогіше пояснюють можливості, описані вище.

Перевірка можливостей

Підтримку карт імпортування можна перевірити за допомогою статичного методу HTMLScriptElement.supports() (котрий сам має широку підтримку):

if (HTMLScriptElement.supports?.("importmap")) {
  console.log("Браузер підтримує карти імпортування.");
}

Імпортування модулів у вигляді простих імен

У частині середовищ JavaScript, як то Node.js, можна використовувати прості імена за модульні специфікатори. Це працює, тому що середовище розв'язує імена модулів до стандартних розташувань у файловій системі. Наприклад, можна використати наступний синтаксис для імпортування модуля "square".

import { name, draw, reportArea, reportPerimeter } from "square";

Для вживання простих імен у браузері необхідна карта імпортування, котра надає інформацію, необхідну браузерові для розв'язання модульних специфікаторів до URL (JavaScript викине TypeError, якщо спробує імпортувати модульний специфікатор, котрий неможливо розв'язати до розташування модуля). Нижче можна бачити карту, що задає ключ модульного специфікатора square, котрий у цьому випадку відображується на значення відносної адреси.

<script type="importmap">
  {
    "imports": {
      "square": "./shapes/square.js"
    }
  }
</script>

За допомогою такої карти можна використовувати при імпортуванні модуля його просте ім'я:

import { name as squareName, draw } from "square";

Перенаправлення шляхів модулів

Записи карти модульних специфікаторів, де і ключ специфікатора, і пов'язане з ним значення – мають пряму скісну риску в кінці (/), можуть використовуватися як префікси шляху. Це дає змогу перенаправляти цілу низку URL імпортування з одного місця в інше. Крім цього, це можна використовувати для імітації роботи з "пакетами й модулями", як це можна бачити в екосистемі Node.

Примітка: / у кінці вказує на те, що ключ модульного специфікатора може бути замінений як частина модульного специфікатора. Якщо / немає, то браузер шукатиме збіг (і виконуватиме заміну) лише з усім ключем – модульним специфікатором.

Пакети модулів

Наступне означення карти імпортування JSON перенаправляє lodash як просте ім'я, а також префікс модульного специфікатора lodash/, на шлях /node_modules/lodash-es/ (розв'язується відносно базового URL документа):

{
  "imports": {
    "lodash": "/node_modules/lodash-es/lodash.js",
    "lodash/": "/node_modules/lodash-es/"
  }
}

З таким перенаправленням можна імпортувати й увесь "пакет", вживаючи його просте ім'я, і модулі всередині нього (за допомогою перенаправлення шляхів):

import _ from "lodash";
import fp from "lodash/fp.js";

Можна імпортувати fp вище і без розширення файлу .js, але для цього довелось би створити простий ключ – модульний специфікатор – для цього файлу, як то lodash/fp, а не використовувати шлях до нього. Це може бути доцільним для одного модуля, але погано масштабується за потреби імпортувати чимало модулів.

Загальне перенаправлення модулів

Ключ – модульний специфікатор – не обов'язково повинен бути шляхом: він також може бути абсолютним URL (або подібним до URL відносним шляхом, як то ./, ../, /). Це може бути корисним, коли є потреба перенаправити модуль, котрий містить абсолютні шляхи, до локального ресурсу.

{
  "imports": {
    "https://www.unpkg.com/moment/": "/node_modules/moment/"
  }
}

Модулі обмеженої сфери дії задля керування версіями

Екосистеми штибу Node використовують для керування модулями та їх залежностями пакетні менеджери, як то npm. Пакетні менеджери слідкують, аби кожний модуль був відділений від решти модулів та їхніх залежностей. Як наслідок, хоч складний застосунок може включати один і той же модуль декілька разів, у вигляді кількох різних версій за різними шляхами в графі модулів, користувачі не повинні замислюватися про таку складність.

Примітка: До речі, керування версіями можна досягнути за допомогою відносних шляхів, але це субоптимально, адже, серед іншого, примушує до конкретної структури проєкту і заважає використовувати прості імена модулів. Подібно до цього, карти імпортування дають змогу мати в застосунку декілька версій залежностей і посилатися до них за допомогою одного модульного специфікатора. Це реалізовується за допомогою ключа scopes, котрий дає змогу задати карти модульних специфікаторів, що будуть використовуватися залежно від шляху до сценарію, що виконує імпортування. Приклад нижче це демонструє.

{
  "imports": {
    "coolmodule": "/node_modules/coolmodule/index.js"
  },
  "scopes": {
    "/node_modules/dependency/": {
      "coolmodule": "/node_modules/some/other/location/coolmodule/index.js"
    }
  }
}

З таким перенаправленням, якщо сценарій з URL, що містить /node_modules/dependency/, імпортує coolmodule, то буде використовуватися версія в /node_modules/some/other/location/coolmodule/index.js. Карта в imports використовується як запасний варіант, коли не має відповідної сфери дії в карті сфер дій, або коли відповідні сфери дії не містять відповідного специфікатора. Наприклад, якщо coolmodule імпортується зі сценарію, шлях до якого не дає збігу зі сферою дії, то натомість застосовується карта модульних специфікаторів у imports, вказуючи на версію в /node_modules/coolmodule/index.js. Зверніть увагу, що шлях, котрий використовується для вибору сфери дії, не впливає на те, як розв'язується адреса. Значення шляху перенаправлення не мусить давати збіг зі сферою дії, а відносні шляхи все одно розв'язуються відносно базового URL того сценарію, що містить карту імпортування. Як і з картами модульних специфікаторів, можна мати кілька ключів сфер дії, і вони можуть містить шляхи, що накладаються. Якщо з URL вихідного сценарію дають збіг декілька сфер дії, то першою обирається найконкретніший шлях сфери дії (найдовший ключ сфери). Браузери використають наступний найконкретніший шлях сфери дії, що дає збіг, якщо в попередній сфері немає збігу, і так далі. Якщо відповідного специфікатора немає в жодній зі сфер дії, що дали збіг, то браузер перевіряє на предмет збігу карту модульних специфікаторів за ключем imports.

Покращення кешування шляхом перенаправлення гешованих імен файлів

Файли сценаріїв, що використовуються вебсайтами, нерідко мають гешовані імена файлів – для спрощення кешування. Недоліком такого підходу є те, що якщо модуль змінюється, то всі модулі, котрі імпортують його за допомогою його гешованого імені файлу, також доведеться оновлювати чи генерувати наново. Це потенційно може призвести до каскаду оновлень, що даремно витрачатимуть мережеві ресурси. Карти імпортування пропонують зручне розв'язання цієї проблеми. Замість задання конкретних гешованих імен файлів, застосунки та сценарії натомість залежать від негешованої версії імені модуля (адреси). Тоді карта імпортування, подібна до карти нижче, надає перенаправлення до фактичного файлу сценарію.

{
  "imports": {
    "main_script": "/node/srcs/application-fg7744e1b.js",
    "dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
  }
}

Коли dependency_script зміниться, то зміниться і його геш у його імені файлу. У такому випадку треба лишень оновити карту імпортування, аби вписати в неї нове ім'я модуля. Немає потреби оновлювати будь-який код мовою JavaScript, що залежить від нього, адже специфікатор в інструкції імпорту не змінюється.

Застосування модуля до HTML

Тепер треба застосувати модуль main.js до сторінки HTML. Це вельми подібно до того, як застосовується до сторінки звичайний сценарій, за винятком кількох відмінностей.

Перш за все треба додати до елемента <script> type="module", аби оголосити сценарій як модуль. Для імпорту сценарію main.js застосовуємо таке:

<script type="module" src="main.js"></script>

Також сценарій модуля можна вбудувати безпосередньо в файл HTML, розташувавши код JavaScript всередині тіла елемента <script>:

<script type="module">
  /* Тут код модуля JavaScript */
</script>

Сценарій, в котрий імпортуються можливості модуля, по суті діє як модуль верхнього рівня. Якщо це упустити, то Firefox, наприклад, дає помилку "SyntaxError: import declarations may only appear at top level of a module".

Інструкції import і export можна використовувати лише в модулях, але не у звичайних сценаріях.

Примітка: Модулі та їхні залежності можуть бути завантажені наперед шляхом задання їх в елементах <link> з атрибутом rel="modulepreloaded". Це може суттєво знизити час завантаження, коли ці модулі використовуються.

Інші відмінності між модулями й звичайними сценаріями

  • Слід звернути увагу на локальне тестування: якщо спробувати завантажити файл HTML локально (тобто з URL file://), то трапляться помилки CORS, у зв'язку з вимогами безпеки модулів JavaScript. Тестування треба проводити за допомогою сервера.
  • Крім того, зверніть увагу, що поведінка частин сценарію, визначених всередині модулів, коли порівняти зі звичайними сценаріями, може відрізнятися. Це пов'язано з тим, що модулі автоматично застосовують суворий режим.
  • Немає потреби застосовувати атрибут defer (дивіться <script> attributes) при завантаженні модульного сценарію; завантаження модулів автоматично відкладається.
  • Модулі виконуються лише раз, навіть якщо до них звертаються декілька тегів <script>.
  • І останнє, але не менш важливе, слід прояснити: можливості модулів імпортуються в область видимості одного сценарію – вони не доступні в глобальній області. Таким чином, імпортовані можливості доступні лише в тому сценарії, в котрий імпортовані, і не вийде звернутися до них з консолі JavaScript, наприклад. Синтаксичні помилки виводитимуться в DevTools, але не вийде застосувати певні методики зневадження, котрі могло б хотітися.

Визначені в модулі змінні обмежені модулем, якщо явно не прикріплені до глобального об'єкта. З іншого боку, глобально описані змінні доступні всередині модуля. Наприклад, в наступному коді:

<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <link rel="stylesheet" href="" />
  </head>
  <body>
    <div id="main"></div>
    <script>
      // Інструкція var створює глобальну змінну
      var text = "Привіт";
    </script>
    <script type="module" src="./render.js"></script>
  </body>
</html>
/* render.js */
document.getElementById("main").innerText = text;

Сторінка покаже Привіт, адже глобальні змінні text і document – доступні в модулі. (Крім того, зауважте в цьому модулі, що модуль не обов'язково потребує інструкцій імпорту чи експорту: єдина необхідна річ – щоб точка входу мала type="module".)

Усталений експорт проти іменованого експорту

Експортована вище функціональність складалася з іменованого експорту: до кожної сутності (чи це функція, const тощо) було звертання за її ім'ям при експорті, і те саме ім'я використовувалося для звертання до неї також при імпорті.

Також є різновид експорту, що зветься усталеним експортом — він розроблений для спрощення використання єдиної функції, наданої модулем, а також допомагає модулям JavaScript взаємодіяти з наявними модульними системами CommonJS та AMD (як це файно описано в Поглиблено про ES6: Модулі (англ.) від Джейсона Орендорффа; шукайте "Default exports").

Погляньмо на приклад у поясненні того, як він працює. В нашому basic-modules square.js є функція, що зветься randomSquare(), котра створює квадрат випадкових кольору, розміру й розташування. Треба експортувати її як усталений експорт, тож в кінці файлу дописуємо таке:

export default randomSquare;

Зверніть увагу на відсутність фігурних дужок.

Замість цього можна дописати export default перед функцією й описати її як анонімну, отак:

export default function (ctx) {
  // …
}

У файлі main.js – імпорт усталеної функції за допомогою такого рядка:

import randomSquare from "./modules/square.js";

Знову таки, зверніть увагу на відсутність фігурних дужок. Це пов'язано з тим, що дозволений лише один усталений експорт на модуль, і відомо, що randomSquare є таким експортом. Рядок вище по суті є скороченням наступного:

import { default as randomSquare } from "./modules/square.js";

Примітка: Синтаксис as для перейменування експортованих сутностей пояснений нижче, в розділі Імпорт та експорт з перейменуванням.

Уникання конфліктів іменування

Поки що, схоже, модулі з малюванням фігур на полотні працювали нормально. Але що буде, якщо спробувати додати модуль, котрий малює іншу фігуру, як то круг чи трикутник? Напевне, для таких фігур також були б відповідні функції, як то draw(), reportArea() тощо; якщо спробувати імпортувати інші функції з таким самим іменем в той самий модульний файл верхнього рівня, то будуть конфлікти й помилки.

На щастя, існує низка способів це обійти. Вони розглядаються в наступних розділах.

Імпорт та експорт з перейменуванням

Всередині фігурних дужок інструкцій import і export можна використовувати ключове слово as вкупі з новим іменем можливості, аби змінити її ім'я, що використовуватиметься в модулі верхнього рівня.

Тож, наприклад, обидва наступні зразки роблять одне й те ж, хоч і в трохи різний спосіб:

// у module.js
export { function1 as newFunctionName, function2 as anotherNewFunctionName };

// у main.js
import { newFunctionName, anotherNewFunctionName } from "./modules/module.js";
// у module.js
export { function1, function2 };

// у main.js
import {
  function1 as newFunctionName,
  function2 as anotherNewFunctionName,
} from "./modules/module.js";

Погляньмо на робочий приклад. В нашій директорії renaming – така сама модульна система, як в попередньому прикладі, крім того, що додалися модулі circle.js і triangle.js, для малювання й звітування про круги та трикутники.

Всередині кожного з цих модулів експортуються можливості з тими самим іменами, а отже – такою інструкцією export внизу:

export { name, draw, reportArea, reportPerimeter };

При їх імпорті в main.js, якщо спробувати

import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/circle.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/triangle.js";

То браузер викине помилку, як то "SyntaxError: redeclaration of import name" (Firefox).

Натомість треба перейменувати імпорт, щоб кожна можливість стала неповторною:

import {
  name as squareName,
  draw as drawSquare,
  reportArea as reportSquareArea,
  reportPerimeter as reportSquarePerimeter,
} from "./modules/square.js";

import {
  name as circleName,
  draw as drawCircle,
  reportArea as reportCircleArea,
  reportPerimeter as reportCirclePerimeter,
} from "./modules/circle.js";

import {
  name as triangleName,
  draw as drawTriangle,
  reportArea as reportTriangleArea,
  reportPerimeter as reportTrianglePerimeter,
} from "./modules/triangle.js";

Зверніть увагу, що замість цього проблему можна вирішити в файлах модулів, наприклад:

// у square.js
export {
  name as squareName,
  draw as drawSquare,
  reportArea as reportSquareArea,
  reportPerimeter as reportSquarePerimeter,
};
// у main.js
import {
  squareName,
  drawSquare,
  reportSquareArea,
  reportSquarePerimeter,
} from "./modules/square.js";

І це працювало б просто так само. Який стиль використовувати – вирішувати вам, проте є зміст залишити код модулів у спокої й вносити зміни в імпорт. Це має особливий зміст при імпорті сторонніх модулів, над котрими немає контролю.

Створення об'єкта модуля

Методика вище працює нормально, але є дещо недоладна й багатослівна. Іще кращий підхід – імпортування можливостей кожного модуля в об'єкт модуля. Це робить наступна синтаксична форма:

import * as Module from "./modules/module.js";

Це згрібає всі інструкції експорту в module.js і робить відповідні можливості членами об'єкта Module, по суті роблячи його простором імен. Тож, наприклад:

Module.function1();
Module.function2();

Знову таки, погляньмо на робочий приклад. Якщо зайти в нашу директорію module-objects, то там той самий приклад, але переписаний для використання переваг такого нового синтаксису. В модулях експорт оформлений в наступний простий спосіб:

export { name, draw, reportArea, reportPerimeter };

Імпорт, з іншого боку, має такий вигляд:

import * as Canvas from "./modules/canvas.js";

import * as Square from "./modules/square.js";
import * as Circle from "./modules/circle.js";
import * as Triangle from "./modules/triangle.js";

В кожному випадку можна звертатись до імпортованих можливостей модуля через вказане ім'я об'єкта, наприклад:

const square1 = Square.draw(myCanvas.ctx, 50, 50, 100, "blue");
Square.reportArea(square1.length, reportList);
Square.reportPerimeter(square1.length, reportList);

Відтак, можна писати код, так само як раніше (поки там, де це потрібно, включаються імена об'єктів), а запис імпорту – набагато стисліший.

Модулі та класи

Як було зауважено вище, також можна експортувати й імпортувати класи; це іще один варіант для уникання конфліктів у коді, і це особливо корисно, коли код модуля вже написаний в об'єктноорієнтованому стилі.

Приклад модуля малювання фігур, переписаний на класи ES, можна побачити в нашій директорії classes. Наприклад, файл square.js тепер вміщує усю свою функціональність в одному класі:

class Square {
  constructor(ctx, listId, length, x, y, color) {
    // …
  }

  draw() {
    // …
  }

  // …
}

котрий далі експортується:

export { Square };

Тим часом в main.js цей клас імпортується, ось так:

import { Square } from "./modules/square.js";

А потім клас використовується для малювання квадрата:

const square1 = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, "blue");
square1.draw();
square1.reportArea();
square1.reportPerimeter();

Агрегування модулів

Трапляються випадки, коли хочеться агрегувати модулі докупи. Може бути декілька рівнів залежностей, де захочеться все спростити, поєднавши декілька підмодулів у одному батьківському модулі. Це можливо в батьківському модулі за допомогою синтаксису експорту наступного вигляду:

export * from "x.js";
export { name } from "x.js";

Приклад є у нашій директорії module-aggregation. В цьому прикладі (заснованому на попередньому прикладі з класами) є додатковий модуль, що зветься shapes.js, котрий агрегує всю функціональність з circle.js, square.js і triangle.js докупи. Крім того, підмодулі перенесені до піддиректорії всередині директорії modules, названої shapes. Тож структура модулів у цьому прикладі:

modules/
  canvas.js
  shapes.js
  shapes/
    circle.js
    square.js
    triangle.js

В кожному з підмодулів експорт має однакову форму, наприклад:

export { Square };

Далі – агрегування. В модулі shapes.js є наступні рядки:

export { Square } from "./shapes/square.js";
export { Triangle } from "./shapes/triangle.js";
export { Circle } from "./shapes/circle.js";

Вони згрібають експорт з окремих підмодулів і роблять його доступним з модуля shapes.js.

Примітка: Експорт, до котрого звертається shapes.js, по суті перенаправляється крізь цей файл і насправді в ньому не існує, тож ви не зможете написати в самому файлі жодного корисного спорідненого коду.

Тож тепер у файлі main.js можна отримати доступ до класів усіх трьох модулів, замінивши

import { Square } from "./modules/square.js";
import { Circle } from "./modules/circle.js";
import { Triangle } from "./modules/triangle.js";

на наступний єдиний рядок:

import { Square, Circle, Triangle } from "./modules/shapes.js";

Динамічне завантаження модулів

Нещодавнє оновлення функціональності модулів JavaScript – динамічне завантаження модулів. Воно дає змогу динамічно завантажувати модулі лише тоді, коли вони потрібні, без потреби завантажувати все одразу. Це дає певні очевидні переваги щодо ефективності; далі – пояснення того, як це працює.

Ця нова функціональність дозволяє викликати import() як функцію, передавши їй шлях до модуля як параметр. Такий виклик поверне Promise, котрий сповнюється об'єктом модуля (дивіться Створення об'єкта модуля), котрий дає доступ до експорту цього об'єкта. Наприклад:

import("./modules/myModule.js").then((module) => {
  // Певні дії з модулем.
});

Примітка: Динамічний імпорт дозволений у головному потоці браузера, а також у спільних та виділених воркерах. Проте import() викине помилку, якщо буде викликана в сервісному воркері або ворклеті.

Погляньмо на приклад. В директорії dynamic-module-imports є іще один приклад на основі прикладу з класами. Проте цього разу при завантаженні прикладу на полотні нічого не малюється. Натомість включені три кнопки – "Circle", "Square" і "Triangle", котрі, бувши натисненими, динамічно завантажують необхідний модуль, а потім використовують його для малювання відповідної фігури.

В цьому прикладі зміни внесені лише до файлів index.html і main.js: експорт модулів – такий самий, як до того.

У main.js за допомогою document.querySelector() отримано посилання на кожну кнопку, наприклад:

const squareBtn = document.querySelector(".square");

Потім до кожної кнопки прикріплюється слухач подій, щоб коли кнопка була натиснена, відповідний модуль динамічно завантажувався і застосовувався для малювання фігури:

squareBtn.addEventListener("click", () => {
  import("./modules/square.js").then((Module) => {
    const square1 = new Module.Square(
      myCanvas.ctx,
      myCanvas.listId,
      50,
      50,
      100,
      "blue",
    );
    square1.draw();
    square1.reportArea();
    square1.reportPerimeter();
  });
});

Зверніть увагу, що у зв'язку з тим, що сповнення промісу повертає об'єкт модуля, клас стає підможливістю об'єкта, а отже – треба звертатися до конструктора з Module. на початку, наприклад: Module.Square( /* … */ ).

Іще одна перевага динамічного імпорту – те, що він доступний завжди, навіть у сценарних середовищах. Таким чином, якщо вже є тег <script> у HTML, котрий не має type="module", все одно можна повторно використати код, що поширюється в модулях, динамічно імпортувавши його.

<script>
  import("./modules/square.js").then((module) => {
    // Певні дії з модулем.
  });
  // Інший код, котрий діє в глобальній області видимості,
  // поки не готовий для переписування на модулі.
  var btn = document.querySelector(".square");
</script>

await верхнього рівня

Await верхнього рівня – доступна в модулях можливість. Це означає, що може використовуватися ключове слово await. Це дає модулям змогу діяти як великі асинхронні функції, тобто код може виконуватися до використання в батьківських модулях, але без блокування завантаження сусідніх модулів.

Погляньмо на приклад. Усі файли й описаний в розділі код доступні в директорії top-level-await, котра є похідною від попередніх прикладів.

По-перше, палітра кольорів оголошується в окремому файлі colors.json:

{
  "yellow": "#F4D03F",
  "green": "#52BE80",
  "blue": "#5499C7",
  "red": "#CD6155",
  "orange": "#F39C12"
}

Потім створюється модуль на ім'я getColors.js, котрий застосовує fetch-запит для завантаження файлу colors.json і повернення даних як об'єкта.

// fetch-запит
const colors = fetch("../data/colors.json").then((response) => response.json());

export default await colors;

Зверніть увагу на останній рядок з експортом.

Ключове слово await застосовується до вказівки експортувати сталу colors. А отже – інші модулі, котрі включатимуть цей, очікуватимуть завантаження й розбору colors, щоб мати змогу використовувати залежність.

Включімо цей модуль до файлу main.js:

import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";

const circleBtn = document.querySelector(".circle");

// …

Застосуймо colors замість рядків, що раніше застосовувалися для виклику функцій фігур:

const square1 = new Module.Square(
  myCanvas.ctx,
  myCanvas.listId,
  50,
  50,
  100,
  colors.blue,
);

const circle1 = new Module.Circle(
  myCanvas.ctx,
  myCanvas.listId,
  75,
  200,
  100,
  colors.green,
);

const triangle1 = new Module.Triangle(
  myCanvas.ctx,
  myCanvas.listId,
  100,
  75,
  190,
  colors.yellow,
);

Це корисно, бо код у main.js не виконається, поки не завершиться код у getColors.js. Проте це не завадить завантаженню інших модулів. Наприклад, модуль canvas.js завантажуватиметься далі, поки виконується отримання colors.

Оголошення імпорту піднімаються

Оголошення імпорту – піднімаються. В цьому випадку це означає, що імпортовані значення доступні в коді модуля навіть до місця, в якому оголошені, і що побічні ефекти імпортованого модуля виробляються до запуску решти коду поточного модуля. Тож, наприклад, у main.js, імпортування Canvas в середині коду все одно працюватиме:

// …
const myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
import { Canvas } from "./modules/canvas.js";
myCanvas.createReportList();
// …

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

Циклічні імпорти

Модулі можуть імпортувати інші модулі, ті модулі можуть імпортувати інші модулі, і так далі. Це формує орієнтований граф, що зветься «графом залежностей». У досконалому світі цей граф є ациклічним. У такому випадку його можна оцінити за допомогою обходу в глибину. Проте цикли іноді є неминучими. Циклічний імпорт виникає, якщо модуль a імпортує модуль b, але b безпосередньо або опосередковано залежить від a. Наприклад:

// -- a.js --
import { b } from "./b.js";
// -- b.js --
import { a } from "./a.js";
// Цикл:
// a.js ───> b.js
//  ^         │
//  └─────────┘

Циклічні імпорти не завжди призводять до помилки. Значення імпортованої змінної отримується лише тоді, коли вона фактично використовується (саме тому можливі живі зв'язки), і лише якщо змінна залишається неініціалізованою під час використання, то викидається ReferenceError.


```js
// -- a.js --
import { b } from "./b.js";
setTimeout(() => {
  console.log(b); // 1
}, 10);
export const a = 2;
// -- b.js --
import { a } from "./a.js";
setTimeout(() => {
  console.log(a); // 2
}, 10);
export const b = 1;

У цьому прикладі як a, так і b – використовуються асинхронно. Тому, коли модуль виконується, ні b, ні a фактично не зчитуються, тому решта коду виконується як зазвичай, і два оператори export надають значення a і b. Потім, після тайм-ауту, як a, так і b – доступні, тому дві інструкції console.log також виконуються як зазвичай. Якщо змінити цей код так, щоб a використовувалась синхронно, то виконання модуля не вдасться:

// -- a.js (модуль входу) --
import { b } from "./b.js";
export const a = 2;
// -- b.js --
import { a } from "./a.js";
console.log(a); // ReferenceError: Cannot access 'a' before initialization
export const b = 1;

Так відбувається через те, що коли JavaScript виконує a.js, то необхідно спершу виконати b.js, залежність a.js. Однак b.js використовує змінну a, яка ще не доступна. З іншого боку, якщо змінити цей код так, щоб b використовувалась синхронно, а a – асинхронно, то виконання модуля вдасться:

// -- a.js (модуль входу) --
import { b } from "./b.js";
console.log(b); // 1
export const a = 2;
// -- b.js --
import { a } from "./a.js";
setTimeout(() => {
  console.log(a); // 2
}, 10);
export const b = 1;

Так відбувається через те, що виконання b.js завершується нормально, тож значення b доступне, коли виконується a.js. Зазвичай слід уникати циклічних імпортів у своїх проєктах, оскільки вони роблять код більш схильним до помилок. Деякі поширені техніки усунення циклів:

  • Злити два модулі в один.
  • Перемістити спільний код у третій модуль.
  • Перемістити частину коду з одного модуля до іншого. Проте також циклічні імпорти можуть виникати, якщо бібліотеки залежать одна від одної, а це важче виправити.

Написання "ізоморфних" модулів

Запровадження модулів заохочує екосистему JavaScript поширювати й повторно використовувати код у модульному стилі. Проте це не обов'язково означає, що певний код JavaScript може працювати в будь-якому середовищі. Припустімо, ви знайшли модуль, що обчислює геші SHA паролів користувача. Чи можна його використати у браузерному фронтенді? Чи можна його використати на Node.js сервері? Відповідь: буває по різному.

Модулі мають доступ до глобальних змінних, як показано вище. Якщо модуль звертається до глобальних значень, як то window, то він може працювати в браузері, але викине помилку на Node.js сервері, адже window там немає. Подібно до цього, якщо кодові потрібен для роботи доступ до process, то такий код може використовуватись лише в Node.js.

Для максимізації перевикористовності модуля нерідко радять робити код "ізоморфним" – тобто демонструвати однакову поведінку в усіх середовищах виконання. Загалом цього досягають у три способи:

  • Розділення модулів на "ядро" і "зв'язування". У "ядрі" слід зосередитися на щирій логіці JavaScript, як то обчисленні гешу, жодної DOM, звертань до мережі, файлової системи – і відкрити доступ до корисних функцій. У "зв'язуванні" можна зчитувати й змінювати глобальний контекст. Наприклад, "браузерне зв'язування" може вирішити отримати значення з поля введення, а "зв'язування Node" – отримати його з process.env, але значення з обох місць передаються до однієї функції ядра й обробляються однаково. Ядро може бути імпортоване в кожному середовищі й використовуватися однаково, тим часом лише зв'язування, котре зазвичай є легковагим, мусить бути специфічним щодо платформи.

  • Визначити, чи існує певне глобальне значення, перед його використанням. Наприклад, якщо ви перевіряєте typeof window === "undefined", то знаєте, що, мабуть, знаходитесь в середовищі Node.js і не повинні звертатися до DOM.

    // myModule.js
    let password;
    if (typeof process !== "undefined") {
      // Виконання в Node.js; отримувати з `process.env`
      password = process.env.PASSWORD;
    } else if (typeof window !== "undefined") {
      // Виконання в браузері; отримувати з поля введення
      password = document.getElementById("password").value;
    }
    

    Краще, якщо дві гілки фактично зводяться до однакової поведінки ("ізоморфної"). Якщо неможливо надати однакову функціональність, або якщо це включає завантаження суттєвого обсягу коду, значна частина якого залишається невикористаною, краще натомість зробити різні "зв'язування".

  • Застосувати поліфіл для надання відсутніх можливостей. Наприклад, при потребі використати функцію fetch, доступної в Node.js лише від версії 18, можна використати подібний API, наприклад, наданий node-fetch. Це можна зробити умовно, за допомогою динамічного імпорту:

    // myModule.js
    if (typeof fetch === "undefined") {
      // Виконання в Node.js; застосувати node-fetch
      globalThis.fetch = (await import("node-fetch")).default;
    }
    // …
    

    Змінна globalThis є глобальним об'єктом, доступним у всіх середовищах, корисним, коли треба отримувати чи створювати в модулях глобальні змінні.

Такі практики не є унікальними для модулів. І все ж, у зв'язку з тенденцією до повторного використання коду та модульності заохочується кросплатформність коду, щоб ним могли насолодитися якомога більше людей. Середовища виконання штибу Node.js також енергійно реалізовують API вебтехнологій, де це можливо, щоб покращити сумісність з вебтехнологіями.

Розв'язання проблем

Кілька порад, котрі можуть допомогти у випадку проблем з роботою модулів. Ласкаво просимо доповнювати список!

  • Згадували про це вище, але повторимо: файли .mjs повинні завантажуватися з типом MIME text/javascript (або іншим сумісним з JavaScript типом MIME, але рекомендований – text/javascript), інакше – буде помилка строгої перевірки типу MIME "The server responded with a non-JavaScript MIME type".
  • Якщо пробувати завантажити файл HTML локально (тобто з URL file://), будуть помилки CORS, пов'язані з вимогами безпеки модулів JavaScript. Тестування треба виконувати через сервер. GitHub pages – ідеальні, адже на додачу віддають файли .mjs з коректним типом MIME.
  • У зв'язку з тим, що .mjs є нестандартним файловим розширенням, частина операційних систем можуть його не розпізнавати чи намагатися замінити чимось іншим. Наприклад, з'ясовано, що macOS без жодних сповіщень додає .js в кінець файлів .mjs, а потім автоматично приховує файлове розширення. Тож усі файли насправді ставали, наприклад, x.mjs.js. Після вимикання автоматичного приховування файлових розширень і навчання системи приймати .mjs – все запрацювало нормально.

Дивіться також