Skip to content

Instantly share code, notes, and snippets.

@monochromer
Last active June 7, 2023 15:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save monochromer/ce880050c581d112692026f7b026b458 to your computer and use it in GitHub Desktop.
Save monochromer/ce880050c581d112692026f7b026b458 to your computer and use it in GitHub Desktop.
Роман Дворнов. Организация dual package с поддержкой модулей EcmaScripts и CommonsJS

Оригинал: https://t.me/gorshochekvarit/175

Dual Package - ESM и CJS

Примерно год назад стал очевиден тренд на переход всего и вся на ESM. Было несколько заявлений от опенсорс мейнтейнеров, самое известное, возможно, пост от Sindre Sorhus. Стало понятно, что нужно смотреть в эту сторону и думать как перевести свои проекты на ESM, в том числе CSSTree (и должно быть частью 2.0). Но в отличие от Sindre, я планировал перевод не на Pure ESM (поддержка только ESM), а на Dual, когда поддерживается и ESM и CommonJS.

Кажется, что до сих пор нет единого мнения о том как правильно – есть ярые сторонники как Pure ESM, так и Dual. У каждого подхода свои плюсы и минусы. Я склоняюсь к Dual, потому что попробовав использовать ESM на нескольких Node.js проектах (библиотеки и скрипты), пришел к мнению, что CommonJS во многих случаях и проще, и уместнее ESM. А вот если говорить про код исполняющийся в браузере, то по сути все равно – обычно используются бандлеры, которые давно уже переваривают и ESM, и CommonJS. Плюс в браузере другая механика работы с модулями и их динамической загрузкой, бандлеры упростили многие паттерны, чего нет в Node.js и в нем пока еще приходится городить огороды. Другими словами, есть нюансы, важен контекст использования библиотек и модулей (Node.js, браузер и т.д.). Но если можно сделать чтобы работало и так, и так, то я предпочту этот вариант, чем не ограничивать пользователей в сценариях использования библиотеки, потому что так проще автору библиотеки или абстрактно "правильней".

Может быть я еще разберу доводы против Dual (например), но пока остановимся на том, что в начале года я поставил себе цель попробовать решить задачу организации правильного Dual пакета без особых накладных расходов в поддержке. Странность постановки задачи связана с тем, что хороших инструкций как это сделать я пока не видел (может пропустил), и у противников Dual есть убеждение что это почти невозможно и/или очень сложно в поддержке. Мне показалось это решаемым, хотя поиск решения занял около года.

Решение нашлось, "флагманом" стал как раз CSSTree 2.0. Этим я не только закрыл свой гештальт, но запустил волну миграции своих проектов на ESM. За последние пару недель помимо CSSTree я перевел на Dual еще 5 проектов (CSSO, CSSTree validator, stylelint-validator, postcss-csso и clap), и с каждым разом переход был быстрее и проще. Это здорово зарядило на новые свершения. И несмотря на то, что я еще продолжаю разрешать проблемы с Dual пакетами, мне кажется, что я нашел тот самый "рецепт", который работает как минимум для меня и которым хочу поделиться. Рецепт не только про организацию основного кода, но и про тесты, автоматизацию и бандлинг.

Тред про организацию Dual npm-пакета

Пререквезиты

Dual пакет подразумевает, что поддерживаются два типа модульности: ESM и CommonJS. Есть несколько способов это организовать, но самым верным, на мой взгляд, является честная реализация ESM и CommonJS в одном пакете. Да, в пакете код модулей будет дублироваться (насколько это и другое проблема – напишу отдельно), различия между модулями будет только в импортах и экспортах. Однако дублировать код самостоятельно не нужно, код пишется в одном "формате", а второй получается автоматически путем преобразования.

Преобразование кода (траспиляция) во фронтенде уже давно обычное дело. К примеру, код на TypeScript не запустить без транспиляции. Поэтому транспиляция ESM -> CommonJS или наоборот не выглядит чем-то необычным. Нужно только выбрать какой "формат" будет основным, а какой производный.

При выборе основного "формата" кода стоит выбрать более строгий, так как из более строго проще получить более свободный. Так ESM строже чем CommonJS, его статический анализ проще и почти все паттерны можно воспроизвести в CommonJS. Обратная трансформация работает хуже (и не всегда работает) и на порядок сложнее. Если проект на TypeScript, то основным "форматом" должен быть ESM TypeScript.

// Я пока не буду рассматривать TypeScript, так как у него еще есть проблемы (основная) с поддержкой Dual пакетов, что должны были разрешить в 4.5, но не случилось. Как разрешатся проблемы (надежда на 4.6), буду пробовать добавлять в Dual проекты TypeScript.

// Напомню, что все это затевается для того, чтобы код пакета работал в Node.js как можно более нативно. В случае когда в основном подразумевается запускать код в браузере (использовать бандлер), то в организации Dual пакета может не быть необходимости.

Переход на ES модули

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

Полноценная поддержка ESM в Node.js появилась в версии 12.20 (с флагом в 12.0, без флага но с варнингом в 12.17). В ходе выполнения программы могут подключаться как ESM, так и CommonJS модули. То есть совсем не обязательно, чтобы все модули были только на ESM или CJS, так из ESM модулей можно подключать CJS модули и наоборот. Но есть особенности:

  • В CJS модулях через require() можно подключать только CJS модули. Для подключения ESM модулей необходимо использовать import() (таким же образом можно подключать и CJS модули), а это значит, что код становится асинхронным. Можно подумать, что здесь поможет top level await, но он не работает в CJS модулях, только в ESM.
  • В ESM модулях import работает одинаково для ESM и CJS модулей. Однако, ленивое (или по условию) подключение модулей (что типично для Node.js) возможно только с import(), а это опять асинхронность. В этом случае мы можем использовать top level await, но только если нам не нужно поддерживать Node.js ранее 14.8 (версия в которой появился top level await).

// Так как require() на данный момент функциональней import'а, то есть возможность создать его аналог в ESM модулях используя метод createRequire() из встроенного модуля "module". Мы еще вернемся к этому. В данном же контексте это про то, что ESM пока не закрывает всех потребностей и CommonJS просачивается в ESM в том числе и в таком виде. Также стоит заметить, что несомненно со временем возможности import будут расширены, но это вопрос неопределенного будущего.

// Бытует мнение, что смешивать CJS и ESM это "к беде". Проблемы на стыке пакетов могут возникать и без смешения CJS с ESM, например, разные версии одного и того же пакета. Есть множество условий от которых зависит исход интеграции пакетов, проблема разных типов пакетов не открывает новую главу, а лишь дополняет список. Технических запретов или ограничений нет, всё сводится к архитектуре пакетов и качеству интеграции.

// В Node.js проектах нет ограничений на использование пакетов по типу, технически возможно подключить любой пакет – будь пакет CommonJS, Pure ESM или Dual – вопрос в стоимости. И насколько бы ESM не было будущим для JavaScript, CommonJS в Node.js является востребованным и вряд ли куда-то денется в обозримой перспективе. Поэтому, Dual модули не про ретроградство или консерватизм, но про возможности и эргономику платформы Node.js.

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

// Отличия в поведении Node.js и бандлеров связаны с их принципами работы. Так Node.js работает с модулями динамически, загружая и исполняя код в runtime, стараясь максимально использовать инфраструктуру V8 (свой JS engine). Это благоприятно сказывается на времени старта приложения (что критично во многих сценариях), позволяет избежать анализа кода (это полностью уходит в V8) и привносить в поведение отклонения от стандартов (и так хватает подпорок, читай "костылей"). Сборщики же работают с исходным кодом (текстом), они могут позволить себе любой анализ и трансформацию кода, время их работы не играет роли для работы основной программы (приложения).

Критерием, по которому определяется тип подключаемого модуля это расширение его файла. Так файлы с расширение .cjs это всегда CommonJS, а с расширением .mjs – ESM. Тип модуля для расширения .js зависит от поля "type" в package.json:

  • Если поле отсутствует или его значение "commonjs", то тип модулей CommonJS;
  • Если значение поля "module" – тип ESM.

Так как основным "форматом" кода выбран ESM, то стоит определить для .js модулей тип ESM, добавив "type": "module" в package.json. Стоит отметить, что эта настройка влияет только на то как трактуются модули (определяется их тип) в самом пакете/проекте. При подключении внешних зависимостей (пакетов) учитываются настройки в их package.json. Так, если ваш пакет/проект является ESM, где .js файлы являются ESM модулями, и он подключает .js файл из CommonJS пакета, то подключаемый модуль будет подключаться как CommonJS модуль.

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

Собственно, начинаем с того, что добавляем "type": "module" в package.json и дальше используем import/export в модулях.

Что стоит учитывать при переходе на ESM:

  • Путь к модулям должен быть полным, включая расширение. Бандлеры ресолвят пути к модулям по принципу require() независимо от типа модуля, но Node.js и браузеры в ESM модулях не трансформируют и не дополняют пути к модулям;
  • В ESM модулях нет __dirname и __filename, эти значения нужно вычислять самостоятельно используя import.meta.url;
  • В ESM пока нет require.resolve() (пока есть лишь экспериментальный аналог за флагом);
  • import не поддерживает загрузку JSON.

С первым пунктом более менее понятно, указываем полные пути (да, может не нравиться, но так надо) – это никак мешает бандлерам, но у нас возникнет нюанс при конвертации в CJS (рассмотрим в свое время). А вот по остальным пунктам есть разные варианты решений и они по разному работают с конвертацией в CommonJS и бандлерами – поэтому рассмотрим каждую проблему подробнее.

ESM: __dirname и __filename

В ESM нет __dirname и __filename, эти значения нужно вычислять самостоятельно используя import.meta.url. Для большинства библиотек это не проблема, так как эти значения не используются. Но если используются, то достаточно просто наступить на грабли.

Например, в случае с __filename ситуация кажется простой. Ведь import.meta.url уже содержит нужное значение, с той лишь разницей, что в начале есть префикс file:// (так как это урл, нужен протокол):

// CommonJS
console.log(__filename);      // /path/to/module.js
// ESM
console.log(import.meta.url); // file:///path/to/module.js

То есть достаточно отсечь префикс (file://) и получим тоже самое. А для __dirname нужно еще отсечь имя файла в конце. Это можно сделать регулярными выражениями, но так делать не стоит. Есть более надежный способ с использованием URL, который доступен через global (то есть даже импортировать ничего не нужно, в браузерах класс тоже доступен). Используя URL можно разрешать относительные пути, но в отличие от path.resolve() относительный путь должен быть указан первым:

const __filename = new URL(import.meta.url).pathname;      // /path/to/module.js
const __dirname = new URL('.', import.meta.url).pathname;  // /path/to/

Тут стоит отметить, что у оригинального __dirname в конце нет "/", что может иметь значение при работе с путями.

Кажется, что проблема решена, но это не так. Хотя в примерах выше мы и получаем похожие результаты, но на деле мы оперируем разными типами значений: путь на файловой системе и урл. Для этих значений действую разные правила:

  • В путях могут использоваться разные разделители в зависимости от ОС (на Windows используется "\" вместо "/"), для урлов разделитель всегда "/";
  • Разный набор запрещенных к использованию символов, то есть тех которые нужно экранировать, и разный способ экранирования.

Поэтому, для надежности стоит использовать fileURLToPath() из модуля url:

// CommonJS (Windows)
__filename                        // D:\path\to\test module.js

// ESM (Windows)
import { fileURLToPath } from 'url';

import.meta.url                   // file://D:/path/to/test%20module.js
new URL(import.meta.url).pathname // D:/path/to/test%20module.js
fileURLToPath(import.meta.url)    // D:\path\to\test module.js

В зависимости от того для чего используются пути, преобразования могут быть не нужны. Так начиная с Node.js 7.6 функции модуля fs принимают экземпляры URL (с протоколом file:) в качестве путей:

fs.readFile(new URL('../path/to/data.json', import.meta.url))
fs.readdir(new URL('.', import.meta.url))

ESM: require.resolve()

Может показаться, что require.resolve() это то же самое, что и resolve() из модуля path. Однако у них разное назначение и принцип работы:

  • path.resolve() работает исключительно со строками, не имеет контекста (не важно в каком модуле вызывается – результат будет одинаковый) и не обращается к файловой системе;
  • require.resolve() напротив, работает с файловой системой для поиска файлов и чтения файлов, разрешает пути относительно модуля из которого вызван.

Метод require.resolve() необходима для разрешения путей к ресурсам подключаемых пакетов. В этом есть потребность, так как пакет может быть расположен где угодно в node_modules или в вышележащих node_modules, и к тому же есть ряд правил для разрешения путей, нужно учитывать содержимое package.json пакетов (например, exports), и т.д.

Зачем разрешать пути к модулям пакетов, если require() и import и так это делают? Это нужно в двух ключевых случаях:

  • Получение пути к файлу пакета, контент которого не может быть загружен используя require() или import. К примеру, это может быть CSS или WASM файл, а в случае с import это актуально и для JSON файлов. В таких случаях получаем путь к файлу и читаем его содержимое используя модуль fs.
  • Разрешение путей пакета относительно заданной локации. require() и import всегда разрешают путь к пакету относительно модуля и это нельзя скорректировать. Но используя require.resolve() можно задать пути, относительно которых нужно разрешать путь к модулю. После чего полученный путь используется для загрузки модуля используя require() или import().

Немного подробней про второй пункт. Такая необходимость возникает, если используется пакет (например, утилита), который поддерживает плагины (или кастомизацию чего либо, например, формата вывода, т.н. репортер) в виде пакетов. Если пакет будет подключать плагины используя require() или import() (так как плагины конфигурируются – это всегда динамическое подключение), то пути к пакетам будут разрешаться относительно импортирующего модуля. Что не работает в случае, когда, например, пакет является глобальной утилитой. Обычно в случае пакетных плагинов, пути к ним разрешаются относительно файла конфигурации (например, .eslintrc или babel.config.json) или же process.cwd().

require.resolve('css-tree')
// /path/to/package/node_modules/css-tree/cjs/index.cjs

require.resolve('css-tree/definition-syntax-data')
// /path/to/package/node_modules/css-tree/cjs/data.cjs

require.resolve('mdn-data/css/properties.json', { paths: [process.cwd()] })
// /path/to/package/node_modules/css-tree/node_modules/mdn-data/css/properties.json

// Раньше я использовал пакет resolve для разрешения путей относительно заданной локации, так как require.resolve() не имел опции paths (опция появилась в Node.js 8.9). Проблема resolve в том (помимо дополнительной зависимости, которая тянет с собой еще 4 пакета), что это эмуляция поведения Node.js и между ними есть отличия, например, resolve не учитывает exports пакета. Правда у resolve есть асинхронное API и гораздо больше опций, но этим никогда не приходилось пользоваться.

В ESM пока нет аналога require.resolve(). Есть экспериментальная реализация import.meta.resolve() за флагом --experimental-import-meta-resolve, но это не то чем можно пользоваться прямо сейчас (из-за необходимости использовать флаг). Поэтому остается использовать что-то вроде пакета resolve(), либо создавать require в ESM модуле. Я склоняюсь ко второму варианту, так как он учитывает все правила разрешение путей в Node.js:

import { createRequire} from 'module';
const require = createRequire(import.meta.url);

require.resolve('package/entry, { paths: [process.cwd()] });

Главный минус такого способа, что пути всегда разрешаются для CommonJS, даже если пакет Dual и поддерживает ESM. А вот import.meta.resolve() разрешает в первую очередь в ESM версию, и делает это асинхронно.

// В CSSTree Validator можно посмотреть пример загрузки кастомного репортера через resolve + import(). Тогда я использовал пакет resolve, так как узнал об опции paths для require.resolve() только в ходе написания этого текста 🤗 Планирую переделать на require.resolve() при возможности.

ESM: загрузка JSON

Кажется, что require() поддерживал загрузку JSON с самого начала существования Node.js. ESM проектировался только для загрузки JavaScript, поэтому загрузить JSON используя import не предусматривалось. Но разработчикам этого хотелось, да и бандлеры это поддерживали, а вот без бандлеров появлялись сложности. И дело не в том, что нужно самостоятельно считать контент файла и применить к нему JSON.parse(). А в том, что возникает сложность переносимости кода (в Node.js для загрузки контента нужно использовать fs, а в браузерах fetch()), которую могло бы снять расширением возможностей import.

Прорабатывая вопрос возможности загрузки JSON через import пришли к тому, что было бы здорово загружать не только JSON, а еще, например, WebAssembly, CSS и др. В итоге пришли к import assertions. Так, каноничный способ импорта JSON в ESM стал таким:

import data from 'foo.json' assert { type: 'json' };
// или
import('foo.json', { assert: { type: 'json' } });

Хорошая новость в том, что это уже работает в некоторых браузерах. Плохая – не работает в Node.js. С помощью флага --experimental-json-modules можно включить загрузку JSON через import, но с варнингом и без поддержки assert (валится на парсинге).

В Node.js чтобы загрузить JSON в ESM модуле, нужно прибегать к модулю fs. Пример из документации:

import { readFile } from 'fs/promises';
const json = JSON.parse(await readFile(new URL('./dat.json', import.meta.url)));

Конструкция более громоздкая чем const json = require('./dat.json'), но работает. Правда есть пара моментов.

Во-первых, с таким кодом не справятся бандлеры, так как используется модуль fs. Во-вторых, что важнее, это не сработает, если нужно загрузить JSON из другого пакета. Такая ситуация возникла в CSSTree, где нужно загружать JSON словари из пакета mdn-data. В этом случае нам необходимо проресолвить путь до пакета, а уж потом прочитать файл и использовать JSON.parse(). И для этого нет других вариантов кроме require.resolve() или его аналога:

import { createRequire } from 'module';
import { readFileSync } from 'fs';

const require = createRequire(import.meta.url);
const mdnSyntaxes = JSON.parse(readFileSync(require.resolve('mdn-data/css/syntaxes.json')));

На самом деле там подкючается несколько словарей, так что последняя строка повторяется 3 раза, но не суть. Для сравнения как это выглядело в CommonJS:

const mdnSyntaxes = require('mdn-data/css/syntaxes.json');

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

// Насколько мне известно, далеко не все банлеры поддерживают import assertions. У меня не получилось найти достаточно информации по поддержке import assertions со стороны браузеров и тулинга, но кажется что поддержка пока еще весьма скромная.

Пример выше можно упростить. Ведь мы создаем require, который умеет загружать JSON, а значит нам не нужен fs, JSON.parse() и require.resolve():

import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const mdnSyntaxes = require('mdn-data/css/syntaxes.json');

Стало проще, но по сути мы принесли CommonJS в ESM, хоть и "временно". На данный момент это наиболее оптимальный вариант загрузки JSON с точки зрения переносимости. Но к этому мы еще вернемся.

ESM: итоги

Выше разобраны некоторые ситуации, с которыми можно столкнуться при переходе на ESM, и с которыми столкнулся я сам. К списку можно добавить:

  • Через import пока нельзя загружать Native Module. Сам с таким не сталкивался, но решением будет использовать require() через createRequire();
  • В ESM нет аналога require.main. Этот флаг полезен, когда скрипт может подключаться как модуль. То есть когда модуль является точкой входа (node script.js), то require.main будет true и выполняем основную логику. А когда модуль подключается как зависимость другого модуля, то require.main равен false и делается только экспорт чего-то полезного. В ESM require.main нужно вычислять самостоятельно от import.meta.url, либо использовать готовое вроде es-main;
  • В ESM нет аналога require.cache, используется отдельный кеш, которым нельзя управлять. Это проблема для некоторых типов проектов, в том числе тест раннеров, которые работают в watch режиме. Насколько знаю, хорошего решения этой проблемы пока нет;
  • В ESM нет NODE_PATH. В этом случае предлагается использовать symlinks;

В целом, выглядит так, что у ESM еще хватает проблем в Node.js, и многое еще предстоит решить. Поэтому переход на ESM может подойти не каждому проекту. Но это зависит от типа проекта, какие то проекты ни одна из проблем не коснется. В моем случае, в процессе переходе на Dual, большую часть времени занимало обновление кода на современный синтаксис и переход на ESM. И если говорить именно про перевод на ESM, то время тратилось на разруливание проблем и поиск решений, в некоторых случаях реорганизацию импортов и экпортов, и сопутствующий рефакторинг.

image

Найти простую утилиту по конвертации CJS в ESM у меня не вышло, но я и не сильно искал. Так как в этом неплохо помогает VS Code (в других IDE, думаю, тоже есть что-то подобное). Я производил конвертацию для каждого файла отдельно и отсматривал что и как поменялось. Иногда VS Code не очень удачно заменяет на named exports, код теряет выразительность, да и не со всеми кейсами справляется. А еще при конвертации не проставляет расширения файлов в путях импортов, без которых ESM не заведется. Но в целом, здорово упрощает задачу.

На этом с ESM частью всё. Переведя код на ESM, получаем Pure ESM модуль, который можно публиковать. Но не стоит с этим торопиться. Лучше сделаем его Dual ;)

ESM → CJS: виды конвертации

Есть два основных подхода к конвертации ESM в CJS:

  • Максимальноe приближенение к поведению ESM, то есть когда CJS модуль пытается выдать себя за ES модуль. Такая конвертация производится, когда выполняется require() ES модуля в бандлерах или import() ES модуля в CJS модуле в Node.js (напомню, что через require() подключать ESM в Node.js нельзя). И, пожалуй, этот вид трансформации часто используется в библиотеках, когда они поставляют CJS формат.

  • Второй вид трансформации (его иногда называют loose), когда результирующий код выглядит так, как если бы его писали в формате CommonJS. В таком случае, полученный CJS модуль может иметь расхождения в поведении импортов с ESM версией, от которой произведен.

После погружения в тему и некоторых размышлений, я пришел к выводу, что для Dual пакетов должен использоваться второй вид конвертации.

Во-первых, CJS модулю нет необходимости выдавать себя за ES модуль, так как его назначение это подключение в CJS модулях. А для ES модулей, которые подключают библиотеку, это тоже не нужно, так как им будет отдаваться ESM версия кода. Получать в CommonJS модуле CommonJS код, который выдает себя за ESM, выглядит странным: да, мы можем подключить его через require(), но теряется эргономика, например, появляется default в импортах. Также в этом случае добавляется вспомогательный код, т.н. interop code, которого может быть достаточно много, что затрудняет чтение кода, его отладку и т.д.

Во-вторых, CommonJS и ESM разные миры, каждый со своими правилами. Да, они могут смешиваться, пусть и без нюансы. Но если модули стыкуются CJS → CJS или ESM → ESM, то это должно быть естественным, без имитаций и лишних проблем. Другими словами, если ESM модуль подключает пакет с поддержкой ESM, то ожидается получить код, который ведет себя как ESM, а для CommonJS модуля – как CommonJS. Именно для этого и делается Dual пакет и две версии кода.

ESM → CJS: определяем требования к результату

Для себя я сформулировал следующие требования к результату трансформации ESM → CJS:

  • Модули должны оставаться модулями, то есть отдельными файлами, а не одним бандлом (если в библиотеке не один модуль, иначе плохо для отладки);
  • ESM конструкции (import/export) должны быть заменены на CJS (require/exports). В идеале изменения кода должны быть только в этой части, остальной код должен остаться нетронутым (в плане форматирования);
  • Не должно быть interop кода или значений в экспортах (__esModule, default и т.д.), связанного с ESM. В идеале код должен выглядеть так, как будто написан человеком в CommonJS;
  • Результирующие модули должны быть с расширением .cjs (иначе Node.js будет трактовать их как ESM);
  • В путях require() расширение должно быть заменено на .cjs (иначе не будет работать).

Возможно со мной могут поспорить, что не все пункты важны, чем-то можно пожертвовать и упростить себе жизнь. Но Dual делается не для галочки, а чтобы соответствовало ожиданиям пользователей как для ESM, так и для CommonJS. Также CommonJS должен быть паритет между CommonJS и ESM, как в плане функциональных возможностей, так и опыта использования. На мой взгляд, именно компромиссы и приводят к плохого качества CJS версии библиотек, что создает негативное отношение к Dual.

Кроме того, все пункты имеют смысл. Например, отдельные модули это не только про отладку, но и про различные точки входа (entry points). Из CSSTree можно подключать только необходимую функциональность, например, require('css-tree/parser'), уменьшая тем самым количество загружаемых модулей и время загрузки. Если отказаться от модулей в сторону "бандла", то для каждой точки придется делать свой бандл, а это дополнительная настройка сборки, увеличение размера пакета, дублирование выполняемого кода и т.д. Если отказаться от entry points в CJS, то не будет паритета функциональности с ESM. Если отказаться от entry points совсем (ради паритета), то у пользователей не будет возможности оптимизации времени загрузки кода и размера бандла (а для некоторых пользователей это важно, поэтому когда-то и появились).

В общем, идя на компромиссы в этих вопросах, приходится идти на компромиссы и в других, даже от чего то отказываться. Забегая вперед, скажу, что поставленные требования выполнить возможно (иначе бы этого треда и не было бы ;)). Но получилось не сразу, путь был тернист...

ESM → CJS: Babel

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

Трансформация запускается командой:

npx babel src -d cjs --delete-dir-on-start --out-file-extension .cjs

где указывается путь к папке с исходным кодом (src), флагом -d путь для результата, флагом --delete-dir-on-start просим удалить папку с результатом перед трансформацией (для чистого результата), а --out-file-extension задает расширение результирующим файлам.

image

Команда запускается с такой конфигурацией (файл .babelrc для трансформации ESM → CJS).

Рассмотрим что тут происходит:

  • Основной плагин @babel/plugin-transform-modules-commonjs, он переводит ESM в CJS. Для него отключается любой interop код опцией "importInterop": "none". А с помощью "strict": true отключаем добавление свойства __esModule на exports, так как нам не нужно чтобы модуль выдавал себя за ESM;

  • Так как основной плагин не справляется с реэкспортом неймспейсов, то требуется @babel/plugin-proposal-export-namespace-from – этот плагин рекомендовался в ошибке трансформации;

  • babel-plugin-transform-import-meta трансформирует import.meta.url в выражение совместимое с CommonJS;

  • babel-plugin-add-import-extension заменяет расширения в путях импортов на .cjs;

  • @reactioncommerce/babel-remove-es-create-require убирает выражения вида const require = createRequire(import.meta.url), так как в CommonJS они становятся не нужны. К сожалению, все еще остается require('module'), которые ничего не делают. Я пробовал использовать плагин для удаления неиспользуемых импортов (с говорящим названием babel-plugin-danger-remove-unused-import), но он не справился – поэтому от него отказался.

В целом это дает результат близкий к рабочему (я тестировал на CSSTree).

image

Единственное, что не получилось решить это отключить default в экспортах и импортах, простой пример на скриншоте. Для модулей самого пакета это не мешает. Но проблема с внешними зависимостями, которые обычно экспортируют через module.exports, а не exports.default. Так что придется применять напильник, там где подключаются внешние зависимости (то есть нужен отдельный скрипт, который исправит такие места в результате).

// В целом, впечатление, что при конвертации ESM во что-то другое, этот ESM пытается пролезть в результат всеми правдами и не правдами. Это мне кажется очень странным, ведь если мы избавляемся от ESM, то будем запускать полученный код, где он не поддерживается и там не нужно знать, что модуль когда-то был ESM. В этом был бы смысл, если бы в Node.js можно было подключать ESM через require(). Однако это возможно только в мире сборщиков, и выходит, что Babel на данный момент заточен именно под специфику сборщиков, а не трансформацию общего характера. Например, сценарий конвертации ESM → чистый CJS не продуман.

Я верю, что это можно доработать в Babel и его плагинах (или даже настроить, возможно я что-то не нашел). Но прямо сейчас такие проблемы:

  • Критично:

    • Конструкция import foo from '...' всегда превращается в аналог var foo = require('...').default. Это не проблема при импорте модулей самого пакета, так как они используют exports.default для экспорта (после трансформаций Babel), но проблема для внешних зависимостей, у которых скорей всего не будет default, если они чистые CommonJS;
  • Не критично:

    • Несмотря на все настройки у меня не получилось полностью избавиться от interop кода, который по большому счету не нужен. Иногда его очень много, особенно когда включается plugin-proposal-export-namespace-from. Это не делает код похожим на то, что его писали в CommonJS, чего хотелось бы;
    • Форматирование кода меняется под предпочтения Babel, иногда очень существенно меняется. Это, конечно, не влияет на работоспособность, но хотелось бы сохранения авторского форматирования.

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

В завершении:

  • Решение нельзя назвать универсальным, результат далек от идеала. Настройку нельзя назвать простым процессом, было сложно искать нужное, документация и DX хромают;
  • Требуется 99 dev-зависимостей в node_modules, ~13Mb на диске. Конечно, если в проекте используются другие решения на основе Babel, то эффект будет меньше;
  • Для CSSTree на моем M1 трансформация отрабатывает за 720ms, генерируется 168 модулей, 856Kb на диске.

ESM → CJS: esbuild

Вот уже чуть больше года я не только активно слежу за проектом esbuild, но и активно с ним экспериментирую. Он стал моим основным бандлером (до этого был rollup, до него browserify) и я стараюсь переводить на него все свои проекты, как только предоставляется возможность поработать над ними. Инструмент достаточно простой, стабильный, предсказуемый и супер быстрый. Я еще расскажу про свой опыт с esbuild. Пока же скажу, что не было чего-то такого, что я не мог бы сделать используя его. Хотя не все решения идеальны и иногда приходится прибегать к напильнику.

У esbuild также есть возможность трансформации кода как отдельных модулей, он умеет конвертировать как в ESM, так и в CJS. И так как он уже используется во многих моих проектах как бандлер, у меня были большие надежды на то, что я смогу с ним и провернуть трансформацию ESM → CJS. К сожалению, эти надежды не оправдались.

Проблемы:

  • Если Babel'ю (@babel/cli) достаточно указать директорию, то esbuild нужно указать каждый файл отдельно, если мы не делаем бандл. То есть нужно просканировать самостоятельно папку с исходниками и составить список всех необходимых модулей;
  • Технически можно задать расширение результирующих модулей через шаблон, но нельзя поменять пути в импортах; таким образом, текст модулей нужно обработать с целью поменять расширение к модулям (возможно это можно решить через плагин, но я тогда не нашел способа это сделать);
  • Не распознает createRequire() и не умеет это удалять, нужно вырезать самостоятельно на "пост-процессинге" (вероятно можно решить через onLoad плагин);
  • Добавляет много interop кода и это никак не отключить. В целом, esbuild старается сделать так, чтобы результирующий CJS, произведенный из ESM, был максимально близок к поведению ESM. В этом плане, вероятно, esbuild добился лучших результатов. Но нам нужно совсем другое. Вот ишью, где Evan Wallace (автор esbuild) отвечает, что поведение esbuild правильное и его менять не нужно (и где-то был gist с развернутой позицией, но я не смог найти);
  • Теряется оригинальное форматирование и комментарии.

Кажется было что-то еще, что уже забыл (что-то еще ломалось). Но даже если решить большую часть проблем самостоятельно, то проблема interop'а никак не решается. Этот момент делает невозможным получение настоящего CommonJS. Поэтому на данный момент esbuild технически не подходит для проблемы перевода ESM в CJS. Очень жаль.

image

Скриншот, чтобы прочувствовать проблему interop'а при конвертации ESM → CJS с esbuild. Стоит заметить, что в данном случае трансформирован код с одним простым импортом и экспортом. На модулях посложнее ситуация еще печальнее.

ESM → CJS: rollup

Как можно заметить, в поиске решения все было не просто. Каково же было моё удивление, когда решением в итоге стал rollup, "списанный" с позиции бандлера. Списан был за то, что не такой простой и не такой быстрый как esbuild, особенно когда дело начинало касаться карт кода и минификации. Но в плане трансформации ESM → CJS он оказался хорош.

image

Не буду тянуть, сразу к результату на том же простом примере. Если не брать в расчет, что rollup использует переменные прежде чем добавить значение в экспорт – результат превосходный. Более того, основной код сохраняет форматирование и комментарии на месте (хоть это не очень заметно на данном примере).

Пара моментов:

  • rollup не справляется с createRequire(), поэтому от этих конструкций нужно избавляться самостоятельно (если они есть). Есть ишью на этот счет, но там пока без движения;
  • Стоит избегать использование default export и named exports в одном модуле. Rollup это решает тем, что в таких случаях использует exports.default вместо module.exports для export default, но также выводит варнинг. То есть это будет работать, и если модуль внутренний, то переживать о этом не стоит. Однако лучше этого избегать.

В целом, метод универсальный и не вызвал проблем на проектах, которые я переводил на Dual. Единственная проблема, если можно это так назвать, что для этого нужно написать скрипт, использующий API rollup'а. Но этот скрипт пишется один раз и потом его можно переиспользовать между проектами с незначительными изменениями.

image

Базовый скрипт для конвертации ESM в CJS с использованием rollup. Основным является указание папки для результата и входных модулей (entry points). Rollup в целом ведет себя как бандлер: по входным модулям разворачивает граф зависимостей. Но вместо бандла (объединения модулей) раскладывает модули также, как они находятся в исходной локации – это задается опцией preserveModules. Опция entryFileNames меняет расширение модулей, если это не нужно, то достаточно удалить то эту строку.

Скрипт пишется в CommonJS, так как может использоваться для тестирования на версиях Node.js без поддержки ESM.

Можно заметить, что в конфигурации есть опция treeshake, которую привычней видеть при бандлинге. Однако здесь она позволяет игнорировать возможные сайд эффекты модулей. Иначе rollup может внезапно добавлять новые require(), с этой опцией это не происходит (вообще, это больше похоже на баг rollup и возможно это поправят). Вероятно, вместо шортхенда 'smallest' нужно использовать более гранулярные настройки (например, когда используются feature detection в модулях или на самом деле есть сайд эффекты), но в моем случае сработало и без этого.

Если требуется немного доработать логику работы rollup, есть механизм плагинов. В данном случае "плагин" достаточно условное определение – это набор функций (хуков), которые реагируют на разные события вроде разрешения путей, чтение или трансформацию контента модулей и т.д. Например, с помощью простого "плагина", который определяется прямо в самом скрипте, я избавлялся от конструкций createRequire():

image

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

Примеры скрипта трансформации ESM → CJS в CSSTree и CSSO. Они не сильно отличаются, и я все еще по немногу совершенствую скрипт.

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

Немного цифр:

  • Метод требует единственную зависимость – сам rollup, ~4,9Mb на диске (если не брать в расчет опциональный fsevents);
  • Для CSSTree на моем M1 трансформация отрабатывает за ~380ms, генерируется 166 модулей, 885Kb на диске.

В сравнении с Babel, это в 99 раз меньше зависимостей, в 3 раза меньше в node_modules на диске и в 2 раза быстрее.

Dual package: Конфигурация

Получив версию пакета в ESM и CommonJS, остается только закончить конфигурацию пакета. Для упрощения восприятия, считаем, что ESM код располагается в папке esm, а CommonJS – в папке cjs.

Итак необходимо:

  • Добавить в .gitignore папку cjs, этот код будет попадать только в состав npm-пакета (не нужно его комитить в git);
  • В package.json:
    • Добавить обе папки в files;
    • В поле main указать путь до индексного CommonJS;
    • В поле module указать путь до индексного ESM;
    • Сконфигурировать поле exports.

image

Ключевые настройки для Dual пакета в package.json Разберем некоторые поля подробнее.

В поле files указываем папки с кодом. При публикации файлы package.json, README и LICENSE добавляются автоматически. Чтобы проверить, что попадет в пакет можно использовать команду npm publish --dry-run – с ней происходит все тоже, что и при публикации, только пакет не отправляется в npm. В конце выводится список файлов, который войдет в пакет, который и стоит проверять.

В поле main указывается путь к главному CommonJS модулю, так как это единственное поле, которое понимают версии Node.js без поддержки ESM. Node.js с поддержкой ESM игнорирует поле main при наличии exports.

Поле module – изобретение бандлеров и других инструментов. Оно появилось в тот период, когда в Node.js еще не придумали exports. Так как сегодня все больше инструментов поддерживают exports (что правильно), то в поле module все меньше необходимости. Я при переводе на Dual его не указывал, так как в моем сетапе работало и без него. Но для обратной совместимости его будет не лишним указать (я начал добавлять). В этом поле указывается главный ESM модуль.

// Важно: в Node.js никогда не было поддержки поля module, поэтому оно необходимо только некоторым инструментам: либо тем, которые еще не поддержали exports, либо их старым версиям.

И главное – поле exports, которое еще называют package entry points. Это поле продвинутый вариант module, который покрывает гораздо больше кейсов. Секция "." современный аналог main, в ней можно указать какой файл использовать для import и require. Это т.н. сonditional exports, сценариев использования которого гораздо больше. Например, Node.js поддерживает еще node, node-addons и default. Есть и т.н. community conditions definitions, которые включают в себя type, deno, browser, development и production. А webpack поддерживает, например, style, sass, script и другие (в целом стоит полистать гайд от Webpack по теме, там есть интересные моменты). Интересно так же и то, что для Node.js условия можно задавать флагом --conditions при запуске.

Важный момент при использовании exports, что при его использовании другие пакеты смогут импортировать только то, что указано в exports (не важно как импортировать – import или require(), это распространяется и на require.resolve() и import.meta.resolve()). Можно сказать, что все что не указано в exports становится несуществующим для внешних пакетов. Именно поэтому стоит добавлять package.json в exports, так как его импорт или ресолв на него могут быть полезным. Кажется, что exports ограничивает возможности для пользователей пакет, но это дает больше контроля пакету над тем, что получают использующие его пакеты, и возможность безопасно проводить рефакторинг внутреней структуры модулей.

Еще одной полезной возможностью exports является то, что можно задавать дополнительные точки входа (entry points) для самостоятельных частей пакета. Так в CSSTree заданы точки входа для токенайзера, парсера и других. Все очень похоже с основной точкой входа:

"exports": {
    ".": {
      "import": "./lib/index.js",
      "require": "./cjs/index.cjs"
    },
    "./package.json": "./package.json",
    "./tokenizer": {
      "import": "./lib/tokenizer/index.js",
      "require": "./cjs/tokenizer/index.cjs"
    },
    "./parser": {
      "import": "./lib/parser/index.js",
      "require": "./cjs/parser/index.cjs"
    },
    ...
}

Последнее, что остается сконфигурировать для Dual пакета – это поддерживаемые версии Node.js. Поддержка ESM в Node.js начала появляться начиная с ранних версий 12й ветки. Однако, согласно документации и моим экспериментам, поддержка стабилизировалась в 12.20, 14.13.0 и 15.0.0. Что-то может работать и в более ранних версиях, однако в них могут быть проблемы, особенно касательно exports. Поэтому лучше остановиться на указанных версиях.

// В документации почти нет информации про 13ю ветку и я с ней не экспериментировал. Не знаю, насколько это актуально, принимая в расчет, что это нестабильная (нечетная) ветка, и есть ли в ней стабилизированная поддержка ESM.

Отсутствие поддержки ESM в более ранних версиях Node.js (до 12й) не означает, что Dual модуль не может в них использоваться. Эти версии могут использовать CommonJS версию пакета, именно поэтому в main указан путь к главному модулю CommonJS. Технический ограничений поддержки "вниз" (более ранних версий Node.js) для Dual модуля нет. Ограничения накладываются используемым синтаксисом, минимально необходимыми возможностями Node.js и используемыми инструментами, вроде тест раннеров (хотя это не касается пользователей пакета). Первую проблему можно снять при трансформации ESM → CJS, понизив версию JavaScript в процессе. На данный момент я остановился на поддержке Node.js 10, но технически, если очень нужно, можно опуститься и ниже.

Поэтому выставляем поддержку Node.js таким образом:

"engines": {
    "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}

Dual package: Unit тестирование

При создании Dual пакетов важно протестировать обе версии кода и на одном наборе тестов: ESM и CJS. Это имеет смысл, так как могут быть проблемы транспиляции, как при переводе ESM в CJS и понижении версии JavaScript, так и в настройках exports.

Есть разные подходы к организации тестов и расположению их файлов. Но по сути тесты можно условно разделить на две категории: белый ящик (white box) и черный ящик (black box).

В случае, если модуль теста подключает модуль пакета напрямую по относительному пути – это всегда белый ящик. При этом не важно, располагается файл теста рядом с тестируемым модулем или в отдельной общей папке для тестов – это все равно белый ящик. Так как тест имеет привилегированный доступ к ресурсам пакета в обход правил для внешних пакетов, того же exports. Такие тесты ничем не плохи, и часто нужны, особенно для прямого тестирования внутренних частей (когда, например, тестируется функция напрямую, а не через использующие ее функции). В таком случае, тесты стоит хранить в папке с основным кодом и переводить их в CJS как и основной код (для этого нужно просканировать папку с исходным кодом и добавить все файлы тестов как входные точки для rollup). Тогда тесты не сломаются и относительные пути продолжат работать без изменений.

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

В этой проблеме на помощь приходит self-referencing, который был добавлен в Node.js 12.16 и 13.6. Этот механизм позволяет подключать пакет (его входные точки) внутри самого пакета. То что нужно для black box тестирования. Так тест подключает сам пакет и работает только с публичными API. При транспиляции в CJS не нужно менять пути, так как используется имя пакета (или другая точка входа) и если тест является ESM модулем, то импортируется ESM версия, а если модуль CJS – то CommonJS версия. Я предпочитаю располагать такие тесты в отдельной папке, и они отдельно транспилируются в CJS в rollup скрипте. На мой взгляд, основная часть тестов пакета (если не все), должны быть именно такими. Такой подход позволяет также протестировать и настройки exports (желательно наличие тест(ов) на подключение всех точек входа). Стоит вспомнить, что невозможно подключить ESM через require() и, в данном случае, это играет на руку, так как если в CJS модуль вдруг прилетит ESM, то все взорвется (а в данном случае, это то что нужно). Можно заметить проблему, что ничего не взорвется если в ESM прилетит CJS (ведь это "легально"). Однако, если предварительно не сгенерировать CJS версию, то взорвется, потому что модуль еще не будет существовать. Это можно пропустить локально (если CJS сгененирован до этого), но это определенно всплывет на CI (об этом будет дальше).

Есть еще одна проблема, если мы хотим тестировать на младших версиях Node.js (например, 10), где self-referencing еще не поддерживался, то тесты сломаются. В этом случае, нужно заменить импорты (а точнее require()) по имени пакета на импорты по относительному пути. Для этого я использовал дополнительный самописный "плагин", который имеет feature detection – если резолв по имени пакета не работает, то заменяем имя пакета на относительный путь. Так как пакет тестируется на разных версиях Node.js, то там где поддерживается self-referencing будет тестироваться по честному, а где нет – как минимум на поддержку JavaScript синтаксиса и функциональность. Конечно, "плагин" еще нужно прокачать, чтобы его можно было использовать под любой проект без допиливания, но пока такой задачи не стояло.

Еще один момент – это фикстуры (статичные файлы вроде JSON, CSS и т.д.). Например, в CSSTree и CSSO их большое множество. И если хранить фикстуры рядом с тестами, то при транспиляции их нужно копировать отдельно. Решением проблемы является вынос фикстур в отдельную папку в корень пакета. Таким образом, ESM и CJS тесты будут работать с одними и теми же файлами фикстур, не нужно их лишний раз копировать или менять к ним путь.

В целом, организация тестов в Dual пакетов не выглядит сильно накладным мероприятием. Этому помогают, как полученные ранее решения (транспиляция ESM → CJS), правильная организация расположения файлов (например, фикстуры в отдельной папке в корне пакета) и новые возможности Node.js вроде self-referencing (хоть в случае со старыми версиями Node.js и нужно подпиливать).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment