Pull to refresh
133.84
Домклик
Место силы

Модули в JavaScript

Reading time 9 min
Views 53K


Фронтенд-разработчики каждый день используют модули. Это может быть функция из локального файла или сторонняя библиотека из 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, чтобы у читателя не осталось пробелов относительно того, как их использовать и как они работают. Надеюсь, у меня это получилось, и статья оказалась вам полезной. Буду рад обратной связи!


Полезные ссылки


Tags:
Hubs:
+52
Comments 11
Comments Comments 11

Articles

Information

Website
domclick.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия
Representative
Евгения Макарова