Модули в JavaScript
Фронтенд-разработчики каждый день используют модули. Это может быть функция из локального файла или сторонняя библиотека из node_modules. Сегодня я кратко расскажу об основных модульных системах в JavaScript и некоторых нюансах их использования.
Синтаксис систем модулей
В современном JavaScript осталось два основных стандарта модульных систем. Это CommonJS, которая является основной для платформы Node.js, и ESM (ECMAScript 6 модули), которая была принята как стандарт для языка и внесена в спецификацию ES2015.
История развития модульных систем JavaScript хорошо описана в статьях «Эволюция модульного JavaScript» и «Путь JavaScript-модуля».
Если вам хорошо известен весь синтаксис модульных систем ESM и CommonJS, то можно пропустить следующую главу.
ESM-модули
Именованный импорт/экспорт
В случае, когда необходимо экспортировать несколько сущностей из модуля, применяется именованный экспорт. Он выполняется с помощью инструкции export
.
export
можно использовать в момент объявления функции, переменной или класса:
export function counter() { /* ... */ }
export const getCurrentDate = () => { /* ... */ }
export const awesomeValue = 42;
export class User { /* ... */ }
или экспортировать уже объявленный ранее элемент:
function counter() { /* ... */ }
export counter;
Для больших модулей удобнее использовать группированный экспорт, это позволяет наглядно увидеть все экспортируемые сущности внутри модуля:
function counter() { /* ... */ }
const awesomeValue = 42;
export { counter, awesomeValue };
Чтобы импортировать какой-либо метод, необходимо воспользоваться инструкциeй import
, указав интересующие части модуля и путь до него:
import { counter, awesomeValue } from './modulePath/index.js';
counter();
console.log('Response:', awesomeValue);
Импорт/Экспорт по умолчанию
В случае, когда из файла модуля экспортируется только одна сущность, удобнее использовать экспорт по умолчанию. Для этого необходимо добавить default
после инструкции export
:
function counter() { /* ... */ }
export default counter;
Импорт модуля в случае экспорта по умолчанию:
/**
Можно использовать любое имя для импортируемой переменной, в связи с тем,
что отсутствует привязка к наименованию внутри модуля
*/
import rainbowCounter from './modulePath/index.js';
rainbowCounter();
Дополнительные возможности
Переименование. Для изменения имени метода в момент импорта/экспорта существует инструкция as
:
function counter() { /* ... */ }
export counter as rainbowCounter;
Импорт этой функции будет доступен только по новому имени:
import { rainbowCounter } from './modulePath/index.js';
rainbowCounter();
Переименование в момент экспорта:
import { counter as rainbowCounter } from './modulePath/index.js';
rainbowCounter();
Этот синтаксис полезен для случаев, когда имя импортируемой части уже занято. Также можно сократить имя функции/переменной/класса, если она часто используется в файле:
import { debounce } from 'shared';
import { debounce as _debounce } from 'lodash';
import { awesomeFunctionThatYouNeed as _helper } from 'awesome-lib';
Инициализация модуля без импорта его частей. Используется, когда необходимо выполнить импорт модуля для выполнения кода внутри него, но не импортировать какую-либо его часть:
import './modulePath/index.js';
Импорт всего содержимого модуля. Можно импортировать всё содержимое модуля в переменную и обращаться к частям модуля как к свойствам этой переменной:
import * as customName from './modulePath/index.js';
customName.counter();
console.log('Response:', customName.awesomeValue);
Такой синтаксис не рекомендуется использовать, сборщик модулей (например, Webpack) не сможет корректно выполнить tree-shaking при таком использовании.
Реэкспорт. Существует сокращенный синтаксис для реэкспорта модулей. Это бывает полезно, когда нужно собрать модули из разных файлов в одном экспорте:
export { counter, awesomeValue } from './modulePath/index.js';
при таком реэкспорте наименования частей модуля будут сохраняться, но можно изменять их с помощью инструкции as
:
export { counter as _counter , awesomeValue as _awesomeValue } from './modulePath/index.js';
Аналогичным способом можно реэкспортировать значения по умолчанию:
export { default as moduleName } from './modulePath/index.js';
Динамические импорты. Кроме «статических» импортов можно загружать модули ассинхронно, для этого есть специальное выражение import()
. Пример использования:
import('./modulePath/index.js')
.then(moduleObject => { /* ... */ })
.catch( error => { /* ... */ })
Это выражение возвращает promise
, который при успешном завершении возвращает объект со всеми экспортами модуля, а при исключении — ошибку выполнения импорта. В Webpack синтаксис динамических импортов используется для создания отдельных чанков.
Изменение значения экспорта. Стоит обратить внимание, что при многократном присваивании значения экспорта по умолчанию или значения именованного экспорта, новое значение будет перезаписывать предыдущее, и, в конечном итоге, будет экспортировано только последнее значение:
const awesomeValue = 42;
export { awesomeValue as default };
export default function counter() { /* ... */ }
export default class User { /* ... */ }
В примере выше, по умолчанию будет экспортирован класс User
Использование модулей в браузере
Современные браузеры нативно поддерживают модули. Для того, чтобы браузер понимал, что мы экспортируем не просто исполняемый JS-файл, а модуль, необходимо в тэг script
, где импортируется модуль, добавить атрибут type="module"
.
Рассмотрим на примере небольшого проекта.
Структура проекта:
┌─index.html
├─main.js
└─dist
├─ module1.js
└─ module2.js
Файл main.js:
import { counter } from './dist/module1';
import { awesomeValue } from './dist/module2';
counter();
console.log('Response:', awesomeValue);
Импорт модуля внутри index.html:
По атрибуту type="module"
браузер понимает, что экспортирует файл с модулями, и корректно его обработает. Стоит отметить, что пути импорта, указанные в main.js (./dist/module1 и ./dist/module2), будут преобразованы в абсолютные пути относительно текущего расположения, и браузер запросит эти файлы у сервера по адресам /dist/module1 и /dist/module2 соответственно. Практического применения у этой возможности не так много, в основном в проектах используется сборщик (например Webpack), который преобразует ESM-модули в bundle. Однако использование ESM-модулей в браузере может позволить улучшить загрузку страницы за счет разбиения bundle-файлов на маленькие части и постепенной их загрузки.
CommonJS
Экспорт. Для экспорта в CommonJS используются глобальные объекты module
и exports
. Для этого необходимо просто добавить новое поле в объект exports
.
module.exports.counter = function () { /* ... */ }
module.exports.awesomeValue = 42;
module.exports.getCurrentDate = () => {/* ... */}
module.exports.User = class User { /* ... */ }
Для удобства экспорта части фунциональности в глобальной области существует переменная exports
, которая является ссылкой на module.exports
. Поэтому возможен и такой синтаксис экспорта:
exports.counter = function () { /* ... */ }
exports.awesomeValue = 42;
В CommonJS cуществует что-то схожее с импортом по умолчанию, для этого необходимо просто присвоить module.exports
значению экспортируемой функции:
module.exports = function () { /* ... */ }
Сохранение значения в exports
напрямую, в отличие от именованного экспорта, не будет работать:
// Данная функция не будет экспортирована!!!
exports = function () { /* ... */ }
Стоит обратить внимание, что если были экспортированы части модуля, они затрутся и будет экспортировано только последнее значение module.exports
:
exports.counter = function () { /* ... */ }
exports.awesomeValue = 42;
module.exports = {};
// counter и awesomeValue не будут экспортированы
Импорт. Для импорта необходимо воспользоваться конструкцией require()
и указать путь до модуля:
const loadedModule = require('./modulePath/index.js');
loadedModule.counter()
console.log(loadedModule.awesomeValue);
Можно воспользоваться деструктуризацией и получить значение необходимой функции сразу после импорта:
const { counter, awesomeValue } = require('./modulePath/index.js');
counter()
console.log(awesomeValue);
Работа с модулями в Node.js
Поддержка ESM-модулей
До недавнего времени Node.js поддерживал только CommonJS, но с версии 13.2.0 команда разработчиков анонсировала поддержку ESM (с версии 8.5.0 поддержка модулей ECMAScript 6 была скрыта за экспериментальным флагом). Подробно о том, как работать с модулями ECMAScript 6 в Node.js, можно прочитать в анонсе команды разработчиков Node.js.
Поиск модулей
Все относительные пути, начинающиеся c './' или '…/' будут обрабатываться только относительно рабочей папки проекта. Пути с '/' будут обрабатываться как абсолютные пути файловой системы. Для остальных случаев Node.js начинает поиск модулей в папке проекта node_modules (пример: /home/work/projectN/node_modules). В случае, если интересующий модуль не был найден, Node.js поднимается на уровень выше и продолжает свой поиск там. И так до самого верхнего уровня файловой системы. Поиск необходимой библиотеки будет выглядеть следующим образом:
/home/work/projectN/node_modules
/home/work/node_modules
/home/node_modules
/node_modules
Если в папках node_modules не удалось обнаружить искомый модуль, то в запасе у Node.js есть еще места, которые он анализирует в поисках необходимой библиотеки. Это так называемые GLOBAL_FOLDERS
. В них добавляются пути, переданные через переменную окружения NODE_PATH
, и три дополнительных пути, которые существуют всегда:
$HOME/.node_modules
$HOME/.node_libraries
$PREFIX/lib/node
/**
$HOME - домашняя директория пользователя,
$PREFIX - node_prefix. Путь до установленной версии Node.js
*/
При желании можно посмотреть все возможные директории, где Node.js ищет модули из папки проекта, обратившись к методу paths()
внутри require.resolve
.
Дополнительные свойства у module
и require
У module
и require
есть дополнительные свойства, которые могут быть полезны.
module.id
— уникальный идентификатор модуля. Обычно это полностью определенный путь до модуля.
module.children
— объект, содержащий модули, которые импортированы в текущем файле. Ключами объекта являются module.id
:
// Расположение исполняемого файла в файловой системе /home/work/projectN
const { counter, awesomeValue } = require('./modulePath/index.js');
console.log(module.children);
// { '/home/work/projectN/modulePath/index.js': }
require.cache
— представляет из себя объект с информацией о каждом импортированном модуле. Если при импорте модуля Node.js находит его в кеше, код модуля не будет выполняться повторно, а экспортируемые сущности будут взяты из закешированного значения. При необходимости повторного «чистого» импорта модуля необходимо сбросить закешированное значение, удалив его из кеша:
delete require.cache['./modulePath/index.js'];
Что происходит в момент импорта ES-модуля
В момент выполнения файла Javascript-движок выполняет несколько этапов загрузки модулей:
- построение графа зависимостей;
- оценка расположения модулей и загрузка файлов;
- анализ модулей;
- запись информации о модулях и создание полей всех экспортируемых значений (без их состояний);
- выполнение сценария модулей для получение состояний;
- запись состояний экспортируемых частей модулей.
Структура данных, содержащая информацию о модуле (уникальный идентификатор, список зависимостей и состояния всех экспортируемых значений) называется Module Records.
При выполнении скрипта строится граф зависимостей и создается запись по каждому импортируемому модулю внутри него. В момент каждого импорта, вызывается метод Evaluate()
внутри модуля Module Records. При первом вызове этой функции выполняется сценарий для получения и сохранения состояния модуля. Подробнее об этом процессе можно прочитать в статье «Глубокое погружение в ES-модули в картинках».
Что происходит при повторном импорте модуля
В предыдущей главе мы упомянули метод Evaluate()
. При очередном импорте модуля Evaluate()
вызывается повторно, но если импорт модуля был успешно выполнен до этого, то метод возвращает undefined
и сценарий модуля запущен не будет. Поэтому запись состояния модуля происходит единожды.
Но остался открытым вопрос, создаётся ли новая сущность Module Records при повторном импорте? Например в данном случае:
import { counter } from './modulePath';
import { counter } from './modulePath';
За получение Module Records для каждого import
отвечает метод HostResolveImportedModule, который принимает два аргумента:
referencingScriptOrModule
— идентификатор текущего модуля, откуда происходит импорт;specifier
— идентификатор импортируемого модуля, в данном случае./modulePath
.
В спецификации говорится, что для одинаковых парах значений referencingScriptOrModule
и specifier
возвращается один и тот же экземпляр Module Records.
Рассмотрим еще один пример, когда один и тот же модуль импортируется в нескольких файлах:
/** main.js */
import moduleA from './moduleA.js'
import moduleB from './moduleB.js'
/** moduleB.js */
import moduleA from './moduleA.js
Будут ли здесь создаваться дублирующие Module Records для moduleB.js
? Для этого обратимся к спецификации:
Multiple different referencingScriptOrModule, specifier pairs may map to the same Module Record instance. The actual mapping semantic is host-defined but typically a normalization process is applied to specifier as part of the mapping process. A typical normalization process would include actions such as alphabetic case folding and expansion of relative and abbreviated path specifiers
Таким образом, даже если referencingScriptOrModule
отличается, а specifier
одинаков, может быть возвращен одинаковый экземпляр Module Records.
Однако этой унификации не будут подвержены импорты с дополнительными параметрами в specifier
:
import moduleA from './moduleA.js?q=1111'
import _moduleA from './moduleА.js?q=1234'
console.log(moduleA !== _moduleA) // true
Циклические зависимости
При большой вложенности модулей друг в друга может возникнуть циклическая зависимость:
ModuleA -> ModuleB -> ModuleC -> ModuleD -> ModuleA
Для наглядности, эту цепочку зависимостей можно упростить до:
ModuleA <-> ModuleD
ES-модули нативно умеют работать с циклическими зависимостями и корректно их обрабатывать. Принцип работы подробно описан в спецификации. Однако, ESM редко используются без обработки. Обычно с помощью транспилятор (Babel) сборщик модулей (например, Webpack) преобразует их в CommonJS для запуска на Node.js, или в исполнямый скрипт (bundle) для браузера. Циклические зависимости не всегда могут быть источником явных ошибок и исключений, но могут стать причиной некорректного поведения кода, которое трудно будет отловить.
Есть несколько хаков, как можно обходить циклические зависимости для некоторые ситуаций, но лучше просто не допускать их возниковения.
Заключение
В этой статье я собрал всю основную информацию о модульных системах в Javascript, чтобы у читателя не осталось пробелов относительно того, как их использовать и как они работают. Надеюсь, у меня это получилось, и статья оказалась вам полезной. Буду рад обратной связи!