skip to main content

Модули, import/export

js

Кратко 🔗

Каждая программа со временем становится большой. Чем больше в проекте кода, тем сложнее в нём ориентироваться, писать и поддерживать.

Модули помогают организовать код так, чтобы с проектом было проще и удобнее работать, позволяют переиспользовать код и структурировать программу.

Как понять 🔗

В модуле по умолчанию всё скрыто. Наружу предоставляется только то, что разработчики намеренно хотят предоставить — экспорты. Эту функциональность можно импортировать в других модулях и использовать.

Таким образом модуль — это чёрный ящик с одним входом (импорт) и одним выходом (экспорт), через которые этот модуль «общается» с другими.

Модули можно сравнить с функциями: они решают те же проблемы, но в более крупном масштабе. Функции помогают разбивать большую задачу на мелкие, а модули — разбивать большой проект на мелкие части. Функции помогают структурировать выстраивать архитектуру решения конкретной задачи, а модули — проекта целиком.

Польза модулей 🔗

Идея и польза модулей в первую очередь в том, что они помогают справляться со сложностью программы. Если мы работаем с проектом на 20 тысяч строк, будет совершенно неудобно хранить весь код в одном файле:

Без модулейС модулями
Навигация и перемещение по проектуСложно. Чтобы поработать над двумя частями кода, придётся либо открывать файл дважды, либо прыгать между секциями файла.Просто. Модули можно открыть параллельно и работать над ними одновременно.
Охват проекта целикомСложно, зачастую невозможно. Простыня на несколько десятков экранов не даст понять, как проект устроен, оценить его структуру и взаимосвязи между частями программы.Сильно проще. Структура папок и файлов проекта, построенного на модулях, помогает охватить структуру и понять, как проект устроен.
Повторное использование кодаЗатруднено, иногда вовсе исключено. Чтобы переиспользовать код из такой портянки, его необходимо отделить, почистить от лишних зависимостей, проверить его работу отдельно и встроить в новый проект. Иногда проще написать всё с нуля.Просто. Модули можно использовать в нескольких проектах.

Модули в JavaScript 🔗

Изначально модулей в JavaScript не существовало. Считалось, что скрипты, подключаемые к страницам очень простые, и модульная система не нужна.

Время шло, приложения на JS становились всё сложнее, и необходимость модулей становилась очевидной. Тогда появились первые попытки «принести» модули в JS.

Сейчас модули уже появились и поддерживаются, но их «становление» проходило медленно. Существовало несколько версий модульных систем: AMD, CommonJS, UMD и ES-модули.

Современной системой считаются ES-модули. Другие модульные системы считаются устаревшими. Если вас интересуют легаси-системы, то информацию о них вы можете найти в подразделе «Модульные системы, которые использовались до ES-модулей», если нет — то вы можете смело пропустить этот подраздел.

Модульные системы, которые использовались до ES-модулей.

AMD 🔗

AMD (asynchronous module definition) — асинхронное определение модулей, одна из первых попыток создать систему модулей.

Её изначально реализовали в библиотеке Require.js. Вид и определение модулей были довольно многословными:

// define — функция из библиотеки require.js,
// которая позволяла определять модули для использования далее.

// Например, определение модуля-объекта с данными:
define(function() {
return
color: "black",
size: "unisize"
}
});

// Также можно было экспортировать и функции:
define(function() {
return {
sum: function(a, b) {
return a + b
},
}
});

// А чтобы получить доступ к другим модулям,
// от которых зависит текущий,
// можно было определять массив зависимостей:
define(['path/to/module1', 'path/to/module2'],
function(module1, module2) {
return {
someComplicatedLogic: function(arg) {
return module1.doStuff(module2.doMoreStuff(arg));
}
}
});

Но даже несмотря на такую многословность, сейчас в вебе достаточно много легаси, который использует AMD. Уж очень была заманчива идея наконец структурировать код.

CommonJS 🔗

CommonJS — это модульная система, которая пришла вместе с Node.js.

У неё несколько другое определение импортов и экспортов:

const useFullConstant = 42

// Чтобы экспортировать функциональность,
// назвав её каким-то именем,
// используют именованные экспорты:
exports.useFullConstant = useFullConstant

// А чтобы экспортировать что-то из модуля,
// как одну сущность —
// экспорты по умолчанию:
module.exports = useFullConstant

// Для импорта функциональности
// используется require:
const { importedFunction } = require("./other-module.js")

Именованные экспорты и экспорты по умолчанию встречаются и сейчас. Разницу между ними мы подробнее рассмотрим чуть дальше.

UMD 🔗

UMD (Universal Module Definition) — предлагается как универсальная модульная система, совместима и с AMD, и с CommonJS.

ECMAScript или ES-модули 🔗

ES-модули — модульная система на уровне языка, которая появилась в спецификации ES2015. Далее, когда мы будем говорить о модулях, мы будем иметь ввиду именно ES-модули.

В ES-модулях для экспорта используется ключевое слово export, а для импорта — import.

// module1.js

// При добавлении ключевого слова export
// выражение становится экспортированным.
export function sum(a, b) {
return a + b
}

// Экспортировать можно не только функции,
// но и константы:
export const SOME_SETTINGS_FLAG = false
export const user = {}
export const books = ["Война и мир", "Мастер и Маргарита"]

// module2.js

// Таким образом мы можем получить
// доступ к этой функциональности
// в другом модуле через импорт:
import { sum } from "./module1.js"

// Точно так же, мы можем импортировать и константы:
import { user, books } from "./module1.js"

// Обратите внимание, что перечисляя названия
// при импорте через запятую, можно в одном
// импорте получить доступ сразу к нескольким
// переменным или функциям.

// Если вдруг мы хотим изменить имя той функции
// или переменной, которую импортируем,
// мы можем использовать ключевое слово as:
import { user as admin } from "./module1.js"

// Это также работает и со множественным импортом:
import { books as library, SOME_SETTINGS_FLAG as turnedOn } from "./module1.js"

// Экспортировать функциональность можно также
// и уже после того, как она определена:
const user = {}
export { user }

// Это иногда бывает полезно, если мы хотим
// описать все экспорты в конце файла.

// Кроме того, это же помогает изменять названия при экспорте.
// Например, если
const user = {}
export { user as admin }

При использовании ключевого слова export рядом с функцией или переменной, мы экспортируем конкретную функцию или переменную.

У таких переменных и функций обязательно должно быть имя, потому что именно по этому имени мы будем получать доступ к ним из других модулей. Поэтому такие экспорты называются именованными — у экспортируемой функциональности есть имя и по этому же имени мы будем импортировать её в других модулях.

Экспорты по умолчанию 🔗

Существуют также экспорты по умолчанию. Когда мы из модуля экспортируем какую-то функциональность по умолчанию, мы можем опустить имя, но обязаны использовать ключевое слово default после export:

// sum.js

// Функция может не иметь имени,
// потому что используется экспорт по умолчанию.
export default function (a, b) {
return a + b
}

// other-module.js

// При импорте такой функциональности в другом модуле
// нам уже не требуется использовать {}.
import sum from "./sum.js"

// Более того, мы сразу можем использовать другое имя при импорте:
import superCoolSummator from "./sum.js"

Сейчас сообщество считает экспорты по умолчанию менее удачно практикой. В первую очередь потому, что с именованными экспортами проще работать: их проще переименовывать, с ними проще работать автоматизированными средствами рефакторинга.

Мы рекомендуем использовать именованные экспорты вместо экспортов по умолчанию там, где это возможно.

Возможности и ограничения модулей в JavaScript 🔗

Модули — это всегда use strict 🔗

Внутри модулей всегда используется строгий режим. Из-за этого, например, this — это не window, а undefined.

Подробнее о строгом режиме — в статье Контекст выполнения функций, this

Переменные изолированы внутри 🔗

Модули не видят «внутренностей» других модулей. Чтобы делиться какой-то функциональностью мы можем использовать либо импорты и экспорты, либо глобальные объекты типа window, global и т. д.

Использование глобальных объектов не рекомендуется. Это засоряет глобальную область видимости и может приводить к неожиданным результатам.

Для простоты в этом плане можно тоже сравнить модуль с функцией. Доступа к локальным переменным функции ни у кого нет — они доступны только внутри этой функции. Так же и с модулем — доступ к переменным и функциональности этого модуля есть только у этого модуля до тех пор, пока эта функциональность явно не экспортируется наружу.

Это не только избавляет от проблемы с именами (когда они могут оказаться одинаковыми), но и позволяет не беспокоиться о том, что другому модулю будет доступно «что-то лишнее».

Код модуля выполняется лишь раз 🔗

Код модуля выполняется единожды при импорте. Поэтому создание каких-то объектов (без использования фабрик) будет выполнено всего лишь раз:

// module1.js
export const user = { name: "Alex" }
console.log(user.name)

// module2.js
import { user } from "./module1.js" // Выведет 'Alex'.
import { user } from "./module1.js" // Не выведет ничего.

Из-за этого же может получиться, что объект и одного модуля может меняться другими.

// module1.js
export const user = { name: "Alex" }

// module2.js
import { user } from "./module1.js"
console.log(user.name) // 'Alex'

// Если в этом модуле мы удалим поле:::
delete user.name

// module3.js
import { user } from "./module1.js"

// ...А в следующем попытаемся вывести его,
// оно будет неопределено.
console.log(user.name) // undefined'

// Чтобы избежать такой ситуации,
// лучше пользоваться фабриками для создания объектов,
// например:

// module1.js
export function createUser() {
return { name: "Alex" }
}

// createUser — это функция,
// которая создаёт однотипные объекты,
// то есть — фабрика.

// При использовании этой функции
// мы каждый раз будем создавать новый объект,
// таким образом обезопасив себя от возможного
// изменения объекта.

// module2.js
import { createUser } from "./module1.js"

// В этот раз мы создаём новый объект:
const user = createUser()
// ...и удаляем поле у свежесзданного объекта:
delete user.name

// module3.js
import { createUser } from "./module1.js"

// ...из-за чего ошибки в третьем модуле уже не будет.
const user = createUser()
console.log(user.name) // 'Alex'

Особенности в браузере 🔗

В браузере модули работают через подключение скриптов с атрибутом type='module':

<body>
<script src="module1.js" type="module"></script>
<script src="module2.js" type="module"></script>
</body>

У работы модулей в браузере есть некоторые особенности.

1. Такие скрипты всегда будут отложенными.

Это значит, что загрузка модулей не будет блокировать отрисовку страницы, но их выполнение начнётся только после того, как документ загрузится полностью. Кроме этого сохранится порядок выполнения.

В примере выше — выполнится вначале module1.js, а только потом module2.js.

2. Внешние скрипты с type='module' загрузятся и выполнятся только один раз.

Поэтому:

<!-- Загрузится и выполнится: -->
<script type="module" src="./user.js"></script>

<!--Не станет загружаться и выполняться,
потому что вызов уже был объявлен выше -->

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

3. Должен быть прописан путь до файла.

То есть:

import user from "user" // Неправильно...

// Должен быть либо абсолютный путь:
import user from "https://some-site.com/js/user.js"

// ...Либо относительный:
import user from "./user.js"

Модули и сборка 🔗

В браузере модули сами по себе используются пока редко. Сейчас чаще используются инструменты сборки типа Gulp, Webpack, Parcel, Rollup и другие.

Код, использующий импорты и экспорты, или использующий скрипты с type="module", «прогоняется» через этот инструмент, соединяется в бандлы, минифицируется и уже в таком виде отправляется в продакшен.

В итоге мы получаем скрипты, готовые для использования в большем количестве браузеров, при этом польза модулей в организации кодовой базы и структуризации проекта сохраняется.

В работе 🔗

Избегайте экспортов по умолчанию 🔗

Старайтесь использовать именованные экспорты везде, где это возможно. Это упростит рефакторинг кода и работу в целом.

(Некоторые фреймворки могут требовать экспортов по умолчанию, например, так делает Next.js. В таких случаях — не остаётся другого выхода.)

Используйте реэкспорты 🔗

Чтобы пути импортов не были слишком длинными:

import { user } from "../domain/models/user/user.js"

... можно использовать реэкспорты — когда мы внутри файла импортируем функциональность из одного модуля и сразу же экспортируем её из него.

/**
Например у нас есть структура проекта:

domain/
models/
user/
user.js

Чтобы сократить путь до модуля user.js,
мы можем использовать ре-экспорт на уровне user/

domain/
models/
user/
user.js
index.js
*/


// domain/models/user/index.js
import { user } from "./user.js"
export { user }

// Или же сразу:
export { user } from "./user.js"

// other-module.js
// Тогда при импорте user.js мы можем прописать такой путь:
import { user } from "./domain/models/user"

// Так как index.js — [индексный файл](https://nodejs.org/api/modules.html#modules_folders_as_modules),
// он может быть опущен в пути до модуля.
// Таким образом мы можем оставить в импорте
// только путь до папки с этим модулем,
// дальше за нас всё сделает Node.js или сборщик.

/**
Так же мы можем и упростить всю структуру:

domain/
index.js
models/
index.js
user/
user.js
index.js
*/


// domain/models/index.js
export * from "./user"

// Эта запись означает, что мы хотим
// ре-экспортировать из модуля user
// всё, что он экспортирует сам.

// domain/index.js
export * from "./models"

// other-module.js
// Тогда наш первый пример превратится в:
import { user } from "../domain"