Создание библиотеки JavaScript с несколькими точками входа с помощью Vite3


Оригинальное сообщение: https://www.raulmelo.dev/blog/build-javascript-library-with-multiple-entry-points-using-vite-3

Введение

Этот пост, вероятно, скоро устареет.

Потому что пока я пишу, Vite 3 (v3.0.4) все еще не поддерживает готовое решение для использования его в режиме библиотеки с несколькими точками входа.

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

Хотелось бы, чтобы все было более просто, чем есть на самом деле, но я проведу вас шаг за шагом через проблемы и решения.

О Vite

Vite — это инструмент сборки, который призван обеспечить более быструю и обучаемую разработку современных веб-проектов.

https://vitejs.dev/guide/#overview

Он ориентирован на структуру ES Modules (хотя мы можем использовать его с Common JS), и он дает нам сервер разработки (как при запуске npm run dev) с молниеносной горячей заменой модулей (HMR).

Мы можем определить HMR для ситуации, когда вы запускаете сервер разработки, изменяете файл, этот файл обрабатывается снова (для поддержки браузером), и сервер обновляется.

Под капотом Vite объединяет esbuild для конкретной оптимизации файлов (из-за его выдающейся производительности) и Rollup (для фактического вывода файлов), имея отличную производительность в целом.

Если вы новичок в Vite, я настоятельно рекомендую вам изучить их документацию и попробовать создать простое приложение, используя один из их шаблонов, чтобы увидеть, насколько это удивительно, своими глазами.


Создание библиотеки JS

Если вы хотите создать библиотеку JS, вы, скорее всего, возьмете Rollup.

Это потому, что это зрелый инструмент, не такой сложный, как Webpack, и его не так сложно настроить.

Даже если конфигурация не слишком сложна, вам все равно придется установить кучу плагинов, позаботиться о разборе TypeScript (в случае, если вы пишете свою библиотеку на TS), позаботиться о транспонировании кода CommonJS и т.д.

К счастью, в Vite есть нечто под названием «Library Mode», что позволяет нам указать на входной файл (например, файл index.ts, который экспортирует все, что содержит пакет) и получить его компиляцию с использованием всей экосистемы Vite.

Документация по этому вопросу великолепна, и я считаю, что этого достаточно, чтобы вы могли иметь lib, готовый к публикации или потреблению самим приложением.

Единственная проблема с этим заключается в следующем: «Что если вместо одной точки входа я хочу иметь несколько?».

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

import { foo } from 'package'
Войти в полноэкранный режим Выйти из полноэкранного режима

И из той же библиотеки, также дают нам подмодуль:

import { bar } from 'package/another-module'
Войти в полноэкранный режим Выйти из полноэкранного режима

Примером может служить Next.JS. Когда мы устанавливаем next, мы можем импортировать некоторые вещи из основного пакета и другие вещи из подмодулей:

import Link from 'next/link';
import Image from 'next/image';
import type { GetServerSideProps } from 'next';
Войти в полноэкранный режим Выход из полноэкранного режима

Мы не можем просто указать на index.ts для этих случаев и иметь несколько выходов. Нам нужно указать на другие файлы.

В том же примере, использованном ранее (далее), они, скорее всего, будут указывать на несколько файлов, таких как src/image.tsx, src/link.tsx компилируются в файлы dist/image.js, и dist/link.js.

Побочное замечание: они не используют для этого Vite. Если посмотреть на их кодовую базу, то экосистема и сборка сложнее, чем нам нужно, и для этого у них другой подход и инструментарий.

Хорошо, но если Vite не поддерживает множественные записи, как нам этого добиться?


Стратегии

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

Единый и настраиваемый vite.config

Я увидел этот комментарий в теме Vite, касающейся этой темы, и поскольку я увлекаюсь скриптингом, мне захотелось его улучшить.

Дело в том, что поскольку мы находимся в среде Node и у нас есть доступ к переменным окружения, мы можем использовать их, например, внутри файла JavaScript:

console.log(process.env.MY_ENV_VAR);
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем, когда я делаю это:

MY_ENV_VAR=random-value node index.js
Войти в полноэкранный режим Выйти из полноэкранного режима

Это сохраняет значение, которое я задал MY_ENV_VAR:

random-value
Войти в полноэкранный режим Выйти из полноэкранного режима

Хорошо. Теперь представим, что я хочу создать библиотеку, которая экспортирует два модуля: logger и math (просто для примера):

.
├── src
│ ├── lib.ts
│ └── math.ts
└── package.json
Войти в полноэкранный режим Выход из полноэкранного режима

В нашем vite.config.js для одного входа, мы могли бы иметь что-то вроде этого:

import { resolve } from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'lib/index.ts'),
      fileName: 'my-lib',
      formats: ['cjs', 'es'],
    },
  }
})
Войти в полноэкранный режим Выйти из полноэкранного режима

В Vite 3 файл vite.config должен экспортировать по умолчанию результат defineConfig.

Для более динамичной сборки мы могли бы иметь объект в нашем конфигурационном файле, который хранит различную информацию между math и logger:

const config = {
  math: {
    entry: resolve(__dirname, "./src/math.ts"),
    fileName: "math.js",
  },
  logger: {
    entry: resolve(__dirname, "./src/logger.ts"),
    fileName: "logger.js",
  },
};
Войти в полноэкранный режим Выход из полноэкранного режима

entry и fileName — единственное различие между этими двумя файлами.

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

import { resolve } from "path";
import { defineConfig } from "vite";

const config = {
  math: {
    entry: resolve(__dirname, "./src/math.ts"),
    fileName: "math.js",
  },
  logger: {
    entry: resolve(__dirname, "./src/logger.ts"),
    fileName: "logger.js",
  },
};

const currentConfig = config[process.env.LIB_NAME];

if (currentConfig === undefined) {
  throw new Error('LIB_NAME is not defined or is not valid');
}

export default defineConfig({
  build: {
    outDir: "./dist",
    lib: {
      ...currentConfig,
      formats: ["cjs", "es"],
    },
    emptyOutDir: false,
  },
});
Войти в полноэкранный режим Выйти из полноэкранного режима

Суммируем действия:

  1. Мы получаем конфигурацию на основе переменной окружения, которую мы укажем при выполнении этой команды;
  2. Мы добавляем валидацию, чтобы помочь нам определить, если мы неправильно указали переменную окружения и пытаемся собрать lib, которая не сопоставлена;
  3. Мы вызываем defineConfig с общим конфигом и распространяем текущий конфиг.

Теперь мы можем выполнить следующую команду:

$ LIB_NAME=math npx vite build

vite v3.0.4 building for production...
✓ 1 modules transformed.
dist/math.js.cjs 0.15 KiB / gzip: 0.14 KiB
dist/math.js.js 0.06 KiB / gzip: 0.08 KiB
Войти в полноэкранный режим Выйти из полноэкранного режима

Команда npx вызывает двоичный файл Vite (сам CLI).

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

$ LIB_NAME=logger npx vite build

vite v3.0.4 building for production...
✓ 1 modules transformed.
dist/logger.js.cjs 0.16 KiB / gzip: 0.16 KiB
dist/logger.js.js 0.09 KiB / gzip: 0.09 KiB
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы облегчить себе жизнь, мы можем использовать эти две команды как npm-скрипт и одну команду, которая вызывает оба скрипта:

{
  "scripts": {
    "build:math": "LIB_NAME=math vite build",
    "build:logger": "LIB_NAME=logger vite build",
    "build": "npm run build:math && npm run build:logger"
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Импорт сборки из Vite в пользовательский скрипт

В том же обсуждении другой пользователь предложил другую стратегию решения этой проблемы: использование build из Vite.

Если вы не знаете, Vite раскрывает метод build, поэтому мы можем сделать это программно:

import { build } from 'vite';
Войти в полноэкранный режим Выйти из полноэкранного режима

Учитывая это, все, что нам нужно сделать, это создать массив конфигураций и выполнить итерации по этому массиву, вызывая build:

import { build } from "vite";
import path from "path";
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const libraries = [
  {
    entry: path.resolve(__dirname, "../src/logger.ts"),
    fileName: "logger",
  },
  {
    entry: path.resolve(__dirname, "../src/math.ts"),
    fileName: "math",
  },
];

libraries.forEach(async (lib) => {
  await build({
    build: {
      outDir: "./dist",
      lib: {
        ...lib,
        formats: ["es", "cjs"],
      },
      emptyOutDir: false,
    },
  });
});
Войти в полноэкранный режим Выйти из полноэкранного режима

И это сгенерирует точно такой же результат, как и предыдущая стратегия.

Разница в том, что вместо вызова vite build, все, что нам нужно сделать, это вызвать наш скрипт с node:

$ node scripts/build.mjs

vite v3.0.4 building for production...
vite v3.0.4 building for production... (x2)
✓ 1 modules transformed.
✓ 1 modules transformed. (x2)
dist/math.js 0.06 KiB / gzip: 0.08 KiB
dist/logger.js 0.09 KiB / gzip: 0.09 KiB
dist/math.cjs 0.15 KiB / gzip: 0.14 KiB
dist/logger.cjs 0.16 KiB / gzip: 0.16 KiB
Войти в полноэкранный режим Выйти из полноэкранного режима

Поскольку я определил этот файл как расширение .mjs, мне пришлось сделать обходной путь, чтобы иметь __dirname и мне нужно использовать Node 16 или выше.


Настройка package.json

Одним из самых важных шагов для создания javascript lib является определение в нашем package.json того, как Node должен разрешать файлы.

В документации Vite есть рекомендации, как это сделать, что почти то, что мы хотим:

{
  "name": "my-lib",
  "type": "module",
  "files": ["dist"],
  "main": "./dist/my-lib.umd.cjs",
  "module": "./dist/my-lib.js",
  "exports": {
    ".": {
      "import": "./dist/my-lib.js",
      "require": "./dist/my-lib.umd.cjs"
    }
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Объяснение каждого поля:

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

Помните, что для работы exports требуется как минимум Node v12, но я не думаю, что это проблема в наше время.
Хорошо, теперь давайте создадим наш собственный конфиг.

Поскольку создаваемая нами lib не имеет точки входа по умолчанию, мы можем избавиться от main и module и использовать только exports.

В поле exports мы теперь можем определить подмодули math и logger и указать на файлы, которые команда сборки будет выводить:

{
  "type": "module",
  "exports": {
    "./math": {
      "import": "./dist/math.js",
      "require": "./dist/math.cjs"
    },
    "./logger": {
      "import": "./dist/logger.js",
      "require": "./dist/logger.cjs"
    }
  },
  "files": [
    "dist/*",
  ]
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что файл, на который мы указываем, должен существовать, и он будет существовать, когда мы запустим npm run build

Еще одно замечание, мы определяем «type»: «module», чтобы определить, как node должен запускать файл (используя расширение .mjs или нет). Vite будет использовать эту информацию для создания файла с расширением .mjs или .cjs. Читайте здесь

Вот и все.

Теперь мы сможем использовать импорт/требование этого пакета:

// ES Modules environment
import { sum } from 'my-package/math'
import { logger } from 'my-package/logger'

// CommonJS (node)
const { sum } = require('my-package/math')
const { logger } = require('my-package/logger')
Войти в полноэкранный режим Выйти из полноэкранного режима

Генерация определений типов

TypeScript становится все более популярным с каждым годом.

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

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

Хорошей практикой является наличие конфигурации tsconfig.json на случай, если вы захотите скомпилировать наш код с помощью tsc или даже использовать компилятор в качестве «линтера».

Однако в данном случае, поскольку нам нужна только генерация типов, мы можем пропустить часть конфигурации и сразу использовать компилятор:

tsc src/*.ts --declaration --emitDeclarationOnly --declarationDir dist/
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь мы говорим компилятору, чтобы он выдавал декларации только для всех файлов .ts, присутствующих в src и помещенных в папку dist.

Когда мы соберем наши файлы с помощью Vite и выполним эту команду для генерации файлов, у нас будет папка dist, подобная этой:

.
└── dist
    ├── logger.cjs
    ├── logger.d.ts
    ├── logger.js
    ├── math.cjs
    ├── math.d.ts
    └── math.js
Вход в полноэкранный режим Выход из полноэкранного режима

Круто.

Теперь нам нужно объявить наши типы, используя атрибут package.json types.

types — это не стандартное поле Node, а то, что ввел TypeScript, и Node и экосистема JS приняли его.

Самая большая проблема здесь заключается в том, что типы принимают только строку, а не массив строк. Как же мы можем указать на несколько определений?

Проблема

Эта проблема показалась мне очень интригующей.

Я читал, как некоторые люди говорили, что если определение типа (d.ts) находится в той же папке, что и собранный файл (например, math.cjs), то TypeScript сможет автоматически определить типы.

Я попробовал это сделать, но ничего не вышло, как я ожидал.

Возможно, потому что в этой папке lib dist у меня есть не только math.cjs, но и math.js, и они будут импортированы по-разному. Возможно, TypeScript путается, пытаясь определить, является ли это определение из файла, который я импортирую.

Честно говоря, я не знаю наверняка.

Было бы здорово, если бы мы могли определять типы внутри exports:

{
  "exports": {
    "./math": {
      "import": "./dist/math.js",
      "require": "./dist/math.cjs",
      "type": "./dist/math.d.ts"
    },
    "./logger": {
      "import": "./dist/logger.js",
      "require": "./dist/logger.cjs",
      "type": "./dist/logger.d.ts"
    }
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Но это не сработает.

Как я уже сказал, exports — это что-то официальное от node, а types — это просто то, что придумал TypeScript.

Решение

Я не уверен, что это лучший способ решения, но я не смог найти ничего лучше.

Опять же на примере Next.js, они делают отличную работу по предоставлению таких отдельных пакетов и типов, поэтому я пошел туда, чтобы посмотреть, как они это делают.

Я понял, что, несмотря на исходный код, у них в корне главного пакета next есть определение типа для каждого пакета.

В каждом отдельном .d.ts, они только export * from './dist/<package-name>.js и, вишенка торта, у них есть один файл под названием index.d.ts, который использует <reference /> директиву из TypeScript и «импортирует» все эти типы.

Логика заключается в том, что каждый подмодуль, который мы собираемся экспортировать, будет указывать на свое определение типа, сгенерированное компилятором в папке dist.

Затем мы используем директиву <reference> из TypeScript, чтобы сообщить компилятору, что эти ссылающиеся файлы должны быть включены в процесс компиляции.

С помощью этой директивы компилятор TypeScript сможет сделать вывод о том, что является определением типа для этого подмодуля.

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

Практическая работа

Первым делом, мы создадим в нашей корневой папке два файла .d.ts, один для модуля logger и другой для модуля math.

В обоих мы экспортируем только все то, что есть в dist/*.js:

export * from "./dist/logger";
Войти в полноэкранный режим Выход из полноэкранного режима
export * from "./dist/math";
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь создадим файл index.d.ts, ссылающийся на оба определения типов:

/// <reference path="./logger.d.ts" />
/// <reference path="./math.d.ts" />
Вход в полноэкранный режим Выход из полноэкранного режима

Круто.

Вы могли заметить, что теперь у нас есть одна запись для наших типов (index.d.ts).

Последний шаг — указать этот файл записи на types и перечислить эти 3 новых определения типов files в поле files:

{
  "types": "./index.d.ts",
  "files": [
    "dist/*",
    "index.d.ts",
    "logger.d.ts",
    "math.d.ts"
  ]
}
Вход в полноэкранный режим Выйти из полноэкранного режима

И это сделает всю работу.


Демонстрация

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

Единственная разница в том, что в посте я использую npm в качестве примера, а в демо я использовал pnpm просто для того, чтобы иметь рабочее пространство, где я могу поддерживать как стратегии сборки, так и ванильное приложение TS, которое потребляет пакеты.

https://github.com/raulfdm/vite-3-lib-multiple-entrypoints


Заключение

Опять же, я думаю, что эта проблема может быть решена в будущей версии Vite, но поскольку вокруг этого идет борьба, я подумал, что стоит написать пост с некоторыми объяснениями.

Надеюсь, я смог как-то помочь вам.

Мир!


Ссылки

  • https://github.com/raulfdm/vite-3-lib-multiple-entrypoints
  • https://github.com/vitejs/vite/discussions/1736#discussioncomment-3310054
  • https://nodejs.org/api/packages.html#package-entry-points
  • https://webpack.js.org/guides/package-exports/
  • https://nodejs.org/api/packages.html#type
  • https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html
  • https://nodejs.org/api/packages.html#community-conditions-definitions
  • https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html
  • https://github.com/raulfdm/vite-3-lib-multiple-entrypoints
  • https://github.com/rollup/rollup

Оцените статью
devanswers.ru
Добавить комментарий