Модули, import/export

Используем функции и переменные из других файлов.

Время чтения: 12 мин

Кратко

Скопировано

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

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

Как понять

Скопировано

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

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

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

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

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

Скопировано

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

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

Модули в JavaScript

Скопировано

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

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

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

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

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

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))    }  }})
          // Определение модуля-объекта с данными
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 — это модульная система, которая пришла вместе с Node.js.

Любой .js файл может рассматриваться как модуль.

Для экспорта из модуля применяется ключевое слово exports. Например:

        
          
          // module1.jsfunction getName(fullname) {  return fullname.firstName}exports.getName = getName
          // module1.js

function getName(fullname) {
  return fullname.firstName
}

exports.getName = getName

        
        
          
        
      

Также для экспорта можно использовать module.exports:

        
          
          // module2.jsfunction showName() {  return 'js'}function calc(a) {  return a*2}module.exports = { showName, calc }
          // module2.js
function showName() {
  return 'js'
}

function calc(a) {
  return a*2
}

module.exports = { showName, calc }

        
        
          
        
      

Оба варианта равнозначны. Такой способ экспорта называется именованным.

Для импорта используется ключевое слово require.

        
          
          // main.jsconst { getName } = require('./module1.js')const { showName, calc } = require('./module2.js')
          // main.js
const { getName } = require('./module1.js')
const { showName, calc } = require('./module2.js')

        
        
          
        
      

Если модуль экспортирует только одну сущность, можно использовать экспорт по умолчанию. В этом случае при импорте не требуется деструктуризация импортируемого объекта.

        
          
          // person.jsfunction Person(id) {  this.id = id}Person.prototype.setName = function(name) {  this.name = name}module.exports = Person// main.jsconst Person = require('./person.js')
          // person.js
function Person(id) {
  this.id = id
}

Person.prototype.setName = function(name) {
  this.name = name
}

module.exports = Person

// main.js
const Person = require('./person.js')

        
        
          
        
      

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

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

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

Скопировано

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

В ES-модулях для экспорта используется ключевое слово export, а для импорта — import. При добавлении ключевого слова export выражение становится экспортированным. Экспортировать можно не только функции, но и константы. Также мы можем получить доступ к нужной функциональности в другом модуле через импорт или импортировать константы. Обратите внимание что, перечисляя названия при импорте через запятую, можно в одном импорте получить доступ сразу к нескольким переменным или функциям.

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

        
          
          // module1.js// Экспортированное выражениеexport function sum(a, b) {  return a + b}// Экспорт константexport const SOME_SETTINGS_FLAG = falseexport const user = {}export const books = ['Война и мир', 'Мастер и Маргарита']// module2.js// Доступ к функциональности из первого модуляimport { sum } from './module1.js'// Импорт константimport { user, books } from './module1.js'// Изменение имени функции или переменной, которую импортируем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 }
          // module1.js

// Экспортированное выражение
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'

// Изменение имени функции или переменной, которую импортируем
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'
          // sum.js

// Экспорт безымянной функции по умолчанию
export default function (a, b) {
  return a + b
}

// other-module.js

// Импорт функциональности в другом модуле
import sum from './sum.js'

// Сразу используем другое имя
import superCoolSummator from './sum.js'

        
        
          
        
      

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

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

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

Скопировано

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

Скопировано

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

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

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

Скопировано

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

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

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

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

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

Скопировано

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

        
          
          // module1.jsexport const user = { name: 'Alex' }console.log(user.name)// module2.jsimport { user } from './module1.js'// Выведет 'Alex'import { user } from './module1.js'// Не выведет ничего
          // module1.js
export const user = { name: 'Alex' }
console.log(user.name)

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

        
        
          
        
      

Из-за этого же может получиться, что объект из одного модуля может меняться другими. Если в первом модуле удалим поле, а в следующем попытаемся вывести его, оно не будет определено. Чтобы избежать такой ситуации, лучше пользоваться фабриками для создания объектов. В примере это функция createUser, которая создаёт однотипные объекты. При использовании этой функции мы каждый раз будем создавать новый объект, таким образом обезопасив себя от возможного изменения объекта.

        
          
          // module1.jsexport const user = { name: 'Alex' }// module2.jsimport { user } from './module1.js'console.log(user.name)// 'Alex'// Удаляем полеdelete user.name// module3.jsimport { user } from './module1.js'// Пытаемся вывести удалённое полеconsole.log(user.name)// 'undefined'// module1.js, фабрика для создания объектовexport function createUser() {  return { name: 'Alex' }}// module2.jsimport { createUser } from './module1.js'// Создаём новый объект…const user = createUser()// …и удаляем поле у свежесозданного объектаdelete user.name// module3.jsimport { createUser } from './module1.js'// …из-за чего ошибки в третьем модуле уже не будетconst user = createUser()console.log(user.name)// 'Alex'
          // 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' }
}

// 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>
          <body>
  <script src="module1.js" type="module"></script>
  <script src="module2.js" type="module"></script>
</body>

        
        
          
        
      

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

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

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

Поэтому:

        
          
          <!-- Загрузится и выполнится --><script type="module" src="./user.js"></script><!--  Не станет загружаться и выполняться,  потому что вызов уже был объявлен выше--><script type="module" src="./user.js"></script>
          <!-- Загрузится и выполнится -->
<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'
          // Неправильно
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'
          import { user } from '../domain/models/user/user.js'

        
        
          
        
      

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

        
          
          domain/  models/    user/      user.js
          domain/
  models/
    user/
      user.js

        
        
          
        
      

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

        
          
          domain/  models/    user/      user.js      index.js
          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.jsimport { user } from './domain/models/user'
          /* 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индексный файл, он может быть опущен в пути до модуля. Таким образом мы можем оставить в импорте только путь до папки с этим модулем, дальше за нас всё сделает Node.js или сборщик.

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

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

        
        
          
        
      

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

        
          
          /* domain/models/index.js */export * from './user'/* domain/index.js */export * from './models'/* other-module.js */// Изменившаяся строчка с импортом из предыдущего примераimport { user } from '../domain'
          /* domain/models/index.js */
export * from './user'

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

/* other-module.js */

// Изменившаяся строчка с импортом из предыдущего примера
import { user } from '../domain'