Оригинальное сообщение: 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,
},
});
Суммируем действия:
- Мы получаем конфигурацию на основе переменной окружения, которую мы укажем при выполнении этой команды;
- Мы добавляем валидацию, чтобы помочь нам определить, если мы неправильно указали переменную окружения и пытаемся собрать lib, которая не сопоставлена;
- Мы вызываем 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