Як створити гібридний NPM-модуль для ESM та CommonJS

10 хв. читання

Як легко створити NPM-модуль для ESM та CommonJS? Чи дійсно можна обійтись без двох кодових баз і Webpack? Це питання актуальне вже досить тривалий час.

Створити модуль NPM з єдиної кодової бази, що орієнтується на модулі CommonJS та ES, — це завдання не для слабких духом. Інколи таке рішення називають «гібридним пакетом». Однак не так просто створити NPM-модуль, котрий легко застосувати через import або require.

Стосовно цієї теми є багато статей у блогах, запитань і відповідей на Stack Overflow тощо. У них описані різні стратегії, які інколи працюють, а інколи ні. Зазвичай йдеться про Webpack, Rollup, підтримку двох кодових баз, створення спеціальних команд та інструментів для збірки тощо. Та усе це переважно не допомагає зробити код чистим і ефективним.

У документації Node ми читаємо про Webpack, Rollup, ESM, CommonJS, UMD та AMD. Читаємо, що розширення .mjs та .cjs — це саме потрібні нам рішення, але, здається, більшість розробників ненавидять їх.

Читаємо про ключові слова у type = "module" та exports, завдяки яким начебто все буде працювати, але працює не так, як уявлялось.

Створення гібридного модуля не має бути таким складним!

Автор статті спробував розширення .mjs та .cjs, які не працюють з деякими основними інструментами збірки проекту.

Спробував пакети Webpack та Rollup.

Спробував використати поле type у файлі package.json, але це не спрацювало у комбінації з полем exports/exports map у файлі package.json.

Було багато спроб, але щоразу щось ішло не так.

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

Можливо, це не буде працювати завжди, але в автора все працювало щоразу, зокрема для Webpack, безсерверного фреймворку, інструментів командного рядка ESM та інших бібліотек ESM і CommonJS.

Основна проблема .mjs

Перш ніж викласти рішення, розглянемо кілька найбільш розхвалених методів.

Чому б просто не використати розширення .mjs або .cjs для позначення коду ESM або CommonJS?

Node підтримав .mjs- та .cjs-розширення файлів вихідного коду, щоб вказати тип вихідного файлу. На перший погляд, це здається логічним. Зазвичай розширення й описують типи файлів.

Це працює для простих і самостійних сценаріїв, які не є гібридними. Та якщо треба створити гібридний модуль, то використання .mjs і .cjs вказує на те, що у вас немає єдиної бази коду. Або ж — що ви створюєте власні інструменти для копіювання джерела та зміни розширень, а тоді виправляєте код, аби використати відповідні розширення у місцях підключення файлів.

ESM-код вимагає, щоб директиви import вказували напрямок до імпортованого файлу. Якщо ви імпортуєте з URL із .mjs-розширенням , то код потрібно виправляти для .cjs-файлів і навпаки.

Окрім того, більшість інструментів ще не підтримують як слід файли .mjs. А деякі вебсервери не мають розширення .mjs , визначеного як 'application/json' mime type. Навіть ваш улюблений інструмент для збірки проєкту також може не підтримувати ці файли. Отже, ви пишете конфіг і те, як їх відобразити, або ж створюєте власні скрипти для управління цими файлами.

Автор не знає нікого, хто б любив розширення .mjs та .cjs. На щастя, є альтернативи. Введіть властивості package.json type.

Проблема властивості 'type' файлу package.json

Щоб з'ясувати, чи є файл із розширенням .js модулем ES чи модулем CommonJS, Node створив властивість type у package.json та способи визначення (conventions).

Якщо ви встановите type до 'module', всі файли в цій директорії та піддирекотріях вважатимуться ESM, доки не з'являться package.json або node_modules. Якщо встановити type до 'commonjs', вважатиметься, що всі файли будуть CommonJS. Ці значення можна змінити, якщо додати в назву файлів розширення .cjs або .mjs.

package.json:

{
    "version": "1.2.3",
    "type": "module"
}

Це добре працює, але ваш пакет автоматично є або 'module', або 'commonjs'. Але що ж відбувається, коли вам потрібен гібридний пакет і формати ESM та CommonJS водночас? На жаль, неможливо мати умовний тип, який є модулем, коли застосовується ESM, та 'commonjs', коли використовуємо CommonJS.

Node надає умовну властивість exports, що визначає вхідні точки для експорту пакета. Однак це не перевизначить тип пакета, а властивості type і exports погано поєднуються.

Проблема умовного експорту package.json

Node надає умовну властивість exports, яка визначає вхідну точку для експортованих модулів. Нас зараз цікавлять селектори import та require, які дозволяють гібридному модулю визначати різні вхідні точки для користування ESM та CommonJS.

package.json:

{
    "exports": {
        "import": "./dist/mjs/index.js",
        "require": "./dist/cjs/index.js"
    }
}

Коли ми використовуємо інструментарій (див. далі), то генеруємо розподіл єдиної бази коду для ESM та CommonJS. Потім властивість exports направляє Node на завантаження відповідної точки входу.

Але що буде, якщо ми визначили пакет з type модуля та exports як для ESM, так і для CommonJS? Для завантаження index.js все працює добре. Якщо ж цей файл потім завантажує інший підмодуль, тоді цей файл завантажується відповідно до налаштування type package.json, а не до налаштування експорту.

Іншими словами, якщо застосунок або бібліотека CommonJS використала require і завантажила цей модуль з './dist/cjs/index.js', а тоді 'index.js' викликає require('./submodule.js'), в такому разі нічого не вийде. Для модуля package.json був встановлений type до module, а модулі ESM не використовують require.

На жаль, якщо Node завантажується за допомогою exports.require, він не передбачає, що наведений нижче код є CommonJS. Було б добре, якщо експорт міг би визначати тип модуля для перевизначення верхнього рівня типу package.json.

Наприклад, ось гіпотетичний пакет package.json (не використовуйте, у Node він не підтримується):

{
    "exports": {
        "import": {
            "path": "./dist/mjs/index.js",
            "type": "module"
        },
        "require": {
            "path": "./dist/cjs/index.js",
            "type": "commonjs"
        }
    }
}

Але це нереально.

То що ж робити

Гаразд, то що насправді може спрацювати:

  1. Єдина база вихідного коду.
  2. Простота збірки.
  3. Створення нативного коду ESM.
  4. Сумісність з наявними інструментами.
  5. Створення гібридного пакета для ESM та CommonJS.

Єдина база коду

Створіть свій код в ES6, ES-Next і Typescript через імпорт та експорт.

З цієї бази ви можете імпортувати модулі ES або CommonJS через імпорт. Але навпаки це не працює. Якщо ви створюєте CommonJS, ви не можете просто так використовувати модулі ES.


import Shape from './Shape.js'

export class MyShape {
    constructor() {
        this.shape = new Shape()
    }
}

Збірка

Створіть збірку для ESM і для CommonJS. Ми використовуємо Typescript як транспайлер і створюємо ES6, ES-Next або Typescript. У разі альтернативи для ES6 підійде Babel.

Файли Javascript повинні мати розширення .js, а не .mjs або .cjs. Typescript файли мають .ts-розширення.

Ось скрипт збірки package.json:

{
    "scripts": {
        "build": "rm -fr dist/* && tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./fixup"
    }
}

tsconfig.json налаштований для збірки ESM, а tsconfig-cjs.json — для CommonJS. Щоб не дублювати налаштування, ми визначаємо загальний tsconfig-base.json, що містить загальні параметри збірки. Вони використовуються як для ESM, так і для CommonJS.

tsconfig.json автоматично призначений для ESM та будується за допомогою «esnext». Ви можете це змінити на «es2015» або будь-який інший пресет.

tsconfig.json:

{
    "extends": "./tsconfig-base.json",
    "compilerOptions": {
        "module": "esnext",
        "outDir": "dist/mjs",
        "target": "esnext"
    }
}

tsconfig-cjs.json:

{
    "extends": "./tsconfig-base.json",
    "compilerOptions": {
        "module": "commonjs",
        "outDir": "dist/cjs",
        "target": "es2015"
    }
}

Ось tsconfig-base.json для коду ES6 з усіма налаштуваннями:

tsconfig-base.json:

{
    "compilerOptions": {
        "allowJs": true,
        "allowSyntheticDefaultImports": true,
        "baseUrl": "src",
        "declaration": true,
        "esModuleInterop": true,
        "inlineSourceMap": false,
        "lib": ["esnext"],
        "listEmittedFiles": false,
        "listFiles": false,
        "moduleResolution": "node",
        "noFallthroughCasesInSwitch": true,
        "pretty": true,
        "resolveJsonModule": true,
        "rootDir": "src",
        "skipLibCheck": true,
        "strict": true,
        "traceResolution": false,
        "types": ["node", "jest"]
    },
    "compileOnSave": false,
    "exclude": ["node_modules", "dist"],
    "include": ["src"]
}

ESM/CJS package.json

Останній крок — це простий скрипт fixup, який створюється перед розподілом package.json-файлів. Ці файли автоматично визначають тип пакета для підкаталогів .dist/*.

fixup:

cat >dist/cjs/package.json <<!EOF
{
    "type": "commonjs"
}
!EOF

cat >dist/mjs/package.json <<!EOF
{
    "type": "module"
}
!EOF

Package.json

У нашого package.json немає властивості type. Замість цього ми переносимо її до файлів package.json у підкаталоги ./dist/*. Ми визначаємо exports map, що визначає вхідні точки пакета: одну для ESM, а другу для CJS.

Ось частина package.json:

"exports": {
    ".": {
        "import": "./dist/mjs/index.js",
        "require": "./dist/cjs/index.js"
    }
}

Підсумки

Отже, в такий спосіб можна підключити модулі для ESM та CommonJS через import або require. І цілком можна використати єдину базу коду, що використовує сучасний ES6 або Typescript. Тоді користувачі вашого ESM насолоджуватимуться продуктивністю і не матимуть проблем із налагодженням.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 4.7K
Приєднався: 10 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація