Сборка библиотеки компонентов с помощью esbuild В заметке Как собрать библиотеку с помощью 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')

Надеюсь статья была полезной, удачной сборки своих библиотек!