В заметке Как собрать библиотеку с помощью rollup я уже объяснял процесс, как можно собрать свою библиотеку на примере бандлера rollup. В этот раз я хочу пойти дальше и собрать библиотеку компонентов (один из них будет зависеть от другого), используя возможности npm workspaces и сборщик esbuild (его главное преимущество в скорости сборки).
Отмечу, что результатом этой статьи будет монорепозиторий с настроенной esbuild сборкой для неопределенного числа React компонентов (достаточно будет добавить новый пакет по аналогии с существующими).
Требования к пакетам
- Каждый компонент представляет из себя самостоятельный npm пакет со своим версионированием (Такой подход я честно подсмотрел у react-spectrum от adobe, версионирование, а именно выпуск новых версий в рамках этой статьи рассматриваться не будет - это отдельная тема).
- Собранные пакеты поставляются в двух форматах:
cjsиesm - Стили экстрактим в отдельные файлы, но после сборки добавляем импорт в бандл. Такой подход я честно подсмотрел у
react-spectrum:- он упрощает подключение библиотеки в другой проект, потому что не надо держать в голове то, что надо заимпортировать еще и стили.
- Если инжектить стили в бандл, как я делал в Как собрать библиотеку с помощью rollup - возникает другая проблема: до применения стилей проходит какое-то время (чтобы понять почему - можно изучить собранный банлд) происходит заметное моргание и верстка скачет
- Поставляем d.ts файлы и source maps
Кому не интересен процесс, а нужен только результат - в конце статьи есть ссылка на репозиторий с примером установки.
Подготавливаем почву
Процесс создания монорепозитория я уже описывал в другой статье ( Монорепозиторий на npm workspaces ), так что повторяться не вижу смысла.
Также я описал настройку линтинга вот тут Как настроить eslint для монорепы
Структура пакетов
У нас есть два пакета @esbuild-libs/button и @esbuild-libs/typography, Причем один зависит от другого (типографика используется внутри кнопки) и это явно указано в поле dependencies:
В корневом package.json у нас установлены все необходимые зависимости для сборки и разработки, поэтому все они установлены как devDependencies
{
"name": "@esbuild-libs/root",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"lint:fix": "eslint --fix .",
"clean": "npm run clean --workspaces --if-present",
"build": "npm run clean && ./scripts/build"
},
"devDependencies": {
"@types/classnames": "^2.3.1",
"@types/eslint": "^8.37.0",
"@types/react": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"classnames": "^2.3.2",
"esbuild": "^0.17.18",
"esbuild-css-modules-plugin": "^2.7.1",
"eslint": "^8.40.0",
"eslint-config-standard-with-typescript": "^34.0.1",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.0.4"
}
}А в директории packages и лежат наши пакеты/компоненты, каждая директория является самостоятельным пакетом и содержит свой package.json, ниже пример package.json для @esbuild-libs/button:
{
"name": "@esbuild-libs/button",
"version": "0.1.0",
"description": "A button component",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"scripts": {
"clean": "rm -rf \"$PWD/dist\""
},
"files": [
"dist/*",
"src/*"
],
"config": {
"entryPoints": [
"src/index.tsx"
]
},
"dependencies": {
"@esbuild-libs/typography": ">=0.x"
},
"peerDependencies": {
"react": "^18.2.0"
}
}Из него можно понять, что мы будем собирать cjs и esm форматы в dist/cjs и dist/esm соответственно, а d.ts мы будем собирать только для сборки esm, но это и не важно - типы общие и для обоих форматов
Еще у нас тут есть поле config в котором можно хранить разные конфигурационные параметры, к которым потом можно обращаться через $ в scripts, но мы его (поле config) используем не совсем по назначению, в нем будут храниться точки входа в пакет, а возможно мы захотим для какого-то пакета в будущем сделать несколько точек входа. Далее в статье я еще вернусь к этому полю, когда буду говорить про сборку.
Еще из важного тут: я указал версию зависимости как >=0.x, то есть каждый раз во время установки @esbuild-libs/button будет автоматом ставиться последняя имеющаяся в npm реджестри версия @esbuild-libs/typography (конечно пока версия typography не перескочет на 1.x.x)
Код и структура компонентов
Как видно из схемы, содержимое dist - полупрозрачное, так я отметил, что мы не храним эти директории под системой контроля версий и их содержимое является артифактом, который остается после сборки.
Теперь можно перейти к самому интересному - сборке
Настройка сборки
Я знаю что esbuild зачастую используется через cli интерфейс и часто это очень удобно, но в данном случае я решил, что будет лучше настроить сборку, описав все в одном скрипте, который и будет заниматься сборкой всех пакетов, именно для этого и появилось поле config.entryPoints , чтобы в этом скрипте мы могли считывать необходимые параметры и гибко управлять сборкой каждого пакета.
Итак, сначала создадим скрипт сборки, я положу его в scripts/build . Расширение не написал, потому что вверху файла добавил комментарий #!/usr/bin/env node, это позволит запускать файл как бинарник просто обращаясь к нему вот так:
./scripts/build
Но чтобы так делать, нужно предварительно дать ему права на исполнение с помощью `chmod +x ./scripts/build
Отлично, теперь к содержимому - я тут все снабдил комментариями, так что все должно быть понятно и дублировать это в тексте не буду:
#!/usr/bin/env node
const esbuild = require('esbuild');
const cssModulesPlugin = require('esbuild-css-modules-plugin');
/*
* ts нам понадобится ниже, чтобы собирать d.ts файлы,
* так как esbuild не умеет этого делать самостоятельно
*
* это накинет какое-то время ко времени сборки, потому что
* компилятор тайпскрипта не такой быстрый как esbuild
*/
const ts = require('typescript');
const path = require('path');
const fs = require('fs');
const packagesDir = path.resolve(process.cwd(), 'packages');
const packages = fs.readdirSync(packagesDir).reduce((acc, dir) => {
const pkg = require(path.join(packagesDir, dir, 'package.json'));
if (Boolean(pkg)) {
acc.push(Object.assign(pkg, { config: { ...(pkg.config || {}), dir } }));
}
return acc;
}, []);
const getOutDir = (out) => out.split('/').slice(0, -1).join('/')
/**
* Собираем точки входа и выхода а также парсим package.json
* Пока решил не усложнять и беру первую точку входа из конфига
* pkg.config.entryPoints[0]
*/
const inOutFiles = packages.reduce((acc, pkg) => {
const cjsOutDir = path.join(packagesDir, pkg.config.dir, getOutDir(pkg.main));
const esmOutDir = path.join(packagesDir, pkg.config.dir, getOutDir(pkg.module));
const entryFile = path.join(packagesDir, pkg.config.dir, pkg.config.entryPoints[0]);
acc.push({
cjsOutDir,
esmOutDir,
entryFile,
pkg,
});
return acc;
}, []);
/*
* Тут получаем массив зависимостей, которые ходим исключить из бандла
* Я делаю это с помощью сброра ключей dependencies и peerDependencies
*
* Благодаря этому в банлде вместо кода библиотеки react
* будет строчка `import r from 'react'` или `var r = require('react')`
* В зависимости от того в каком формате мы собираем библиотеку
*
* Вышеописанное справедливо не только для react
*/
const getDepsArr = (pkg) =>
Array.from(
new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
])
)
// Настройки для css модулей
const cssModules = (inject = true) => cssModulesPlugin({
inject,
v2: true,
v2CssModulesOption: {
pattern: `[local]_[hash]`
}
});
// Собираем массив промисов: для каждого пакета по 2 - cjs и esm сборка
const buildPromises = inOutFiles.reduce((acc, { cjsOutDir, esmOutDir, entryFile, pkg }) => {
// cjs build
acc.push(() => esbuild.build({
entryPoints: [entryFile],
bundle: true,
platform: 'node',
target: 'node14',
format: 'cjs',
outdir: cjsOutDir,
external: getDepsArr(pkg),
sourcemap: true,
minify: false,
logLevel: 'info',
plugins: [cssModules(false)],
tsconfig: path.join(process.cwd(), 'tsconfig.json'),
}));
// esm build
acc.push(() => esbuild.build({
entryPoints: [entryFile],
bundle: true,
platform: 'neutral',
target: 'es2015',
format: 'esm',
outdir: esmOutDir,
external: getDepsArr(pkg),
sourcemap: true,
treeShaking: true,
minify: false,
logLevel: 'info',
banner: {
/*
* Благодаря этому, будет возможен импорт компонентов
* одной строкой, без необходимости отдельно импортировать стили
*
* import { Button} from '@esbuild-libs/button'
*
* Но может привести к проблемам,
* о которых я напишу в заключении статьи
*/
js: `import './index.css';`
},
tsconfig: path.join(process.cwd(), 'tsconfig.json'),
plugins: [
cssModules(false),
{
/**
* Так как esbuild не умеет сам собирать d.ts
* о чем я написал выше
* напишем небольшой плагин, который в конце esm сборки
* будет генерировать d.ts файлы для пакета
*/
name: 'DeclarationGenerator',
setup(build) {
build.onEnd((_result) => {
console.log('Generating declaration files...', pkg.name);
const result = ts.createProgram({
rootNames: [entryFile],
options: {
emitDeclarationOnly: true,
declaration: true,
declarationDir: esmOutDir,
}
}).emit();
console.log('Complete declaration files...', pkg.name);
});
},
},
]
}));
return acc;
}, []);
const run = async () => {
await Promise.all(buildPromises.map((build) => build()));
};
run(); // Запускаем run и дожидаемся выполнения Promise.all
В будущем может возникнуть ситуация, когда Promise.all нужно будет заменить на что-то с ограничением конкуррентности, но в данном случае его будет достаточно.
Полностью код можно посмотреть в репозитории на github а попробовать можно так:
git clone git@github.com:theStrangeAdventurer/esbuild-react-components-monorepo.git esbuild-libs-dir
cd esbuild-libs-dir
npm install
npm run buildПроверка пакетов
Чтобы проверить установку пакетов до публикации в монорепозиторий, можно упаковать их локально с помощью
npm run build && npm pack -ws ( Чтобы работал функционал воркспейсов у вас должна быть версия npm >=7.x.x, проверить можно с помощью npm --version)
После выполнения команды у вас должны появиться два файла в корне
esbuild-libs-typography-0.1.0.tgz
esbuild-libs-button-0.1.0.tgzТеперь упакованные пакеты можно установить используя:
npm install /absolute/path/to/esbuild-libs-typography-0.1.0.tgz
npm install /absolute/path/to/esbuild-libs-button-0.1.0.tgzОбращу внимание на то, что сначала ставим typography потому что от него зависит button .
В противном случае button попытается установить typography из npm реджестри, а его там нет - и упадет с ошибкой.
Для проверки пакетов можно использовать любой существующий проект например на create-react-app или nextjs. Может возникнуть проблема, о которой я писал в NextJS faq (из-за импортов css файлов в банлде, решение довольно простое, как запасной вариант, можно убрать поле banner из скрипта сборки и вручную имопртировать стили для каждого компонента import '@esbuild-libs/button/dist/index.css')
Надеюсь статья была полезной, удачной сборки своих библиотек!


