Уже 10 лет в JS-экосистеме воюют два формата модулей: CommonJS и ES Modules. Чтобы и получить плюшки ESM, и не распугать пользователей, npm-пакеты часто используют dual packaging: собирают код в оба формата. Это решает одну проблему, но создает несколько новых:

  1. Мы собираем наш код 2 раза (а хотелось бы вообще не собирать).

  2. Настроить двойную сборку не супер сложно, но все таки сложнее, чем вообще не настраивать.

  3. Мы публикуем в 2 раза больше кода (и потом жалуемся на жирые node_modules)

  4. dual package hazard — если библиотеку подключить и через import, и через require, она задвоится и может поломать instanceof / глобальное состояние / Symbol().

Забавный случай произошел в 2021 году — sindresorhus, великий опенсорсер (кроме шуток), решил публиковать все свои пакеты только в ESM. Потом все смеются над ним, ха-ха посмотрите, народ сидит на версиях его библиотек из 2020. Мне кажется, этот случай немного дискредитировал всё движение в сторону esm-only, но в целом-то Синдре был прав, просто немного поспешил и людей насмешил.

Прошло еще 4 года, пора еще раз подойти к вопросу, как публиковать npm-пакеты — в esm, в cjs, или всё-таки оба? Мы разберем:

  1. Какие проблемы решает dual packaging, и есть ли решения получше?

  2. Правда ли у ESM есть какие-то преимущества?

  3. Для чего именно нам нужна поддержка CJS?

  4. В конце вас ждет шпаргалочка — когда какой формат выбрать?

Если вы не занимаетесь опенсорсом — не убегайте! Вопрос актуален и для внутренних библиотек, и для простых разработчиков (хотя бы чтобы понимать, откуда у вас в node_modules 2 Гб). Поехали!

Что такое dual packaging?

Откуда вообще взялась схема с двумя сборками? В JS не было системы модулей, и в районе 2011, вместе со взрывом фронтенда начали расти как грибы userland реализации модулей — AMD! CommonJS! UMD! Из всех этих реализаций победил CJS (require / module.exports), который нативно работал в nodejs. В 2015 JS наконец получил стандартную систему модулей ESM (import / export) — но совсем непохожее на userland реализации. В 2019 nodejs поддержала ES modules, но их нельзя было использовать из CJS-кода, которого оставалось (да и еще остается) много.

Получается, ESM лучше работает в современном тулинге, но не совсем поддерживается в nodejs. Чтобы никого не обидеть и получить плюшки ESM, мы положим на npm две версии нашей библиотеки и разрулимся между ними через package.json:

{
    "module": "./esm/index.esm",
    "main": "./cjs/index.cjs",
    "exports": {
        ".": {
            "import": "./esm/index.esm",
            "require": "./cjs/index.cjs",
        }
    }
}

Да, проблему это решает, но удваивает размер нашей библиотеки. Может, можно упихнуть оба формата в один файл?

export const hello = 'hello';
if (typeof module !== 'undefined') {
    module.exports.hello = hello;
}

Это не сработает, потому что nodejs "красит" все файлы в cjs / esm цвет в зависимости от расширения .cjs / .esm или type в ближайшем package.json. В cjs-файле токен export вызовет SyntaxError, в esm никогда не будет объекта module. Обернуть export в try / catch тоже не выйдет, потому что export может быть только на верхнем уровне файла. В общем, dual packaging — единственный способ полноценно поддержать оба формата.

Чем ESM лучше CJS

Тогда зайдем с дургой стороны. Может, ESM ничем не лучше CJS, и всю проблему придумали хипстеры, чтобы не работать? Эстетические аргументы (приятнее синтаксис / стандарт лучше местечковой поделки / новое лучше старого) опустим, только практика:

  1. Почти в любом браузере в 2025 (caniuse) ESM работает из коробки. Значит, ESM-библиотеку можно использовать вообще без установки и без бандлера, через jsdelivr / unpkg. Но это пока маргинально, едем дальше.

  2. ESM легко тришейкается, CJS — посложнее, потому что для бандлера это просто ковыряние в объекте exports.

  3. Для CJS нужно подпихивать в браузерный бандл "рантайм" из функции require и объекта exports

Эксперимент! Возьмем примитивную "библиотеку" из двух констант, но в "приложении" используем только одну:

// lib.cjs, изображает cjs-библиотеку
module.exports.hello = 'hello';
module.exports.world = 'world';

// lib.mjs, изображает esm-библиотеку
export const hello = 'hello';
export const world = 'world';

// user.mjs, изображает приложение
import { world } from 'lib';
console.log(world);

Мы хотим, чтобы в браузер клиента поехал только world, а hello отрезался. Давайте сначал попробуем сделать это в уме. Для ESM все просто: сцепляем файлы, удаляем токены import / export

// не используетсяа
const hello = 'hello';
const world = 'world';
console.log(world);

Теперь отрезаем неиспользуемые переменные, вы великолепны! Для CJS посложнее: подпихнем объект exports...

// рантайм
const exports = {};
// lib
exports.hello = 'hello';
exports.world = 'world';
// user
const { world } = exports;
console.log(world);

Теперь минификатору нужно найти все использования exports, посмотреть, какие его поля использутся, и удалить одно из присваиваний. Потно!

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

// vite
var r={},l;function o(){return l||(l=1,r.hello="hello",r.world="world"),r}var e=o();console.log(e.world);
// vite + terser
var l,o={};var r=(l||(l=1,o.hello="hello",o.world="world"),o);console.log(r.world);
// webpack
(()=>{var r={346:r=>{r.exports.z="world"}},o={};function t(e){var s=o[e];if(void 0!==s)return s.exports;var n=o[e]={exports:{}};return r[e](n,n.exports,t),n.exports}(()=>{"use strict";var r=t(346);console.log(r.z)})()})();
// esbuild
(()=>{var h=Object.create;var s=Object.defineProperty;var m=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var c=Object.getPrototypeOf,f=Object.prototype.hasOwnProperty;var g=(l,o)=>()=>(o||l((o={exports:{}}).exports,o),o.exports);var i=(l,o,r,p)=>{if(o&&typeof o=="object"||typeof o=="function")for(let e of x(o))!f.call(l,e)&&e!==r&&s(l,e,{get:()=>o[e],enumerable:!(p=m(o,e))||p.enumerable});return l};var n=(l,o,r)=>(r=l!=null?h(c(l)):{},i(o||!l||!l.__esModule?s(r,"default",{value:l,enumerable:!0}):r,l));var t=g((b,d)=>{d.exports.hello="hello";d.exports.world="world"});var w=n(t(),1);console.log(w.world);})();

hello отрезал только webpack. Также обратите внимание, что код везде довольно мерзкий за счет cjs-рантайма (особенно в esbuild).

Fun fact: если записать lib.cjs в виде module.exports = { hello: 'hello', world: 'world' }, webpack тоже проиграет: (()=>{var r={346:r=>{r.exports={hello:"hello",world:"world"}}},o={};function e(t){var l=o[t];if(void 0!==l)return l.exports;var s=o[t]={exports:{}};return r[t](s,s.exports,e),s.exports}(()=>{"use strict";var r=e(346);console.log(r.world)})()})();

Для чистоты эксперимента повторим упражнение с esm-сборкой. Все молодцы, все справились! Рантайм тоже исчез, и итоговый код стал вполне приятным:

  • vite: const o="world";console.log(o);

  • vite + terser console.log("world");

  • webpack (()=>{"use strict";console.log("world")})();

  • esbuild: (()=>{var o="world";console.log(o);})();

Результаты:

бандлер

три-шейкинг cjs

размер cjs

размер esm

vite

нет

106

32

vite+terser

нет

84

22

webpack

да

222

44

esbuild

нет

635

41

Легенда подтверждена! CJS-код тришейкается по настроению левой пятки бандлера и мусорит в бандле. Наверняка эту проблему можно порешать какими-то плагинами, но просить всех пользователей ставить плагины — не очень надежное решение.

Когда CJS не хуже ESM

Обратите внимание, что все плюсы ESM относились к браузеру — они и поддерживают ESM, и бандлом код едет именно туда. Значит, если мы делаем пакет только для nodejs (мидлвар express / плагин stylelint / придумайте сами), CJS не вызовет никаких проблем, и ESM не нужен!

С натяжечкой можно сказать, что плюсы ESM не так важны, когда три-шейкинг не нужен, потому что пакет состоит из одной функции. Аналогично для семейства несвязанных функций (например, date-fns или lodash), но только если каждая функция живет в отдельном энтрипойнте (да: import addDays from 'date-fns/addDays', нет: import { addDays } from 'date-fns'). Натяжечка заключается в том, что cjs-рантайм все равно добавится.

В остальных кейсах (пакет может работать в браузере, и три-шейкинг нужен) ESM выигрывает.

Поддержка ESM в nodejs

На первый взгляд node 10 — последняя версия, где ESM не работает — ушла с поддержки в 2020 году, и уже давно пора перекатываться. Но есть нюанс.

В спеке ES Modules зарыта свинья — top-level await. Не будем обсуждать, зачем его туда включили, но факт — статический import { f } from './some-module' может оказаться асинхронным, и нужно подождать все await в поддереве. Как же поддержать синхронный require(esm), чтобы я мог потихоньку подключать esm-пакетики в свой cjs-код? node 12 даёт решительный ответ: никак, require(esm) не сработает.

Но наконец в node 22 (с бекпортом в node 20) эта несправедливость исправлена, и esm-модули без top-level await можно require. Еще раз, если серверный код ваших потребителей:

  • написан на esm (и не компилируется в cjs) — esm-only библиотеки работают с node >= 12

  • написан на cjs (или компилируется в cjs) — esm-only библиотеки работают с node >= 20

И особый случай: чистые CLI для запуска через npx my-lib никогда не импортируются в код, и тоже работают с node >= 12.

В апреле 2025 node 18, последняя версия без require(esm), дошла до EOL. Все, у кого актуальная версия nodejs, могут испольовать esm-only библиотеки. Все, у кого код на esm, омгут использовать их уже 4 года. Ну уже можно публиковать esm-only. Главное top-level await не делайте.

А вдруг кому-то очень надо CommonJS

Но предположим, вы поддерживаюте огромную кодовую базу на CommonJS на устаревшей версии node. Давайте посмотрим, есть ли у вас способ использовать esm-only библиотеку. Ведь одно дело — оторвать то, что пользователи могут сами починить, а другое — оставить их в безвыходной ситуации.

Во-первых, можно сделать оберточку, которая подпихивает библиотеку в globalThis, и запускает остальное приложение. Довольно мерзко, но работает!

// index.cjs
import("./lib.mjs")
    .then(lib => globalThis.lib = lib)
    .then(() => import('./app.cjs'));
// app.cjs
console.log(globalThis.lib.hello);

Во-вторых, можно где-то сбоку сделать npx esbuild node_modules/lib/index.mjs --bundle --format=cjs, и бамс, у вас есть cjs-библиотека. Дальше подпихнуть ее в код — простая формальность.

Да, это не очень юзерфрендли, но пихать 90% пользователей код, который нужен только 10%, тоже так себе. Предлагаю продуктовую стратегию для новых библиотек: публикуетесь в esm-only, если кто-то жалуется — предлагаете ему эти фиксы, если жалуются часто — делаете dual packaging.

Итого

Ребята, уже пора публиковаться в esm-only. Или в cjs-only, если делаете nodejs-библиотеку. Сегмент dual packaging очень узкий, и будет только сужаться. Вот вам шпаргалочка:

Комментарии (1)


  1. DmitryKazakov8
    04.08.2025 12:15

    Минус dual packaging как верно замечено - только в увеличении node_modules и соответственно размера скачиваемых файлов. Для небольших библиотек это не то, чтобы проблема. Но вот если пакет резко переходит на esm-only (как например Chalk 5 в 2021 году), то только ради него переводить большие проекты на esm - не стоит усилий и бизнес не даст денег на рефакторинг без практической пользы. Тем более что тогда у node.js были серьезные проблемы с использованием cjs из mjs и многие пакеты не заводились, также и старые версии TS влияют (а их апгрейд связан с большими сложностями).

    Даже сейчас есть немало проектов на старых node.js и TS, и если работа проектная или фрилансерская, но требуется использовать какой-нибудь новый пакет - то будет однозначно выбрана версия с dual packaging. И я очень сомневаюсь, что это 10% кейсов, скорее думаю больше 50%, по крайней мере из того, что мне встречается. Вывод - свои библиотеки выдаю в обоих форматах, а минус в виде небольшого увеличения node_modules несущественен по сравнению с увеличенной вдвое совместимостью.

    По крайней мере, на сегодняшний момент так, а через пару лет думаю уже можно на esm-only переходить.