JavaScript: Полное руководство по модулям в браузере и Node.js
В большинстве языков программирования есть концепция модулей: способ определить функции в одном файле, а использовать в другом. Разработчики могут создавать библиотеки кода, отвечающие за связанные задачи.
К преимуществам модулей относятся:
- вы можете разделить код на более мелкие файлы с автономными функциями;
- одни и те же модули можно повторно использовать в любом количестве приложений;
- проверенный модуль не требует дальнейшей отладки;
- решает конфликты имён: функция
f()
вmodule1
не должна конфликтовать с функциейf()
вmodule2
.
Те, кто перешёл на JavaScript с другого языка, были бы шокированы, обнаружив. Что первые два десятилетия его жизни не было концепции модулей. Не было возможности импортировать один файл JavaScript в другой.
Разработчики клиентской части должны были:
- добавлять несколько тегов
<script>
на HTML-страницу; - объединять скрипты в один файл, возможно, с помощью сборщика, такого как webpack, esbuild или Rollup.js, или;
- использовать библиотеку динамической загрузки модулей, такую как RequireJS или SystemJS, в которой реализован собственный синтаксис модуля (такой, как AMD или CommonJS).
Использование модулей ES2015 (ESM)
Модули ES (ESM) появились в ECMAScript 2015 (ES6). ESM предлагал следующие возможности:
- Код модуля работает в строгом режиме — не нужно использовать
'use strict'
. - Всё внутри модуля ES2015 по умолчанию является приватным. Оператор
export
предоставляет публичные свойства, функции и классы; операторimport
может ссылаться на них в других файлах. - Вы ссылаетесь на импортированные модули по URL-адресу, а не по имени локального файла.
- Все модули ES (и дочерние подмодули) разрешаются и импортируются до выполнения скрипта.
- ESM работает в современных браузерах и средах выполнения серверов, включая Node.js, Deno и Bun.
Следующий код определяет модуль mathlib.js
экспортирующий три публичные функции в конце:
// mathlib.js
// сложение значений
function sum(...args) {
log('sum', args);
return args.reduce((num, tot) => tot + num);
}
// умножение значений
function multiply(...args) {
log('multiply', args);
return args.reduce((num, tot) => tot * num);
}
// factorial: умножить все значения от 1 до значения
function factorial(arg) {
log('factorial', arg);
if (arg < 0) throw new RangeError('Invalid value');
if (arg <= 1) return 1;
return arg * factorial(arg - 1);
}
// приватная функция вывода лога
function log(...msg) {
console.log(...msg);
}
export { sum, multiply, factorial };
Вы также можете экспортировать публичные функции и значения по отдельности, например:
// сложение значений
export function sum(...args) {
log('sum', args);
return args.reduce((num, tot) => tot + num);
}
Оператор import
включает модуль ES, ссылаясь на его URL путь с использованием относительной записи (./mathlib.js
, ../mathlib.js
) или полной записи (file:///home/path/mathlib.js
, https://mysite.com/mathlib.js
).
Вы можете ссылаться на модули ES, добавленные с помощью Node.js npm install
используя "name"
определённое в package.json
.
Современные браузеры, Deno и Bun могут загружать модули с URL-адреса в интернете (https://mysite.com/mathlib.js
). Это изначально не поддерживалось в Node.js, но должно появиться в следующем выпуске.
Вы можете импортировать определённые именованные элементы:
import { sum, multiply } from './mathlib.js';
console.log( sum(1,2,3) ); // 6
console.log( multiply(1,2,3) ); // 6
Вы можете использовать псевдонимы для импорта, чтобы разрешить любые конфликты:
import { sum as addAll, mult as multiplyAll } from './mathlib.js';
console.log( addAll(1,2,3) ); // 6
console.log( multiplyAll(1,2,3) ); // 6
Или импортировать все общедоступные значения, используя имя объекта в качестве пространства имён:
import * as lib from './mathlib.js';
console.log( lib.sum(1,2,3) ); // 6
console.log( lib.multiply(1,2,3) ); // 6
console.log( lib.factorial(3) ); // 6
Модуль экспортирующий один элемент, может быть анонимным по умолчанию (default
). Например:
// defaultmodule.js
export default function() { ... };
Импортируя default
без фигурных скобок, используйте любое предпочитаемое имя:
import myDefault from './defaultmodule.js';
Это фактически то же самое, что и следующее:
import { default as myDefault } from './defaultmodule.js';
Некоторые разработчики избегают экспорта default
, потому что:
- Может возникнуть путаница из-за присвоенного имени, например, вы можете дать название
divide
(деление) функции умножения экспортируемой какdefault
. Функционал модуля также мог измениться и сделать название избыточным. - Это может сломать инструменты помощи с кодом, такие как рефакторинг.
- Добавление связанных функций в библиотеку становится более сложным. Почему одна функция по умолчанию, а другая нет?
- Заманчиво экспортировать один объектный литерал по умолчанию с более чем одной функцией, адресованной свойству, вместо использования отдельных объявлений экспорта. Это делает невозможным для бандлеров извлечение неиспользуемого кода.
Я бы не никогда не стал говорить никогда, но от экспорта по умолчанию мало пользы.
Загрузка ES модулей в браузерах
Браузеры загружают ES модули асинхронно, и откладывают выполнение до готовности DOM. Модули запускаются в порядке, указанным тэгом <script>
:
<script type="module" src="./run-first.js"></script>
<script type="module" src="./run-second.js"></script>
И каждым встроенным import
:
<script type="module">
import { something } from './run-third.js';
// ...
</script>
Браузеры не поддерживающие ESM не будут загружать и запускать скрипт с атрибутом type="module"
. Точно так же браузеры с поддержкой ESM не будут загружать скрипты с атрибутом nomodule
.
При необходимости можно предоставить два скрипта для современного и старого браузера:
<script type="module" src="./runs-in-modern-browser.js"></script>
<script nomodule src="./runs-in-old-browser.js"></script>
Это может быть практично, когда:
- У вас большая часть пользователей IE.
- Прогрессивное улучшение затруднено и у вашего приложения есть важный функционал, который нельзя реализовать только с помощью HTML и CSS.
- У вас есть процесс сборки, который может выводить код ES6 и ES5 из одних и тех же исходных файлов.
Обратите внимание, что ES модули должны отдаваться сервером с MIME типом application/javascript
или text/javascript
. Заголовок CORS должен быть установлен для модуля импортируемого из другого домена, например, Access-Control-Allow-Origin: *
для разрешения доступа с любого сайта.
Будьте осторожны с импортом стороннего кода из другого домена. Это повлияет на производительность и представляет угрозу безопасности. Если вы сомневаетесь, скопируйте файл на локальный сервер и импортируйте оттуда.
Использование модулей CommonJS в Node.js
CommonJS был выбран в качестве системы модулей для Node.js, потому что в 2009, когда появилась среда выполнения JavaScript, ESM ещё не существовало. Возможно вы сталкивались с CommonJS используя Node.js или npm
. Модуль CommonJS делает функцию или значение доступным с помощью module.exports
. Перепишем наш ES модуль mathlib.js
:
// mathlib.js
// сложение значений
function sum(...args) {
log('sum', args);
return args.reduce((num, tot) => tot + num);
}
// умножение значений
function multiply(...args) {
log('multiply', args);
return args.reduce((num, tot) => tot * num);
}
// factorial: умножить все значения от 1 до значения
function factorial(arg) {
log('factorial', arg);
if (arg < 0) throw new RangeError('Invalid value');
if (arg <= 1) return 1;
return arg * factorial(arg - 1);
}
// приватная функция вывода лога
function log(...msg) {
console.log(...msg);
}
module.exports = { sum, multiply, factorial };
Оператор require
включает модуль CommonJS, ссылаясь на путь к его файлу, используя относительную (./mathlib.js
, ../mathlib.js
) или абсолютную нотацию (/path/mathlib.js
). Справочные модули добавляются с помощью npm install
с использованием имени
, определённого в package.json
.
Модуль CommonJS подключается динамически и синхронно загружается в точке, на которую он ссылается во время выполнения скрипта. Вы можете задать при подключении определённые экспортируемые элементы:
const { sum, mult } = require('./mathlib.js');
console.log( sum(1,2,3) ); // 6
console.log( multiply(1,2,3) ); // 6
Или может, при подключении, задать, чтобы каждый экспортируемый элемент использовал имя переменной в качестве пространства имён:
const lib = require('./mathlib.js');
console.log( lib.sum(1,2,3) ); // 6
console.log( lib.multiply(1,2,3) ); // 6
console.log( lib.factorial(3) ); // 6
Вы можете определить модуль с одним экспортируемым элементом по умолчанию:
// mynewclass.js
class MyNewClass {};
module.exports = MyNewClass;
Подключая элемент по умолчанию, можно использовать любое имя:
const
ClassX = require('mynewclass.js'),
myObj = new ClassX();
Различия между ES модулями и CommonJS
ESM и CommonJS внешне похожи, но есть фундаментальные различия.
- CommonJS динамически загружает файл при обнаружении оператора
require
во время выполнения. - ESM поднимает, предварительно анализирует и разрешает все операторы
import
до выполнения кода.
Динамический импорт модулей ES напрямую не поддерживается и не рекомендуется — этот код не выполнится:
// НЕ РАБОТАЕТ!
const script = `./lib-${ Math.round(Math.random() * 3) }.js`;
import * as lib from script;
Можно динамически загружать модули ES с помощью асинхронной функции import()
, которая возвращает Promise
.
const script = `./lib-${ Math.round(Math.random() * 3) }.js`;
const lib = await import(script);
Это влияет на производительность, а проверка кода становится более сложной. Используйте функцию import()
когда нет другого варианта, например, скрипт создаётся динамически после запуска приложения.
ESM также может импортировать данные JSON, хотя это (пока) не утверждённый стандарт, и поддержка на разных платформах может различаться:
import data from './data.json' assert { type: 'json' };
Динамический CommonJS и ESM с поднятой (hoisted) загрузкой могут привести к другим логическим несовместимостям. Рассмотрим этот ES модуль:
// ESM two.js
console.log('running two');
export const hello = 'Hello from two';
Этот сценарий импортирует его:
// ESM one.js
console.log('running one');
import { hello } from './two.js';
console.log(hello);
При выполнении one.js
выведет следующее:
running two
running one
hello from two
Это происходит из-за того, что two.js
импортируется до того, как one.js
выполнится, даже если импорт происходит после console.log()
.
Подобный CommonJS модуль two.js
:
// CommonJS two.js
console.log('running two');
module.exports = 'Hello from two';
Вызывается в one.js
:
// CommonJS one.js
console.log('running one');
const hello = require('./two.js');
console.log(hello);
Порядок выполнения другой:
running one
running two
hello from two
Браузеры не поддерживают CommonJS напрямую, поэтому вряд ли это повлияет на клиентский код. Node.js поддерживает оба типа модулей, и в одном проекте можно смешивать CommonJS и ESM!
Node.js использует следующий подход для решения проблем совместимости модулей:
- CommonJS используется по умолчанию (или установите
"type": "commonjs"
вpackage.json
). - Любой файл с расширением
.cjs
обрабатывается как CommonJS. - Любой файл с расширением
.mjs
обрабатывается как ESM. - Запустив
node --input-type=module index.js
сценарий обрабатывается как ESM. - Параметр
"type": "module"
вpackage.json
, указывает анализировать входной сценарий как ESM.
Ещё одно преимущество ES модулей заключается в том, что они поддерживают верхний уровень await
. Вы можете выполнить асинхронный код в коде входа:
await sleep(1);
Это невозможно в CommonJS, необходимо объявлять внешнее асинхронное Выражения Немедленно Вызываемой Функции (IIFE).
(async () => {
await sleep(1);
})();
Импорт модулей CommonJS в ESM
Node.js может импортировать модуль CommonJS в файл ESM. Например:
import lib from './lib.cjs';
Часто это работает хорошо, и Node.js предлагает варианты синтаксиса при возникновении проблем.
Подключение ES модулей в CommonJS
Невозможно подключить ES модуль, через require
в файл CommonJS. При необходимости можно использовать асинхронную функцию import()
, показанную выше:
// CommonJS script
(async () => {
const lib = await import('./lib.mjs');
// ... use lib ...
})();
Заключение
На разработку ES Модулей ушло много лет, но наконец-то у нас есть система, которая работает в браузерах и средах выполнения JavaScript на стороне сервера, таких как Node.js, Deno и Bun.
Тем не менее Node.js использует CommonJS половину своей жизни, и он так же поддерживается в Bun. Вы можете столкнуться с библиотеками, которые предназначены только для CommonJS, только для ESM или предоставляют сборки для обеих модульных систем JavaScrip. Я рекомендую использовать ES Модули для новых проектов Node.js, если только вы не столкнётесь с важным (но редким) пакетом CommonJS, который невозможно импортировать, через import
. Даже в этом случае вы можете рассмотреть возможность переноса этой функциональности в рабочий поток или дочерний процесс, чтобы остальная часть проекта сохранила ESM.
Преобразование большого устаревшего проекта Node.js из CommonJS в ESM может оказаться сложной задачей, особенно если вы столкнётесь с указанными выше различиями в порядке выполнения. Node.js будет поддерживать CommonJS в течение многих лет — возможно, навсегда — так что, вероятно, это не стоит затраченных усилий. Это может измениться, если клиенты потребуют полной совместимости ESM для ваших публичных библиотек.
Для всего остального: используйте ES модули. Это стандарт JavaScript.
Дополнительная информация: JavaScript: различие между require и import