Логотип блога Владислава Иванова

Всем привет! 👋

Если вы когда-нибудь имели удовольствие разрабатывать что-либо на JavaScript, думаю хотя бы раз в жизни вы сталкивались с чем-то типа:

text
SyntaxError: Cannot use import statement outside a module

(если нет, то все еще впереди! 😉)

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

Итак, сегодня я постараюсь рассказать про модульные системы JavaScript/TypeScript, как так получилось что их несколько и что лично я бы рекомендовал использовать в 2023 году.

Немного теории

Принцип модульности является средством упрощения задачи проектирования программного обеспечения (ПО) и распределения процесса разработки между группами разработчиков.

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

Модуль — это последовательность логически связанных фрагментов, оформленных как отдельная часть программы. Во многих языках оформляется в виде отдельного файла с исходным кодом или поименованной непрерывной её части.

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

IIFE

Начнем, пожалуй, с IIFE, хоть это и не модульная система, а нативный механизм языка, я считаю, что для полноты картины нам нужно его рассмотреть. IIFE представляет собой функцию, которая объявляется и сразу же вызывается. Эта техника используется ради изоляции - переменные, объявленные в теле такой функции недоступные извне, что помогает избежать конфликтов имен и загрязнения глобального пространства имен. Пример такой функции можно увидеть ниже:

javascript
;(function () {
  const a = 5;

  console.log(a) // 5
})()

console.log(a) // Uncaught ReferenceError: a is not defined

Если вы предпочитаете использовать JS без ;, то перед объявлением IIFE я рекомендую ставить ; как показано выше, чтобы выражение не интерпретировалось как вызов функции. Если же вы за ; повсюду, то таких проблем у вас не возникнет.

Кстати, IIFE может быть асинхронной, а это значит, что в теле такой функции допустимо использовать ключевое слово await, но это не совсем по теме этой статьи.

Несмотря на то, что IIFE дает удобный (и нативный) способ изоляции кода, он не дает удобного механизма разделения кода на файлы. Конечно, вы можете использовать сколько угодно тегов script на странице, однако на больших проектах очень сложно отслеживать зависимости модулей друг от друга. И хотя, с точки зрения определения модуля в программировании, которое я привел выше, модуль не обязательно должен представлять собой отдельный файл, практика показывает, что работать так значительно проще, поэтому со временем появились более мощные инструменты и подходы.

AMD

AMD (Asynchronous Module Definition) - это модульная система, предназначенная для использования в браузерной среде. AMD предоставляет механизм обнаружения и разрешения зависимостей модулей, что позволяет автоматически загружать и выполнять модули в правильном порядке.

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

Одной из главных особенностей AMD является возможность асинхронной загрузки модулей. Когда модуль запрашивается для загрузки, AMD загрузчик асинхронно загружает зависимости модуля, обеспечивая правильный порядок загрузки.

Ниже приведен пример как это можно использовать:

javascript
define('sounds', 
  ['dog', 'audio'], 
  function (dog, audio) {
    return {
      bark: function() {
        return audio.play(dog.getVoice());
      }
    }
  };
});

Здесь мы реализуем модуль sounds и явно говорим о его зависимостях. Одним из самых популярных инструментов, реализующих AMD является RequireJS. Приведу пример того, как он может использоваться:

javascript
requirejs(
  ['helper/util'],
  function(util) {
    // This function is called when scripts/helper/util.js is loaded.
    // If util.js calls define(), then this function is not fired until
    // util's dependencies have loaded, and the util argument will hold
    // the module value for "helper/util".
  }
);

CommonJS (CJS)

В то время как в браузерных окружениях использовалась AMD, в node-окружениях использовалась еще одна модульная система - CommonJS. Основное различие между AMD и CommonJS заключается в поддержке асинхронной загрузки модулей.

Если AMD в наше время встретить довольно трудно, то CommonJS буквально повсюду. Тут важно заметить, что CommonJS все еще является модульной системой Node.js по-умолчанию.

Ниже приведен пример кода с использованием CommonJS:

javascript
const fs = require('fs');
const dog = require('./dog');

module.exports = {
  barkToFile: function () {
    fs.writeFileSync('./bark.txt', dog.voiceToString());
  }
};

Для импорта модулей в CommonJS используется ключевое слово require. Это функция и она принимает путь (на самом деле не совсем путь, что достижимо благодаря механизму резрешения модулей) к модулю и возвращает экспортируемые значения этого модуля.

Любые свойства, добавленные в exports или присвоенные module.exports, становятся экспортируемыми значениями модуля.

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

В браузере нет встроенной функции require и доступа к файловой системе, поэтому CommonJS в браузере не поддерживается, однако можно получить схожее API, используя подход AMD и библиотеку RequireJS:

javascript
define(
  function(require, exports) {
    const dog = require("dog");

    exports.barkToConsole = function() {
      console.log(dog.voiceToString());
    }
  }
);

ES Модули (ESM)

И вот мы, наконец, добрались до первой модульной системы, которая описана в стандарте ECMAScript. ES модули появились вместе с ES6, в 2015 году. И да, вы все правильно поняли, на протяжении 20 лет в JavaScript не было стандартизованной модульной системы. Я думаю этот синтаксис также Вам знаком:

javascript
import api from 'api.js';
import dog from './dog.js';

export function makeBarkRequest () {
  api.post('/bark', (err) => {
    if (err) return;

    dog.bark();
  });
}

Несмотря на стандартность, ES модули выключены по-умолчанию. В Node окружении вы должны использовать type: module в вашем package.json, либо .mjs расширения файлов. В браузерных окруженях для использования синтаксиса ESM внутри тега script необходимо установить атрибут type в значение module. Поддержка ES модулей впервые появилась в Node 12 под флагом --experimental-modules. В более старших версиях ES модули вышли из под флага.

Модульные системы на этом закончились, однако есть еще пара подходов, которые важно рассмотреть.

UMD

UMD (Universal Module Definition) - это шаблон или подход к созданию модулей, который позволяет модулям работать как в среде CommonJS, так и в среде AMD, а также сделать их доступными как глобальные переменные, если загрузчик модулей отсутствует.

Основная идея UMD заключается в создании модуля, который может автоматически адаптироваться к различным средам выполнения и загрузчикам модулей.

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

Выглядит это примерно так:

javascript
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD env
    define(['dependency'], factory);
  } else if (typeof exports === 'object') {
    // CommonJS env
    module.exports = factory(require('dependency'));
  } else {
    // global
    root.ModuleName = factory(root.Dependency);
  }
}(this, function (dependency) {
  // module logic
  return {
    // exports
  };
}));

Если среда выполнения поддерживает AMD (проверка define), модуль определяется с использованием define и указываются зависимости для загрузчика модулей.

Если среда выполнения поддерживает CommonJS (проверка exports), модуль экспортируется с помощью module.exports, и зависимости разрешаются через require.

Если ни одна из проверок не прошла, модуль "экспортируется" путем присвоения его свойства глобальному объекту (root), который в данном случае представляет глобальное пространство имен.

SystemJS

SystemJS - это универсальный загрузчик модулей JavaScript, предназначенный для использования в браузере и среде выполнения Node.js. Он разработан с учетом поддержки и загрузки различных модульных форматов, таких как AMD, CommonJS, UMD и ES модули, позволяя разработчикам использовать разные форматы модулей в одном проекте.

Выглядит это примерно так:

javascript
System.import('dog.js').then(function(module) {
  console.log(module.bark());
}).catch(function(error) {
  console.error('Failed to load module:', error);
});

Причем на месте dog.js может быть модуль, описанный в ЛЮБОМ формате из вышеперечисленных.

Но я бы сказал, что если у вас возникла потребность в использовании разных модульных систем в одном проекте, то скорее всего что-то не так с вашим проектом.

На самом деле, скорее всего вы не будете иметь дело ни с AMD, ни с UMD, ни с SystemJS. ESM и CJS же очень распространены и вы будете видеть их на самом деле вне зависимости от того, в каком окружении разрабатываете.

Бандлеры и транспиляторы

Материал, который мы сейчас рассмотрели не является сложным сам по себе. Думаю, не составит труда опредеделить используемый подход при просмотре исходного кода. Однако, в реальной разработке код который мы пишем не попадает в продакшен в неизменном виде. На бэкенде обычно достаточно транспилятора: самым распространенным сценарием кажется транспиляция TS кода в JS (конечно, есть решения, например ts-node или deno, но это тема для другой статьи). На фронтенде все еще сложнее - код проходит не только через транспилятор, но и через различные инструменты, называемые бандлерами, минификаторами и тд.

У транспиляторов (например, tsc) есть возможность генерировать код с использованием всех модульных систем, которые мы рассмотрели: ESMCJSAMDUMD и SystemJS. А здесь Вы можете поиграться с этим самостоятельно. Например, вот такой код:

javascript
import dog from './dog';
import audio from './audio';

export const bark = () => {
  audio.play(dog.bark())
}

Не изменится, если собрать его с помощью tsc с настройкой module, установленной в 'esnext', однако если собрать код тем же инструментом, но с настройкой module , установленной в 'commonjs', то код примет такой вид:

javascript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports"__esModule", { valuetrue });
exports.bark = void 0;
const dog_1 = __importDefault(require("./dog"));
const audio_1 = __importDefault(require("./audio"));
const bark = () => {
    audio_1.default.play(dog_1.default.bark());
};
exports.bark = bark;

Если разобраться что здесь происходит и откинуть сгенерированные хелперы, то можно заметить, что код, который мы писали с использованием ESM превратился в код c использованием CJS и это и есть корень всех ошибок!

Отсюда появляется путаница. Так как в исходниках вы используете ESM, кажется логичным установить type равный 'module' настройку в package.json, но не тут то было. Реально Node.js будет запускать уже собранный tsc код, который, как мы убедились, ESM не использует и вы получите ошибку:

text
ReferenceError: require is not defined

Возможна и обратная ситуация: например, например, мы собрали код без изменений, и в нем остался синтаксис ESM. Но настройку type равную 'module' мы не установили, тогда мы получим ошибку из начала статьи:

text
SyntaxError: Cannot use import statement outside a module

Все это осложняется тем, что некоторые пакеты прекращают поддержку CJS. Одним из таких пакетов является chalk. С 5 мажорной версии они перестали поддерживать CJS и при попытке сделать require вы получите ошибку:

text
Error [ERR_REQUIRE_ESM]: require() of ES Module

А теперь представьте ситуацию, что у вас есть TS код:

javascript
import chalk from 'chalk'; // >= 5
import dog from './dog';

export const colorfulBark = () => {
  console.log(chalk.green(dog.barkToString()));
}

И вы решили собрать его с помощью tsc с module равной 'commonjs'. Вы получите вот такой код:

javascript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports"__esModule", { valuetrue });
exports.colorfulBark = void 0;
const chalk_1 = __importDefault(require("chalk")); // <-- require('chalk')
const dog_1 = __importDefault(require("./dog"));
const colorfulBark = () => {
    console.log(chalk_1.default.green(dog_1.default.barkToString()));
};
exports.colorfulBark = colorfulBark;

А при попытке запустить его - ошибку:

text
Error [ERR_REQUIRE_ESM]: require() of ES Module

которая говорит о том, что какой то из наших модулей (в данном случае chalk) не поддерживает CJS. И это очень запутанно, так как в исходном коде у нас нет никаких require! А в уже собранном коде бывает довольно сложно разобраться, если проект достаточно большой.

В данном случае помогут два варианта:

  1. Установть type в значение 'module' в package.json, что также потребует установки module в значение 'esnext' в tsconfig.json (возможно, сломается что-то другое 😁).
  2. Даунгрейднуть chalk до версии 4.1.2, в которой CJS еще поддерживался. То есть проблема различия модульных систем заставляет Вас использовать не самые новые версии пакетов, что, конечно же, нехорошо.

Вообще, хочу дополнительно отметить, что type, установленный в значение 'module' это очень серьезная project-wide настройка, которая влияет буквально на все, поэтому поменять ее, обычно, крайне трудно. Например, если вы используете jest с type равным 'module', а потом резко по какой-то причине решили перейти на type равный  'commonjs' у вас перестанут работать top-level await в тестах (которые, например могли бы использоваться для моков отдельных модулей).

И что же делать?

Я не могу давать рекомендации относительно уже существующих проектов, потому что, как я уже сказал, изменение настройки type, обычно, даётся крайне трудно и в каждом отдельном случае могут возникнуть разные проблемы. Но, конечно, я бы рекомендовал перейти на type равный 'module', если есть такая возможность.

Новые же проекты я бы рекомендовал начинать с type равным 'module'. Все больше публичных пакетов отказываются от поддержки CJS. К тому же, ESM дает дополнительные возможности, например top-level await.

Конечно, есть нюанс. При использовании ESM расширения файлов необходимы при импортах модулей ('./startup/index.js'). Также не поддерживается автоматическое разрешение импорта папки в index.js файл (directory index), то есть путь нужно указывать полностью.

Это проблема, если вы используете tsc, так как TypeScript не модифицирует пути импортов при транспиляции. Эту проблему можно решить специальным тулингом или просто использовать в импортах .js расширение. TypeScript сможет понять о каком файле (.ts) идет речь, а после транспиляции в коде будет валидный путь. Кстати моя IDE (vscode) даже смогла правильно подсказать путь с .js расширением.

Как альтернативу для TypeScript проектов можно рассмотреть ts-node, но запускать его необходимо с флагом --transpile-only. Конечно, нужно помнить, что это другой, отличный от стандартной node рантайм и у него есть свои не менее интересные особенности.

Еще одним решением этой проблемы могут быть bare specifiers в сочетании с настройкой moduleResolution равной 'nodenext', но тогда нужно еще решить проблему копирования package.json файлов в папку с билдом (причем структуру необходимо сохранить).

Но все же для новых JavaScript проектов я бы рекомендовал использовать type равный 'module' и только его. В случае с TypeScript не все так просто, однако я бы тоже рекомендовал использовать type равный 'module' и указывать .js расширения. Да, это немного странно, но это лучшее решение из тех, что есть, как по мне.

Выше мы рассмотрели вопросы использования модульных систем в node-окружениях. А как же быть в браузерных? Обычно, в браузерных окружениях таких проблем не возникает, так как используются различные инструменты типа webpack, который сам по себе поддерживает и ESM синтаксис, и CJS. На выходе обычно мы имеем бандл, в котором весь код просто сконкатенирован в один и никаких модульных систем (а значит и проблем с ними там нет). Webpack также предоставляет решение для разбиений приложения на файлы (чанки) с целью асинхронной загрузки по требованию, что тоже есть модульная система, но это тема для отдельной статьи.

Если говорить о более новых инструментах, например Vite, то он построен на ESM и использует в браузере script с атрибутом type равном 'module'. Быстродействие Vite построено на ESM. Насколько мне известно, альтернативы этому нет, поэтому выбирать не приходится.

Заключение

Пока я писал эту статью я нашел в интернете множество других статей на тему сравнения модульных систем в JavaScript. Однако, все эти статьи лишь поверхностно рассматривают различия и сходства этих систем. Я же постарался рассмотреть не только модульные системы, но и проблемы с которыми обычно сталкиваются разработчики и даже предложил несколько решений. Надеюсь, вам это будет полезно в ваших проектах.

Спасибо за внимание и до встречи!

Ссылки

  1. Modular programming, просмотрено 19 июня 2023, https://en.wikipedia.org/wiki/Modular_programming
  2. IIFE, просмотрено 19 июня 2023, https://developer.mozilla.org/en-US/docs/Glossary/IIFE
  3. RequireJS, просмотрено 19 июня 2023, https://requirejs.org/
  4. ESM, просмотрено 19 июня 2023, https://nodejs.org/docs/latest-v18.x/api/esm.html
  5. UMD, просмотрено 19 июня 2023, https://github.com/umdjs/umd
  6. AMD, просмотрено 19 июня 2023, https://en.wikipedia.org/wiki/Asynchronous_module_definition
Поддерживается markdown

Пока нет комментариев