ProgIngContrSystems

Матеріали дисципліни "Програмна інженерія в системах управління"

До розділу

Модулі

https://learn.javascript.ru/modules

Про модулі?

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

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

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

// 📁 sayHi.js
export function sayHi(user) {
  alert(`Hello, ${user}!`);
}

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

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

alert(sayHi); // function...
sayHi('John'); // Hello, John!

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

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

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

say.js

export function sayHi(user) {
  return `Hello, ${user}!`;
}

index.html

<!doctype html>
<script type="module">
  import {sayHi} from './say.js';
  document.body.innerHTML = sayHi('John');
</script>

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

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

Чим відрізняються модулі від «звичайних» скриптів? Є основні можливості та особливості, що працюють як в браузері, так і в серверному JavaScript.

use strict

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

<script type="module">
  a = 5; // ошибка
</script>

область видимості

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

У наступному прикладі імпортовані 2 скрипта, і hello.js намагається використовувати змінну user, оголошену в user.js. В результаті буде помилка:

hello.js

alert(user); // в этом модуле нет такой переменной (каждый модуль имеет независимые переменные)

user.js

let user = "John";

index.html

<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>

Модулі повинні експортувати функціональність, призначену для використання ззовні. А інші модулі можуть її імпортувати. Так що нам треба імпортувати user.js в ` hello.js` і взяти з нього потрібну функціональність, замість того щоб покладатися на глобальні змінні.

Правильний варіант буде наступний:

hello.js

import {user} from './user.js';
document.body.innerHTML = user; // John

user.js

export let user = "John";

index.html

<!doctype html>
<script type="module" src="hello.js"></script>

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

<script type="module">
  // Переменная доступна только в этом модуле
  let user = "John";
</script>

<script type="module">
  alert(user); // Error: user is not defined
</script>

Якщо нам потрібно зробити глобальну змінну рівня всієї сторінки, можна явно привласнити її об’єкту window, тоді отримати значення змінної можна звернувшись до ` window.user`. Але це повинно бути винятком, що вимагає вагомої причини.

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

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

// 📁 alert.js
alert("Модуль выполнен!");
// Импорт одного и того же модуля в разных файлах
// 📁 1.js
import `./alert.js`; // Модуль выполнен!
// 📁 2.js
import `./alert.js`; // (ничего не покажет)

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

// 📁 admin.js
export let admin = {
  name: "John"
};

Якщо модуль імпортується в декількох файлах, то код модуля буде виконаний тільки один раз, об’єкт 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

Ще раз зауважимо - модуль виконується тільки один раз. Генерується експорт і після передається всім імпортерам, тому, якщо щось зміниться в об’єкті admin, то інші модулі теж побачать ці зміни. Така поведінка дозволяє конфігурувати модулі при початковому імпорті. Ми можемо встановити його властивості один раз, і в подальших імпортах він буде вже налаштованим. Наприклад, модуль admin.js надає певну функціональність, але очікує передачі облікових даних в об’єкт admin ззовні:

// 📁 admin.js
export let admin = { };
export function sayHi() {
  alert(`Ready to serve, ${admin.name}!`);
}

В init.js, першому скрипті нашого застосування, ми встановимо admin.name. Тоді всі це побачать, включаючи виклики, зроблені з самого admin.js:

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

Інший модуль теж побачить admin.name:

// 📁 other.js
import {admin, sayHi} from './admin.js';
alert(admin.name); // Pete
sayHi(); // Ready to serve, Pete!

import.meta

Об’єкт import.meta містить інформацію про поточний модулі. Вміст залежить від оточення. У браузері він містить посилання на скрипт або посилання на поточну веб-сторінку, якщо модуль вбудований в HTML:

<script type="module">
  alert(import.meta.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-сторінку, включаючи елементи під ними. Наприклад:

<script type="module">
  alert(typeof button); // object: скрипт может 'видеть' кнопку под ним
  // так как модули являются отложенными, то скрипт начнёт выполнятся только после полной загрузки страницы
</script>
<!-- Сравните с обычным скриптом ниже: -->
<script>
  alert(typeof button); // Ошибка: кнопка не определена, скрипт не видит элементы под ним
  // обычные скрипты запускаются сразу, не дожидаясь полной загрузки страницы
</script>
<button id="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>
  1. Зовнішній скрипт, який завантажується з іншого домену, вимагає вказівки заголовків CORS. Іншими словами, якщо модульний скрипт завантажується з іншого домену, то віддалений сервер повинен встановити заголовок 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 неправильний:

import {sayHi} from 'sayHi'; // Ошибка, "голый" модуль
// путь должен быть, например './sayHi.js' или абсолютный

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

Сумісність «nomodule»

Старі браузери не розуміють атрибут type="module". Скрипти з невідомим атрибутом type просто ігноруються. Ми можемо зробити для них «резервний» скрипт за допомогою атрибута 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>

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

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

Експорт до оголошення

https://learn.javascript.ru/import-export

Ми можемо помітити будь-яке оголошення як експортоване, розмістивши export перед ним, будь то змінна, функція або клас. Наприклад, всі наступні експорти допустимі:

// экспорт массива
export let months = ['Jan', 'Feb', 'Mar', 'Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// экспорт константы
export const MODULES_BECAME_STANDARD_YEAR = 2015;
// экспорт класса
export class User {
  constructor(name) {
    this.name = name;
  }
}

Не ставиться крапка з комою після експорту класу/функції.

Зверніть увагу, що export перед класом або функцією не робить їх функціональним виразом. Це все також оголошення функції, хоча і експортоване. Більшість посібників по стилю коду в JavaScript не рекомендують ставити крапку з комою після оголошень функцій або класів. Тому в кінці export class і ` export function` не потрібна крапка з комою:

export function sayHi(user) {
  alert(`Hello, ${user}!`);
}  // без ; в конце

Експорт окремо від оголошення

Також можна написати export окремо. Тут ми спочатку оголошуємо, а потім експортуємо:

// 📁 say.js
function sayHi(user) {
  alert(`Hello, ${user}!`);
}
function sayBye(user) {
  alert(`Bye, ${user}!`);
}
export {sayHi, sayBye}; // список экспортируемых переменных

… Або, технічно, ми також можемо розташувати export вище функцій.

Імпорт *

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

// 📁 main.js
import {sayHi, sayBye} from './say.js';
sayHi('John'); // Hello, John!
sayBye('John'); // Bye, John!

Але якщо імпортувати потрібно багато чого, ми можемо імпортувати все відразу у вигляді об’єкта, використовуючи import * as <obj>. наприклад:

// 📁 main.js
import * as say from './say.js';
say.sayHi('John');
say.sayBye('John');

На перший погляд «імпортувати все» виглядає дуже зручно, не треба писати зайвого, навіщо нам взагалі може знадобитися явно перераховувати список того, що потрібно імпортувати?

Для этого есть несколько причин.

1) Сучасні інструменти збірки (webpack та інші) збирають модулі разом і оптимізують їх, прискорюючи завантаження і видаляючи невикористаний код. Припустимо, ми додали в наш проект сторонню бібліотеку say.js з безліччю функцій:

// 📁 say.js
export function sayHi() { ... }
export function sayBye() { ... }
export function becomeSilent() { ... }

Тепер, якщо з цієї бібліотеки в проекті ми використовуємо тільки одну функцію:

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

… Тоді оптимізатор побачить, що інші функції не використовуються, і видалить інші із зібраного коду, тим самим роблячи код менше. Це називається «tree-shaking».

2) Явно перераховуючи те, що хочемо імпортувати, ми отримуємо більш короткі імена функцій: sayHi() замість say.sayHi(). 3) Явна перерахування імпорту робить код більш зрозумілим, дозволяє побачити, що саме і де використовується. Це спрощує підтримку і рефакторинг коду.

Імпорт «як»

Ми також можемо використовувати as, щоб імпортувати під іншими іменами. Наприклад, для стислості імпортуємо sayHi в локальну змінну ` hi, а sayBye імпортуємо як bye`:

// 📁 main.js
import {sayHi as hi, sayBye as bye} from './say.js';
hi('John'); // Hello, John!
bye('John'); // Bye, John!

Експортувати «як»

Аналогічний синтаксис існує і для export. Давайте експортуємо функції, як hi і bye:

// 📁 say.js
...
export {sayHi as hi, sayBye as bye};

Тепер hi і bye - офіційні імена для зовнішнього коду, їх потрібно використовувати при імпорті:

// 📁 main.js
import * as say from './say.js';
say.hi('John'); // Hello, John!
say.bye('John'); // Bye, John!

Експорт за замовченням

На практиці модулі зустрічаються в основному одного з двох типів:

  1. Модуль, що містить бібліотеку або набір функцій, як say.js вище.
  2. Модуль, який оголошує щось одне, наприклад модуль user.js експортує тільки ` class User`.

Здебільшого, зручніше другий підхід, коли кожна «річ» знаходиться в своєму власному модулі. Природно, потрібно багато файлів, якщо для всього робити окремий модуль, але це не проблема. Так навіть зручніше: навігація по проекту стає простіше, особливо, якщо у файлів хороші імена, і вони структуровані по папках. Модулі надають спеціальний синтаксис export default («експорт за замовчуванням») для другого підходу. Ставимо export default перед тим, що потрібно експортувати:

// 📁 user.js
export default class User { // просто добавьте "default"
  constructor(name) {
    this.name = name;
  }
}

Зауважимо, у файлі може бути не більше одного export default. … І потім імпортуємо без фігурних дужок:

// 📁 main.js
import User from './user.js'; // не {User}, просто User
new User('John');

Імпорт без фігурних дужок виглядає красивіше. Звичайна помилка початківців: забувати про фігурні дужки. Запам’ятаємо: фігурні дужки необхідні в разі іменованих експортом, для export default вони не потрібні.

Іменований експорт Експорт за замовченням
export class User {...} export default class User {...}
import {User} from ... import User from ...

Технічно в одному модулі може бути як експорт за замовчуванням, так і іменовані експорти, але на практиці зазвичай їх не змішують. Тобто, в модулі знаходяться або іменовані експорти, або один експорт за замовчуванням. Так як в файлі може бути максимум один export default, то експортована сутність не зобов’язана мати ім’я. Наприклад, все це - повністю коректні експорти за замовчуванням:

export default class { // у класса нет имени
  constructor() { ... }
}
export default function(user) { // у функции нет имени
  alert(`Hello, ${user}!`);
}
// экспортируем значение, не создавая переменную
export default ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

Це нормально, тому що може бути тільки один export default на файл, так що ` import без фігурних дужок завжди знає, що імпортувати. Без default` такий експорт видав би помилку:

export class { // Ошибка! (необходимо имя, если это не экспорт по умолчанию)
  constructor() {}
}

Ім’я «default»

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

function sayHi(user) {
  alert(`Hello, ${user}!`);
}
// то же самое, как если бы мы добавили "export default" перед функцией
export {sayHi as default};

Або, ще ситуація, давайте уявимо наступне: модуль user.js експортує одну сутність «за замовчуванням» і кілька іменованих (рідкісний, але можливий випадок):

// 📁 user.js
export default class User {
  constructor(name) {
    this.name = name;
  }
}
export function sayHi(user) {
  alert(`Hello, ${user}!`);
}

Ось як імпортувати експорт за замовчуванням разом з іменованих експортом:

// 📁 main.js
import {default as User, sayHi} from './user.js';
new User('John');

І, нарешті, якщо ми імпортуємо все як об’єкт import *, тоді його властивість default - якраз і буде експортом за замовчуванням:

// 📁 main.js
import * as user from './user.js';
let User = user.default; // экспорт по умолчанию
new User('John');

Довід проти експортом за замовчуванням

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

import {User} from './user.js';
// import {MyUser} не сработает, должно быть именно имя {User}

… У той час як для експорту за замовчуванням ми вибираємо будь-яке ім’я при імпорті:

import User from './user.js'; // сработает
import MyUser from './user.js'; // тоже сработает
// можно импортировать с любым именем, и это будет работать

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

import User from './user.js';
import LoginForm from './loginForm.js';
import func from '/path/to/func.js';
...

Проте, в деяких командах це вважають серйозним аргументом проти експорту за замовчуванням і вважають за краще використовувати іменовані експорти всюди. Навіть якщо експортується тільки одна річ, вона все одно експортується з ім’ям, без використання default. Це також трохи спрощує реекспорт (дивіться нижче).

Реекспорт

Синтаксис «реекспорту» export ... from ... дозволяє імпортувати щось і тут же експортувати, можливо під іншим ім’ям, ось так:

export {sayHi} from './say.js'; // реэкспортировать sayHi
export {default as User} from './user.js'; // реэкспортировать default

Навіщо це потрібно? Розглянемо практичний приклад використання. Уявімо, що ми пишемо «пакет»: папку з безліччю модулів, з якої частина функціональності експортується назовні (інструменти на зразок NPM дозволяють нам публікувати і поширювати такі пакети), а багато модулів - просто допоміжні, для внутрішнього використання в інших модулях пакета. Структура файлів може бути такою:


auth/
    index.js
    user.js
    helpers.js
    tests/
        login.js
    providers/
        github.js
        facebook.js
        ...

Ми б хотіли зробити функціональність нашого пакета доступною через єдину точку входу: «головний файл» auth/index.js. Щоб можна було використовувати її в такий спосіб:

import {login, logout} from 'auth/index.js'

Ідея в тому, що зовнішні розробники, які будуть використовувати наш пакет, не повинні розбиратися з його внутрішньою структурою, ритися в файлах всередині нашого пакета. Все, що потрібно, ми експортуємо в auth/index.js, а решта приховуємо від зацікавлених поглядів. Так як потрібна функціональність може бути розкидана по модулях нашого пакета, ми можемо імпортувати їх в auth/index.js і тут же експортувати назовні.

// 📁 auth/index.js
// импортировать login/logout и тут же экспортировать
import {login, logout} from './helpers.js';
export {login, logout};
// импортировать экспорт по умолчанию как User и тут же экспортировать
import User from './user.js';
export {User};
...

Тепер користувачі нашого пакета можуть писати import {login} from" auth/index.js ". Запис export ... from ... - це просто більш короткий варіант такого імпорту-експорту:

// 📁 auth/index.js
// импортировать login/logout и тут же экспортировать
export {login, logout} from './helpers.js';
// импортировать экспорт по умолчанию как User и тут же экспортировать
export {default as User} from './user.js';
...

Реекспорт експорту за замовчуванням

https://learn.javascript.ru/import-export#reeksport-eksporta-po-umolchaniyu

Динамические импорты

https://learn.javascript.ru/modules-dynamic-imports