🍜

esbuildではじめるUIライブラリ自作

2024/12/20に公開

はじめに

本稿では、esbuildとReactでUIライブラリを自作する手順をまとめます。
esbuidを使えば、OSSとしての開発はもちろんのこと、GitHub Packagesと併せてを利用することで、社内限定のUIライブラリも開発できます。
ただし、本稿では、UIライブラリを作成するための簡単な実装を解説に留めます。デザインシステムやパフォーマンス、スケーラビリティなど発展的な要素については、次の機会にてまとめますので、ご容赦下さい。

成果物

https://github.com/kugue99A/esbuild-sample

esbuildを選んだ理由

本稿でesbuildを選んだ理由は、「学習しやすいため」です。
esbuildはRadix UIにも採用されており、その設定を参考にすれば、比較的容易に初期の開発が完了するでしょう。また、esbuildは、高速にビルドでき、トライアンドエラーを繰り返しやすいことも利点です。
他の選択肢は、新しいものであればRspack、より簡単なものを求めるのであればViteのライブラリモードもあります。
どのツールでも良いUIライブラリは作れるのですが、esbuildは、それらと比較して難易度的に中間に位置するので苦しむことなく、知見を得ることができると考えています。

使用技術

  • React19
  • TypeScript
  • esbuild
    • tsup
  • pure CSS
  • Next.js(Playgroundとして)

コンポーネントを用意

まずは、サンプルとしてCardコンポーネントと、Buttonコンポーネントを作成します。

Card

./src/Card/index.tsx
import type { ReactNode } from 'react'
import './style.css'

type Props = {
  children: ReactNode
}

const Card = ({ children }: Props): JSX.Element => {
  return <div className="udui__Card_root">{children}</div>
}

export default Card
./src/Card/style.css
.udui__Card_root {
  padding: 40px;
  border: none;
  box-shadow: 2px 2px 12px rgba(0, 0, 0, 0.5);
  border-radius: 4px;
  background-color: #fff;
}

Button

./src/Button/index.tsx
import type { JSX, ReactNode } from 'react'
import './style.css'

type ButtonProps = {
  children: ReactNode
  onClick: () => void
}

const Button = ({ children, onClick }: ButtonProps): JSX.Element => {
  return (
    <button type="button" onClick={onClick} className="udui__Button_root">
      {children}
    </button>
  )
}

export default Button
./src/Button/style.css
.udui__Button_root {
  display: grid;
  padding: 20px 32px;
  border: none;
  box-shadow: 2px 2px 12px rgba(0, 0, 0, 0.5);
  border-radius: 100px;
  background-color: #fff;
  font-weight: 700;
  font-size: 1rem;
  color: #1c9988;
}

esbuildの設定

esbuildはNode.js上で実行するツールです。したがってesbuildの細かい設定を書いたファイルを用意し、そのファイルをNode.js上で実行することでUIライブラリが作成されます。
以下のようなビルドスクリプトを記述し、node ./build.mjsを実行することでソースファイルからビルドの生成物が出来上がります。

また、esbuildをベースとしたtsupを使って型定義ファイルを生成します。esbuildには型定義を
cssPluginについては後の章で解説します。

参考: https://esbuild.github.io/api/

build.mjs
// esbuildを使ったビルドスクリプト
import * as esbuild from 'esbuild'
import * as tsup from 'tsup'
import { cssPlugin } from './plugin.mjs'

esbuild.build({
  bundle: true,
  entryPoints: ['src/index.ts'],
  external: ['react', 'react-dom'],
  format: 'cjs',
  outdir: 'dist',
  plugins: [cssPlugin],
})

// NOTE: tsupの型情報生成はユーティリティが高いため利用するが、tsupのビルドは使用されるesbuildが最新ではないため利用しない
// @see: https://github.com/radix-ui/primitives/blob/157415ed1f34c53b5afbf53a047895ed6a7f957f/build.mjs#L30-L36
tsup.build({
  dts: {
    compilerOptions: {
      incremental: false,
    },
    entry: ['src/index.ts'],
    only: true,
  },
  entry: ['src/index.ts'],
})

利用時のパスの解決

Radix UIをはじめとしたnpmライブラリは次のようにインポートします。

import { Button } from 'library-name'
import 'library-name/style.css'

しかし、ビルドの生成物は一括でgit管理から外すために/distというディレクトリに格納されています。そのままでは、以下のように冗長なimport文になってしまうでしょう。

import { Button } from 'library-name/dist'
import 'library-name/dist/style.css'

この/distを省略するために次のコードを実装します。

エクスポート用のファイル作成

ビルドされたjsとその型定義を再エクスポートすることで/distをスキップできます。

./index.js
module.exports = require('./dist')
./index.d.ts
export * from './dist'

CSS Pluginの定義

デフォルトのesbuildはコンポーネントで用いているCSSを一つのファイルにまとめてくれます。しかし、そのままでは./distに生成され、import文が長くなってしまうので、esbuildのプラグインを作り、ビルド生成物である./dist/index.css./style.cssとして移動します。

./plugin.mjs
import * as fs from 'node:fs'
import * as path from 'node:path'

export const cssPlugin = {
  name: 'css',
  setup(build) {
    build.onEnd(async (result) => {
      if (result.errors.length > 0) {
        return
      }

      const oldPath = path.join(process.cwd(), 'dist', 'index.css')
      const newPath = path.join(process.cwd(), 'style.css')

      try {
        await fs.promises.access(oldPath)
        // dist/index.cssが存在すれば、style.cssへ移動
        await fs.promises.rename(oldPath, newPath)
        console.log('CSS moved from dist/index.css to style.css')
      } catch (err) {
        // dist/index.cssが存在しない場合はエラーを無視
        console.log('No dist/index.css found, skipped moving.')
      }
    })
  },
}

npm packageの設定

npm packageとしてレジストリに登録する際の設定を示します。

package.json
{
  "name": "@kugue99A/udagawa-ui",
  "version": "0.1.0",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com"
  },
  "types": "index.d.ts",
  "files": [
    "dist",
    "index.js",
    "index.d.ts",
    "style.css"
  ],
  "scripts": {
    "build": "node ./build.mjs",
    "storybook": "storybook dev -p 6006 -c ./playground/storybook/.storybook"
  },
  "dependencies": {
    "esbuild": "0.24.0",
    "tsup": "8.3.5"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@storybook/addon-essentials": "8.4.7",
    "@storybook/addon-interactions": "8.4.7",
    "@storybook/addon-webpack5-compiler-swc": "1.0.5",
    "@storybook/blocks": "8.4.7",
    "@storybook/react": "8.4.7",
    "@storybook/react-webpack5": "8.4.7",
    "@storybook/test": "8.4.7",
    "storybook": "8.4.7",
    "typescript": "5.6.3"
  },
  "peerDependencies": {
    "@types/react": "*",
    "@types/react-dom": "*",
    "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
    "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
  },
  "peerDependenciesMeta": {
    "@types/react": {
      "optional": true
    },
    "@types/react-dom": {
      "optional": true
    }
  },
  "packageManager": "pnpm@9.3.0"
}

UIライブラリの配信

ビルドできることを確認したらビルドした生成物をpublishします。ライブラリを配信するレジストリは、標準のnpmもありますが、本稿では社内限定の運用を視野に入れ、GitHub Packagesを利用します。
リポジトリ管理と併せて運用できると便利なので、ビルド、パブリッシュをGitHub Actionsにて実施します。

GitHub Actionsの設定

GitHub Actionsを使ってGUIからパブリッシュできるようにworkflowを定義します。

./.github/workflows/publish.yaml
name: Publish udagawa-ui
run-name: udagawa-ui - publish
on:
  workflow_dispatch:

permissions:
  contents: write
  packages: write
  
jobs:
  up-version:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Set config
        run: pnpm config set "//npm.pkg.github.com/:_authToken" "${NODE_AUTH_TOKEN}"
        env:
          NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}

      - name: Install modules
        run: |
          pnpm i
          pnpm build

      - name: Publish
        run: |
          npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

成果物の確認

実際にnpmライブラリとしてNext.jsアプリにインストールし、利用できるか確認します。
GitHub Packagesに登録したので、以下のようにコマンドの引数にてレジストリを指定するか、~/.npmrcに記述する必要があります。

npm install ${{ library_name }} --registry=https://npm.pkg.github.com

Reactのバージョン衝突のエラーが起きる場合はnode_modulesを一旦削除してからインストールコマンドを実行してください。

.npmrcの設定の詳細は以下の文献を参照してください。
https://zenn.dev/moneyforward/articles/20230620-github-packages#インストールする

インストールができたらスタイルファイルとコンポーネントを読み込むことができます。

まとめ

本稿では、esbuildを用いてReactコンポーネントをビルドし、GitHub Packagesで配信するというUIライブラリを自作する第一歩を紹介しました。
ライブラリ開発のほんの入り口ではありますが、読んでいただいた方の開発の一助になれば幸いです。

Discussion