Как создать проект TypeScript для целей CJS и ESM

Как создать проект TypeScript для целей CJS и ESM

4 февраля 2023 г.

Прежде чем вы начнете читать, ознакомьтесь с различие между CommonJS (CJS) и модулями ECMAScript (ESM). В этой статье описывается, как мы можем создать проект TypeScript для целей CJS и ESM, используя чистый компилятор TypeScript и встроенные функции npm.

Вы можете найти пример проекта в моем репозитории на GitHub.< /p>

Мотивация

На создание этого поста меня вдохновила моя любимая библиотека rxjs   просто взгляните, сколько у них файлов tsconfig.json там! Давайте попробуем создать несколько минимальных примеров, которые продемонстрируют, как вы можете построить свой проект TypeScript (TS) как для модулей EcmaScript, так и для целей CommonJS. Конечно, вы можете сделать то же самое в настоящее время, используя некоторые причудливые сборщики, такие как Rollup, Webpack, Vite и т. д. — Держу пари, что к тому времени, когда я закончу писать свою статью, выпустят несколько новых — но я это только в образовательных целях (…и для развлечения).

Представьте ситуацию, когда вы хотите, чтобы ваша библиотека использовалась несколькими проектами в вашей организации  — один из них является старым проектом Node.js, созданным для цели CJS, а другой — современным и модным браузерным приложением. Скорее всего, если вы попытаетесь импортировать пакет ESM в проект Node.js, он не скомпилируется.

От слов к делу

Сначала создадим наш пакет. Запустите в своем терминале:

npm init -y 
npm i -D typescript @types/node npm-run-all 
npx tsc --init

В сгенерированном файле tsconfig.json (это будет наш базовый файл для разных целей сборки) измените outDir так, чтобы он указывал на каталог build:

"outDir": "./build"

Теперь мы можем создать нашу конфигурацию для TS на основе формата вывода сборки:

  • tsconfig.esm.json для сборок ESM будет генерировать вывод в папку esm

javascript { "расширяет": "./tsconfig.json", "Параметры компилятора": { "outDir": "./сборка/esm", "модуль": "следующий" }

* tsconfig.cjs.json для сборок CJS будет генерировать вывод в папку cjs

javascript { "расширяет": "./tsconfig.json", "Параметры компилятора": { "outDir": "./сборка/cjs", "модуль": "commonjs" } * tsconfig.types.json для типизации будет генерировать вывод в папку types

javascript { "расширяет": "./tsconfig.json", "Параметры компилятора": { "outDir": "./сборка/типы", "декларация": правда, "эмитдекларатиононли": правда }

Давайте определим наши сценарии для создания выходных данных сборки. Перейдите к файлу package.json и добавьте следующие команды:

"compile": "tsc -b ./tsconfig.cjs.json ./tsconfig.esm.json ./tsconfig.types.json",
"build:clean": "rm -rf ./build", 
"build": "npm-run-all build:clean compile && && node ./scripts/prepare-package-json"

build:clean просто очищает целевой каталог сборки перед каждой новой сборкой. compile будет использовать компилятор TypeScript (tsc) для сборки нашего исходного кода (-b означает сборку) на основе конфигурации, которую мы ему передаем.

Теоретически у нас может быть больше форматов сборки (например, ESM5 для поддержки старых браузеров). И, наконец, мы сгенерируем специальный файл package.json для нашей сборки ESM, используя наш собственный скрипт prepare-package-json (подробнее об этом ниже). Теперь мы можем опубликовать наш пакет, используя npm publish.

А что если смысл публиковать библиотеку, если библиотеки нет? Давайте что-нибудь построим.

Что будет делать наша библиотека?

Давайте создадим файл lib.ts в папке src:

export async function run() {
  let type = "";
  const workerPath = "./worker.js";
  // require and __dirname are not supported in ESM
  // see: https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs
  if (typeof require !== "undefined" && typeof __dirname !== "undefined") {
    type = "CJS";
    const { Worker, isMainThread } = require("worker_threads");
    if (isMainThread) {
      const worker = new Worker(__dirname + "/" + workerPath);
      worker.on("exit", (code: number) => {
        console.log(`Nodejs worker finished with code ${code}`);
      });
    }
  } else {
    type = "ESM";
    if (typeof Worker !== "undefined") {
      new Worker(workerPath);
    } else {
      console.log("Sorry, your runtime does not support Web Workers");
      await import(workerPath);
    }
  }
  console.log(`Completed ${type} build run.`);
}

Идея этой библиотеки заключалась бы в том, чтобы переложить часть дорогостоящей вычислительной работы на рабочий экземпляр. Для Node.js мы будем использовать реализацию рабочего потока, а для браузеров — WebWorker API. В качестве запасного варианта мы можем лениво загрузить скрипт в основной поток и выполнить его там.

Для нашего рабочего кода мы будем использовать вычисление чисел Фибоначчи:

const maxLimit = 1_000_000;
let n1 = BigInt(0),
  n2 = BigInt(1),
  iteration = 0;
console.log("Starting fibonacci worker");
console.time("fibonacci");
while (++iteration <= maxLimit) {
  [n2, n1] = [n1 + n2, n2];
}

console.log("Fibonacci result: ", n1);
console.timeEnd("fibonacci");

Эта операция займет некоторое время, поэтому ее стоит выделить в отдельный поток (да, JavaScript не совсем однопоточный), а не блокировать основной поток.

Добавим немного клея

Теперь нам нужно сообщить нашим потребителям, как импортировать нашу библиотеку, не сообщая им, какой именно путь им нужно импортировать. Здесь пригодится функция условного экспорта npm:

  "exports": {
    "./*": {
      "types": "./build/types/*.d.ts",
      "require": "./build/cjs/*.js",
      "import": "./build/esm/*.js",
      "default": "./build/esm/*.js"
    }
  }

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

 "exports": {
    ".": {
      "types": "./build/types/lib.d.ts",
      "require": "./build/cjs/lib.js",
      "import": "./build/esm/lib.js",
      "default": "./build/esm/lib.js"
    }
  }

Как это прочитать? ./* указывает npm разрешить любой путь, идущий после имени пакета (например, import lib from 'my-fancy-lib/lib' будет соответствовать / lib путь), а . просто говорит нам разрешить корневой импорт (import lib from 'my-fancy-lib').

Ключ (types, require, import, default), определенный в хэш-объекте для этого экспорта, будет срабатывать на основе о том, как конечный пакет использует эту библиотеку:

* import lib from 'my-fancy-lib/lib' (или import lib from 'my-fancy-lib') разрешается в <node_modules> /my-fancy-lib/build/esm/lib.js * const lib = require('my-fancy-lib/lib') (или const lib = require('my-fancy-lib')) разрешается в < code><node_modules>/my-fancy-lib/build/cjs/lib.js * Клавиша default в основном является резервной клавишей, если ничего не соответствует поиску. Кстати, есть еще несколько ключей, которые вы можете определить — вы можете найти их все в документации.

Теперь самое смешное. Ключ types ДОЛЖЕН быть определен до всех остальных, а ключ по умолчанию должен быть последним. Хотя я понимаю, почему порядок по умолчанию важен (обычная практика для резервных механизмов), но я не уверен, почему важно сначала иметь типы.

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

Вы также можете определить условный экспорт для типов TS с помощью типовVersions:

Хакерская часть

Как я упоминал ранее, нам нужно выполнить несколько пользовательских скриптов в конце сборки. Так зачем нам это нужно?

Прежде всего, давайте посмотрим на скрипт:

Таким образом, вся идея этого скрипта заключается в создании отдельного package.json для сборки ESM (в каталоге build/esm) со следующим содержимым:

Это сообщит системе сборки потребителя, что в базовом каталоге есть современные модули EcmaScript. В противном случае он будет жаловаться на:

Можем ли мы сделать лучше? Да! npm имеет неявное соглашение о расширении файлов, позволяющее различать ESM и CJS. Все файлы с расширением .mjs будут интерпретироваться как ESM, а .cjs - как модуль CommonJS. Таким образом, вместо того, чтобы создавать этот хакерский скрипт, мы можем определить "type": "module" в нашем корневом package.json и заставить CommonJS запрашивать файлы с расширением .cjs.< /p>

Но я считаю, что существующий способ более удобен для пользователя, потому что потребителям не нужно беспокоиться о расширениях; они могут просто использовать эту библиотеку как есть:

Соображения безопасности

Существует опасность, называемая двойной опасностью пакета:

<цитата>

Когда приложение использует пакет, содержащий исходные коды модулей CommonJS и ES, существует риск возникновения определенных ошибок при загрузке обеих версий пакета. Этот потенциал исходит из того факта, что pkgInstance, созданный с помощью const pkgInstance = require('pkg'), не совпадает с pkgInstance, созданным с помощью import pkgInstance из 'pkg' (или альтернативного основного пути, такого как 'pkg/module'). Это «опасность двойного пакета», когда две версии одного и того же пакета могут быть загружены в одной и той же среде выполнения. Хотя маловероятно, что приложение или пакет намеренно загрузит обе версии напрямую, обычно приложение загружает одну версию, в то время как зависимость приложения загружает другую версию. Эта опасность может возникнуть из-за того, что Node.js поддерживает смешение модулей CommonJS и ES, что может привести к непредвиденному поведению.

Заключительные слова

Наличие нескольких вариантов одной и той же сборки, которые конечные пользователи могут использовать, не беспокоясь о том, скомпилирована ли она для цели их сборки, — это всегда приятный пользовательский опыт. В реальных проектах вы, скорее всего, будете использовать формат UMD (Universal Module Definition), который создает единый пакет для обоих миров. Однако иногда бывает полезно иметь детализированные сборки  — например, при использовании шаблона module/nomodule. для загрузки скриптов в браузере.


Также опубликовано здесь


Оригинал