16 липня 2023 р.

Вступ до модулів

Оскільки наш застосунок з часом збільшується, ми захочемо розділити його на кілька файлів, так звані «модулі». Модуль може містити клас або бібліотеку функцій для певної мети.

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

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

Наприклад:

  • AMD – одна з найстаріших модульних систем, спочатку реалізована бібліотекою require.js.
  • CommonJS – модульна система, створена для сервера Node.js.
  • UMD – ще одна модульна система, пропонується як універсальна, сумісна з AMD і CommonJS.

Тепер усі вони поступово стають частиною історії, хоча їх можна знайти в старих скриптах.

Система модулів на рівні мови з’явилася у стандарті JavaScript у 2015 році та поступово еволюціонувала. На цей час вона підтримується більшістю браузерів та Node.js. Далі ми вивчатимемо саме її.

Що таке модуль?

Модуль – це файл. Один скрипт – це один модуль.

Модулі можуть завантажувати один одного та використовувати директиви export та import, щоб обмінюватися функціональністю, викликати функції одного модуля з іншого:

  • export відзначає змінні та функції, які мають бути доступні поза поточним модулем.
  • import дозволяє імпортувати функціональність з інших модулів.

Наприклад, якщо ми маємо файл sayHi.js, який експортує функцію:

// 📁 sayHi.js
export function sayHi(user) {
  alert(`Привіт, ${user}!`);
}

…Тоді інший файл може імпортувати її та використовувати:

// 📁 main.js
import {sayHi} from './sayHi.js';

alert(sayHi); // функція...
sayHi('Іван'); // Привіт, Іван!

Директива import завантажує модуль за шляхом ./sayHi.js до поточного файлу та записує експортовану функцію sayHi у відповідну змінну.

Запустимо приклад у браузері.

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

Ось так:

Результат
say.js
index.html
export function sayHi(user) {
  return `Hello, ${user}!`;
}
<!doctype html>
<script type="module">
  import {sayHi} from './say.js';

  document.body.innerHTML = sayHi('John');
</script>

Браузер автоматично завантажить та запустить імпортований модуль (і ті, що він імпортує, якщо треба), а потім запустить скрипт.

Модулі працюють тільки через HTTP(s), не локально

Якщо ви спробуєте відкрити веб-сторінку локально через file:// протокол, то ви побачите, що директиви import/export не працюють. Використовуйте локальний веб-сервер, такий як static-server або скористайтеся можливістю “живого сервера” вашого редактора, наприклад, VS Code Live Server Extension для тестування модулів.

Основні можливості модулів

Чим модулі відрізняються від «звичайних» скриптів?

Є основні можливості та особливості, що працюють як у браузері, так і на сервері.

Завжди “use strict”

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

<script type="module">
  a = 5; // помилка
</script>

Своя область видимості змінних

Кожен модуль має власну область видимості. Іншими словами, змінні та функції, оголошені в модулі, не доступні в інших скриптах.

В прикладі нижче ми бачимо два імпортованих скрипта. Скрипт з файлу hello.js намагається використати змінну user, яка оголошена в user.js. В наступному коді ми отримаємо помилку:

Результат
hello.js
user.js
index.html
alert(user); // no such variable (each module has independent variables)
let user = "John";
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>

Модулі повинні “експортувати” те, що вони віддають на ззовні, і “імпортувати” те, що їм потрібно.

  • user.js повинен експортувати змінну user.
  • hello.js повинен імпортувати змінну з user.js модуля.

Іншими словами, використовуючи модулі ми користуємось import/export замість використання глобальних змінних.

Правильний варіант:

Результат
hello.js
user.js
index.html
import {user} from './user.js';

document.body.innerHTML = user; // John
export let user = "John";
<!doctype html>
<script type="module" src="hello.js"></script>

У браузері також існує незалежна область видимості для кожного скрипту <script type="module">.

На цій сторінці ми маємо два скрипти type="module". Вони не бачать змінні верхнього рівня один одного:

<script type="module">
  // Змінна доступна тільки в цьому модулі
  let user = "Іван";
</script>

<script type="module">
  alert(user); // Помилка: змінна user не оголошена
</script>
Будь ласка, зверніть увагу:

В браузері є можливість створити глобальну змінну рівня вікна браузера шляхом явного призначення її до об’єкту window, наприклад: window.user = "Іван".

Тоді всі скрипти типу type="module" і без нього будуть “бачити” змінну user.

Тим не менш, створення таких глобальних змінних неприйнятно. Будь ласка, намагайтеся уникати їх.

Код у модулі виконується лише один раз під час імпорту

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

Розуміння одноразового обчислення і послідовності імпорту є важливою основою для розуміння роботи модулів.

Давайте подивимося приклади.

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

// 📁 alert.js
alert("Модуль виконано!");
// Імпорт одного і того ж модуля у різних файлах

// 📁 1.js
import `./alert.js`; // Модуль виконано!

// 📁 2.js
import `./alert.js`; // (нічого не покаже)

Другий імпорт нічого не покаже, тому що цей модуль вже був виконаний.

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

Тепер більш ускладнений приклад.

Уявімо, що модуль експортує об’єкт:

// 📁 admin.js
export let admin = {
  name: "Іван"
};

Якщо модуль імпортується в кількох файлах, код модуля буде виконано лише один раз, об’єкт admin буде створено і надалі буде переданий всім імпортерам.

Всі імпортери отримають один-єдиний об’єкт admin:

// 📁 1.js
import {admin} from './admin.js';
admin.name = "Pete";

// 📁 2.js
import {admin} from './admin.js';
alert(admin.name); // Pete

// Обидва файли, 1.js і 2.js, імпортують той самий об’єкт
// Зміни, зроблені в 1.js, з’являться в 2.js

Як ви бачите, коли 1.js змінює значення властивості name об’єкту admin, тоді інші модулі теж побачать ці зміни.

Ще раз зауважимо – модуль виконується лише один раз. Генерується експорт і після цього передається всім імпортерам, тому, якщо щось зміниться в об’єкті admin, то інші модулі теж побачать ці зміни.

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

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

Класичний шаблон використання:

  1. Модуль експортує деякі значення конфігурації, наприклад, об’єкт конфігурації.
  2. При першому імпорті ми його ініціалізуємо, записуємо в його властивості. Це може зробити скрипт програми верхнього рівня.
  3. Подальші імпорти використовують цей модуль.

Наприклад, модуль admin.js може забезпечувати певну функціональність (наприклад, аутентифікацію), але очікуймо, що облікові дані надходять в об’єкт config ззовні:

// 📁 admin.js
export let config = { };

export function sayHi() {
  alert(`Ready to serve, ${config.user}!`);
}

В цьому прикладі, admin.js експортує config об’єкт (спочатку порожній, але також може мати властивості за замовчуванням).

Потім у init.js, першому скрипту нашої програми, ми імпортуємо config об’єкт і записуємо дані для ініціалізації config.user:

// 📁 init.js
import {config} from './admin.js';
config.user = "Pete";

…Тепер модуль admin.js налаштовано.

Інші модулі можуть імпортувати його, і він правильно показує поточного користувача:

// 📁 another.js
import {sayHi} from './admin.js';

sayHi(); // Ready to serve, Pete!

import.meta

Об’єкт import.meta містить інформацію про поточний модуль.

Вміст залежить від оточення. У браузері він містить посилання на скрипт або посилання на поточну вебсторінку, якщо модуль вбудований в HTML:

<script type="module">
  alert(import.meta.url); // script URL
  // посилання на html сторінку для вбудованого скрипту
</script>

У модулі «this» не визначено

Це незначна особливість, але для повноти картини треба згадати про це.

У модулі на верхньому рівні this не визначено (undefined).

Порівняємо з не-модульними скриптами, там this – глобальний об’єкт:

<script>
  alert(this); // window
</script>

<script type="module">
  alert(this); // undefined
</script>

Особливості у браузерах

Є кілька інших, саме браузерних особливостей скриптів пов’язаних з type="module".

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

Модулі є відкладеними (deferred)

Модулі завжди виконуються у відкладеному (deferred) режимі, так само як скрипти з атрибутом defer (описаний у розділі Скрипти: async, defer). Це вірно і для зовнішніх та вбудованих скриптів-модулів.

Інакше кажучи:

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

Як побічний ефект, модулі завжди бачать повністю завантажену HTML-сторінку, включаючи HTML-елементи під ними.

Наприклад:

<script type="module">
  alert(typeof button); // object: скрипт може 'бачити' кнопку під ним
  // оскільки модулі є відкладеними, то скрипт почне виконуватись тільки після повного завантаження сторінки
</script>

Порівняйте зі звичайним скриптом нижче:

<script>
  alert(typeof button); // Помилка: кнопка не визначена, скрипт не бачить під ним елементи
  // звичайні скрипти запускаються відразу, не чекаючи повного завантаження сторінки
</script>

<button id="button">Button</button>

Будь ласка, зверніть увагу: другий скрипт виконається раніше ніж перший! Тому ми побачимо спочатку undefined, а потім object.

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

При використанні модулів нам варто мати на увазі, що HTML-сторінка буде показана браузером до того, як виконаються модулі та JavaScript-програма буде готовий до роботи. Деякі функції можуть ще не працювати. Нам слід розмістити «індикатор завантаження» або ще щось, щоб не збентежити цим відвідувача.

Атрибут async працює у вбудованих скриптах

Для немодульних скриптів атрибут async працює лише на зовнішніх скриптах. Скрипти з ним запускаються відразу по готовності, вони не чекають на інші скрипти або HTML-документ.

Async також працює у вбудованих сценаріях для модулів.

Наприклад, у скрипті нижче є async, тому він виконається одразу після завантаження, не чекаючи інших скриптів.

Скрипт виконає імпорт (завантажить ./analytics.js) і відразу запуститься, навіть, якщо HTML документ ще не завантажився чи якщо інші скрипти все ще завантажуються.

Це дуже корисно, коли модуль ні з чим не пов’язаний, наприклад, для лічильників, реклами, обробників подій.

<!-- завантажуються залежності (analytics.js) і скрипт запускається -->
<!-- не чекаючи завантаження документу чи інших тегів <script> -->
<script async type="module">
  import {counter} from './analytics.js';

  counter.count();
</script>

Зовнішні скрипти

Зовнішні скрипти з атрибутом type="module" мають дві відмінності:

  1. Зовнішні скрипти з однаковим атрибутом src запускаються лише один раз:

    <!-- скрипт my.js завантажиться і виконається тільки раз -->
    <script type="module" src="my.js"></script>
    <script type="module" src="my.js"></script>
  2. Зовнішній скрипт, який завантажується з іншого домену (наприклад, іншого сайту), вимагає зазначення заголовків CORS, як описано у главі Fetch: Запити між різними джерелами. Іншими словами, якщо модульний скрипт завантажується з іншого домену, віддалений сервер повинен встановити заголовок Access-Control-Allow-Origin, що означає, що завантажити скрипт дозволено.

    <!-- another-site.com повинен вказати заголовок Access-Control-Allow-Origin -->
    <!-- інакше, скрипт не виконається -->
    <script type="module" src="http://another-site.com/their.js"></script>

    Це забезпечує кращу безпеку за промовчанням.

Не допускаються «голі» модулі

У браузері import має містити відносний або абсолютний шлях до модуля. Модулі без вказаного шляху до нього називаються «голими» (bare). Вони не дозволені в імпорті.

Наприклад, даний import неправильний:

import {sayHi} from 'sayHi'; // Помилка, "голий" модуль
// імпорт модуля повинен мати шлях до нього, наприклад, './sayHi.js' чи 'C:/test/sayHi.js' (абсолютний шлях до модуля)

Інші оточення, наприклад Node.js, допускають використання «голих» модулів, без шляхів, тому що в них є свої правила, як працювати з такими модулями та де їх шукати. Але браузери поки не підтримують голі модулі.

Сумісність, “nomodule”

Старі браузери не розуміють атрибут type="module". Скрипти з невідомим типом атрибутів просто ігноруються. Ми можемо зробити для них “резервний” скрипт за допомогою атрибуту nomodule:

<script type="module">
  alert("Працює у сучасних браузерах");
</script>

<script nomodule>
  alert("Сучасні браузери розуміють обидва атрибути: type=module і nomodule, тому пропускають цей тег script)
  alert("Старі браузери ігнорують скрипти з невідомим атрибутом type=module, але виконують цей.");
</script>

Інструменти збирання

У реальному житті модулі в браузерах рідко використовуються у «сирому» вигляді. Зазвичай ми об’єднуємо модулі разом, використовуючи спеціальний інструмент, наприклад Webpack і після викладаємо код на робочий сервер.

Одна з переваг використання збирача – він надає більший контроль над тим, як модулі шукаються, дозволяє використовувати голі модулі та інші “власні” налаштування, наприклад CSS/HTML-модулі.

Збирач робить таке:

  1. Бере «основний» модуль, який ми збираємося помістити в <script type="module"> HTML.
  2. Аналізує залежності (імпорт, імпорти імпортів тощо)
  3. Збирає один файл з усіма модулями (або кілька файлів, це можна налаштувати), перезаписує вбудований import функцією імпорту від збирача, щоб усе працювало. Спеціальні типи модулів, такі як HTML/CSS теж підтримуються.
  4. У процесі можуть відбуватися й інші трансформації та оптимізації коду:
    • Недосяжний код видаляється (код, що ніколи не виконається).
    • Експорти, що не використовуються, видаляються (“tree-shaking”).
    • Специфічні оператори для розробки, такі як console та debugger, видаляються.
    • Сучасний синтаксис JavaScript також може бути трансформований на попередній стандарт, зі схожою функціональністю, наприклад, за допомогою Babel.
    • Отриманий файл можна мінімізувати (видалити пробіли, замінити назви змінних на більш короткі й т.д.).

Якщо ми використовуємо інструменти збирання, вони об’єднують модулі разом в один або кілька файлів, і замінюють import/export на свої виклики. Тому підсумкове збирання можна підключати й без атрибута type="module", а як звичайний скрипт:

<!-- Припустимо, що ми зібрали bundle.js, використовуючи, наприклад, утиліту Webpack -->
<script src="bundle.js"></script>

Хоча і «як є» модулі теж можна використовувати, а збирач налаштувати пізніше за необхідності.

Підсумки

Підіб’ємо підсумки:

  1. Модуль – це файл. Щоб працював import/export, потрібно для браузерів вказувати атрибут <script type="module">. Модулі мають ряд особливостей:
    • Відкладене (deferred) виконання за замовчуванням.
    • Атрибут async працює у вбудованих скриптах.
    • Для завантаження зовнішніх модулів з іншого джерела повинні бути встановлені заголовки CORS.
    • Зовнішні скрипти, що дублюються, ігноруються.
  2. Модулі мають свою область видимості, обмінюватися функціональністю можна через import/export.
  3. Директива use strict завжди ввімкнена в модулі.
  4. Код у модулях виконується лише один раз. Функціональність, що експортується, створюється один раз і передається всім імпортерам.

Коли ми використовуємо модулі, кожен модуль реалізує свою функціональність та експортує її. Потім ми використовуємо import, щоб безпосередньо імпортувати її туди, куди потрібно. Браузер завантажує та аналізує скрипти автоматично.

У реальному житті часто використовується збирач Webpack, щоб поєднати модулі: для продуктивності та інших «плюшок».

У наступному розділі ми побачимо більше прикладів та варіантів імпорту/експорту.

Навчальна карта