16 липня 2023 р.

Експорт та імпорт

Директиви експорту та імпорту мають декілька варіантів синтаксису.

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

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

Будь-яке оголошення змінної, функції чи класу можна позначати попереду оператором export.

Наприклад, всі наступні експорти валідні:

// експорт масиву
export let months = ['Січ', 'Лют', 'Бер','Квіт', 'Серп', 'Вер', 'Жов', 'Лист', 'Груд'];

// експорт константи
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(`Привіт, ${user}!`);
}  // знак ; відсутній вкінці

Експорт поза оголошенням

Також export можна використовувати окремо.

В прикладі ми спочатку оголошуємо функцію, а потім експортуємо:

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

function sayBye(user) {
  alert(`Бувай, ${user}!`);
}

export {sayHi, sayBye}; // список експортованих змінних

…Чи, технічно, ми можемо використати export вище оголошення функції.

Імпорт *

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

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

sayHi('Іван'); // Привіт, Іван!
sayBye('Іван'); // Бувай, Іван!

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

// 📁 main.js
import * as say from './say.js';

say.sayHi('Іван');
say.sayBye('Іван');

З першого погляду, “імпортувати все” виглядає цікавим та зручним у використанні, тоді навіщо нам явно виписувати список того, що потрібно імпортувати?

На це є декілька причин.

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

Сучасні інструменти збірки, такі як webpack та інші, об’єднують модулі разом і оптимізують їх для прискорення завантаження. Вони також видаляють імпорти, що не використовуються.

Наприклад, якщо ви import * as library з величезної бібліотеки коду, а потім використаєте лише кілька методів, тоді невикористані методи не будуть включені у оптимізований бандл.

Імпорт “as”

Для імпорту під іншим іменем можна використовувати as.

Наприклад, для спрощення імпортуймо sayHi в локальну змінну hi та sayBye як bye:

// 📁 main.js
import {sayHi as hi, sayBye as bye} from './say.js';

hi('Іван'); // Привіт, Іван!
bye('Іван'); // Бувай, Іван!

Експорт “as”

Подібний синтаксис існує і для export.

Експортуймо функцію як hi та bye:

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

Тепер hi та bye будуть використовуватися зовнішніми модулями при імпорті:

// 📁 main.js
import * as say from './say.js';

say.hi('Іван'); // Привіт, Іван!
say.bye('Іван'); // Бувай, Іван!

Типовий експорт

На практиці існує два головних типи модулів.

  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');

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

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

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

Оскільки, тільки одна сутність може бути типово експортованою, вона може не мати імені.

В прикладі правильно використано типові експорти:

export default class { // відсутнє ім’я класу
  constructor() { ... }
}
export default function(user) { // відсутнє ім’я функції
  alert(`Привіт, ${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(`Привіт, ${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(`Привіт, ${user}!`);
}

Для імпорту типової сутності та декількох іменованих потрібно:

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

new User('Іван');

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

// 📁 main.js
import * as user from './user.js';

let User = user.default; // типовий імпорт
new User('Іван');

Аргументи проти типових імпортів

Іменовані імпорти є явними. Нам потрібно точно перелічити все, що імпортуємо – це є перевагою.

Іменовані експорти змушують нас використовувати точне ім’я сутності для імпорту:

import {User} from './user.js';
// import {MyUser} не спрацює, оскільки ім’я повинно бути {User}

…В той час, як для типового імпорту нам завжди потрібно обрати ім’я:

import User from './user.js'; // спрацює
import MyUser from './user.js'; // спрацює теж
// можна навіть import Anything... і це все одно ще спрацює

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

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

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:

// 📁 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';
...

Суттєвою різницею між export ... from та import/export є недоступність реекспортованих модулів всередині поточного файлу. Тому в прикладі вище всередині файлу auth/index.js ми не зможемо використати реекспортовані функції login/logout.

Реекспорт типового експорту

Для реекспорту типового експорту потрібна спеціальна обробка.

Скажімо, у нас є user.js з якого ми хочемо реекспортувати клас User:

// 📁 user.js
export default class User {
  // ...
}

У нас може виникнути дві проблеми:

  1. export User from './user.js' не спрацює. Це призведе до синтаксичної помилки.

    Для типового реекспорту нам потрібно використати export {default as User}, як в прикладі вище.

  2. export * from './user.js' реекспортує тільки іменовані експорти і проігнорує типові.

    Якщо ми хочемо реекспортувати як іменовані експорти, так і типові, тоді нам потрібні дві інструкції:

    export * from './user.js'; // для реекспорту іменованих експортів
    export {default} from './user.js'; // для реекспорту типових експортів

Такі дивацтва для реекспорту типових експортів є однією з причин чому деякі розробники недолюблюють типові експорти та надають перевагу іменованим.

Підсумки

Ось всі типи export, що ми розглянули в цій та попередній частинах.

Щоб перевірити себе, ви можете прочитати та згадати, що вони означають:

  • Перед оголошенням класу/функції/…:
    • export [default] class/function/variable ...
  • Окремий експорт:
    • export {x [as y], ...}.
  • Реекспорт:
    • export {x [as y], ...} from "module"
    • export * from "module" (не реекспортує типові експорти).
    • export {default [as y]} from "module" (типовий реекспорт).

Імпорт:

  • Імпорт іменованих експортів:
    • import {x [as y], ...} from "module"
  • Імпорт типових експортів:
    • import x from "module"
    • import {default as x} from "module"
  • Імпортування всього:
    • import * as obj from "module"
  • Тільки імпорт модуля (буде виконано його код) без присвоєння в змінну:
    • import "module"

Інструкції import/export можуть бути розташовані як зверху, так і знизу в скрипті.

Тому, технічно, це правильний код:

sayHi();

// ...

import {sayHi} from './say.js'; // імпорт розташовано в кінці файлу

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

Зверніть увагу, що інструкції імпорту та експорту не спрацюють, якщо розташовані всередині {...}.

Умовний імпорт, як в наступному прикладі, не спрацює:

if (something) {
  import {sayHi} from "./say.js"; // Error: import must be at top level
}

…Що робити, якщо нам потрібно щось імпортувати за певних умов? Або в певний час? Наприклад, завантажити модуль за запитом, коли він дійсно стане потрібним.

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

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