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

К преимуществам модулей относятся:

  • вы можете разделить код на более мелкие файлы с автономными функциями
  • одни и те же модули можно повторно использовать в любом количестве приложений
  • проверенный модуль не требует дальнейшей отладки
  • он разрешает конфликты имен: функция f() в module1 не должна конфликтовать с функцией f() в module2.

Те, кто перешел на JavaScript с другого языка, были бы шокированы, обнаружив, что в первые два десятилетия его жизни не было концепции модулей. Невозможно было импортировать один файл JavaScript в другой.

Клиентские разработчики должны были:

  • добавить несколько тегов <script> на HTML-страницу
  • объединить скрипты в один файл, возможно, используя сборщик, такой как webpack, esbuild или Rollup.js, или
  • использовать библиотеку динамической загрузки модулей, такую ​​как RequireJS или SystemJS, в которой реализован собственный синтаксис модуля (например, AMD или CommonJS).

Использование модулей ES2015 (ESM)

Модули ES (ESM) появились в ECMAScript 2015 (ES6). ЕСМ предлагает следующие возможности:

  • Код модуля работает в строгом режиме — 'use strict' не нужен.
  • Все внутри модуля ES2015 по умолчанию является приватным. Оператор export предоставляет общедоступные свойства, функции и классы; оператор import может ссылаться на них в других файлах.
  • Вы ссылаетесь на импортированные модули по URL-адресу, а не по имени локального файла.
  • Все модули ES (и дочерние подмодули) разрешаются и импортируются до выполнения скрипта.
  • ESM работает в современных браузерах и средах выполнения серверов, включая Node.js, Deno и Bun.

Следующий код определяет модуль mathlib.js, который в конце экспортирует три общедоступные функции:

// mathlib.js
// add values
function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);
}
// multiply values
function multiply(...args) {
  log('multiply', args);
  return args.reduce((num, tot) => tot * num);
}
// factorial: multiply all values from 1 to value
function factorial(arg) {
  log('factorial', arg);
  if (arg < 0) throw new RangeError('Invalid value');
  if (arg <= 1) return 1;
  return arg * factorial(arg - 1);
}
// private logging function
function log(...msg) {
  console.log(...msg);
}
export { sum, multiply, factorial };

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

// add values
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 могут загружать модули с веб-адреса ( https://mysite.com/mathlib.js). Это изначально не поддерживается в Node.js, но появится в будущем выпуске.

Вы можете import конкретных именованных элементов:

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 все общедоступные значения, использующие имя объекта в качестве пространства имен:

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() { ... };

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

import myDefault from './defaultmodule.js';

Это фактически то же самое, что и следующее:

import { default as myDefault } from './defaultmodule.js';

Некоторые разработчики избегают экспорта default, потому что:

  • Может возникнуть путаница, потому что вы можете назначить любое имя, например, divide, для функции умножения по умолчанию. Функционал модуля также мог измениться и сделать название избыточным.
  • Это может сломать инструменты помощи коду, такие как рефакторинг в редакторах.
  • Добавление связанных функций в библиотеку становится более сложным. Почему одна функция используется по умолчанию, а другая нет?
  • Заманчиво экспортировать один литерал объекта по умолчанию с более чем одной функцией, адресованной свойству, а не использовать отдельные объявления export. Это делает невозможным для сборщиков встряхивание дерева неиспользуемого кода.

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

Загрузка модулей 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.
  • У вас есть процесс сборки, который может выводить код ES5 и ES6 из одних и тех же исходных файлов.

Обратите внимание, что модули ES должны обслуживаться с типом MIME application/javascript или text/javascript. Заголовок CORS должен быть установлен, когда модуль может быть импортирован из другого домена, например, Access-Control-Allow-Origin: *, чтобы разрешить доступ с любого сайта.

Будьте осторожны с импортом стороннего кода из другого домена. Это повлияет на производительность и представляет угрозу безопасности. Если есть сомнения, скопируйте файл на локальный сервер и import оттуда.

Повтор сеанса с открытым исходным кодом

OpenReplay – это пакет для воспроизведения сеансов с открытым исходным кодом, который позволяет вам видеть, что пользователи делают в вашем веб-приложении, помогая вам быстрее устранять неполадки. OpenReplay размещается на собственном сервере для полного контроля над вашими данными.

Начните получать удовольствие от отладки — начните использовать OpenReplay бесплатно.

Использование модулей CommonJS в Node.js

CommonJS был выбран в качестве модульной системы для Node.js на стороне сервера, потому что ESM не существовало, когда среда выполнения JavaScript была выпущена в 2009 году. Возможно, вы сталкивались с CommonJS при использовании Node.js или npm. Модуль CommonJS делает функцию или значение общедоступными с помощью module.exports. Переписываем наш модуль mathlib.js ES сверху:

// mathlib.js

// add values
function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);
}

// multiply values
function multiply(...args) {
  log('multiply', args);
  return args.reduce((num, tot) => tot * num);
}

// factorial: multiply all values from 1 to value
function factorial(arg) {
  log('factorial', arg);
  if (arg < 0) throw new RangeError('Invalid value');
  if (arg <= 1) return 1;
  return arg * factorial(arg - 1);
}

// private logging function
function log(...msg) {
  console.log(...msg);
}

module.exports = { sum, multiply, factorial };

Оператор require включает модуль CommonJS, ссылаясь на его путь file с использованием относительной (./mathlib.js, ../mathlib.js) или абсолютной записи (/path/mathlib.js). Справочные модули добавляются с помощью npm install с использованием "name", определенного в package.json.

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

const { sum, mult } = require('./mathlib.js');

console.log( sum(1,2,3) );      // 6
console.log( multiply(1,2,3) ); // 6

Или вы можете require каждый экспортируемый элемент использовать имя переменной в качестве пространства имен:

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;

require по умолчанию с любым именем:

const
  ClassX = require('mynewclass.js'),
  myObj = new ClassX();

CommonJS также может импортировать данные JSON как объект JavaScript:

const myData = require('./data.json');

console.log( myData?.myProperty );

Различия между модулями ES и CommonJS

ESM и CommonJS внешне похожи, но есть фундаментальные различия.

  • CommonJS динамически загружает файл при обнаружении оператора require во время выполнения.
  • ESM поднимает, предварительно анализирует и разрешает все операторы import перед выполнением кода.

Динамические import модулей ES напрямую не поддерживаются и не рекомендуются — этот код не сработает:

// WON'T WORK!
const script = `./lib-${ Math.round(Math.random() * 3) }.js`;
import * as lib from script;

Можно динамически загружать ES-модули с помощью асинхронной import()функции, которая возвращает обещание:

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 с поднятой загрузкой могут привести к другим логическим несовместимостям. Рассмотрим этот модуль 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, хотя import идет после console.log().

Аналогичный модуль CommonJS:

// 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. Необходимо объявить внешнее выражение async немедленно вызываемой функции (IIFE):

(async () => {
  await sleep(1);
})();

Импорт модулей CommonJS в ESM

Node.js может import использовать модуль CommonJS в файле ESM. Например:

import lib from './lib.cjs';

Это часто работает хорошо, и Node.js предлагает варианты синтаксиса при возникновении проблем.

Требование модулей ES в CommonJS

Невозможно require ES-модуль в файле 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 или предоставляют отдельные сборки для обоих. Я рекомендую использовать модули ES для новых проектов Node.js, если вы не столкнетесь с важным (но редким) пакетом CommonJS, который невозможно import. Даже в этом случае вы можете рассмотреть возможность переноса этой функциональности в рабочий поток или дочерний процесс, чтобы остальная часть проекта сохранила ESM.

Преобразование большого устаревшего проекта Node.js из CommonJS в ESM может оказаться сложной задачей, особенно если вы столкнетесь с указанными выше различиями в порядке выполнения. Node.js будет поддерживать CommonJS в течение многих лет — возможно, навсегда — так что, вероятно, это не стоит затраченных усилий. Это может измениться, если клиенты потребуют полной совместимости ESM для ваших общедоступных библиотек.

Для всего остального: используйте модули ES. Это стандарт JavaScript.

Для получения дополнительной информации см. следующее:

Первоначально опубликовано на https://blog.openreplay.com.