Модули в 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() { /* ... */ } 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 синтаксис динамических импортов используется для создания отдельных чанков.
Использование модулей в браузере
Современные браузеры нативно поддерживают модули. Для того, чтобы браузер понимал, что мы экспортируем не просто исполняемый 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:
<script type="module" src="main.js"></script>
По атрибуту 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': <Module> }
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, чтобы у читателя не осталось пробелов относительно того, как их использовать и как они работают. Надеюсь, у меня это получилось, и статья оказалась вам полезной. Буду рад обратной связи!
Источник: habr.com