Уже 10 лет в JS-экосистеме воюют два формата модулей: CommonJS и ES Modules. Чтобы и получить плюшки ESM, и не распугать пользователей, npm-пакеты часто используют dual packaging: собирают код в оба формата. Это решает одну проблему, но создает несколько новых:
Мы собираем наш код 2 раза (а хотелось бы вообще не собирать).
Настроить двойную сборку не супер сложно, но все таки сложнее, чем вообще не настраивать.
Мы публикуем в 2 раза больше кода (и потом жалуемся на жирые node_modules)
dual package hazard — если библиотеку подключить и через import, и через require, она задвоится и может поломать instanceof / глобальное состояние / Symbol().
Забавный случай произошел в 2021 году — sindresorhus, великий опенсорсер (кроме шуток), решил публиковать все свои пакеты только в ESM. Потом все смеются над ним, ха-ха посмотрите, народ сидит на версиях его библиотек из 2020. Мне кажется, этот случай немного дискредитировал всё движение в сторону esm-only, но в целом-то Синдре был прав, просто немного поспешил и людей насмешил.
Прошло еще 4 года, пора еще раз подойти к вопросу, как публиковать npm-пакеты — в esm, в cjs, или всё-таки оба? Мы разберем:
Какие проблемы решает dual packaging, и есть ли решения получше?
Правда ли у ESM есть какие-то преимущества?
Для чего именно нам нужна поддержка CJS?
В конце вас ждет шпаргалочка — когда какой формат выбрать?
Если вы не занимаетесь опенсорсом — не убегайте! Вопрос актуален и для внутренних библиотек, и для простых разработчиков (хотя бы чтобы понимать, откуда у вас в 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, и всю проблему придумали хипстеры, чтобы не работать? Эстетические аргументы (приятнее синтаксис / стандарт лучше местечковой поделки / новое лучше старого) опустим, только практика:
Почти в любом браузере в 2025 (caniuse) ESM работает из коробки. Значит, ESM-библиотеку можно использовать вообще без установки и без бандлера, через jsdelivr / unpkg. Но это пока маргинально, едем дальше.
ESM легко тришейкается, CJS — посложнее, потому что для бандлера это просто ковыряние в объекте
exports
.Для 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 очень узкий, и будет только сужаться. Вот вам шпаргалочка:
