esbuildではじめるUIライブラリ自作
はじめに
本稿では、esbuildとReactでUIライブラリを自作する手順をまとめます。
esbuidを使えば、OSSとしての開発はもちろんのこと、GitHub Packagesと併せてを利用することで、社内限定のUIライブラリも開発できます。
ただし、本稿では、UIライブラリを作成するための簡単な実装を解説に留めます。デザインシステムやパフォーマンス、スケーラビリティなど発展的な要素については、次の機会にてまとめますので、ご容赦下さい。
成果物
esbuildを選んだ理由
本稿でesbuildを選んだ理由は、「学習しやすいため」です。
esbuildはRadix UIにも採用されており、その設定を参考にすれば、比較的容易に初期の開発が完了するでしょう。また、esbuildは、高速にビルドでき、トライアンドエラーを繰り返しやすいことも利点です。
他の選択肢は、新しいものであればRspack、より簡単なものを求めるのであればViteのライブラリモードもあります。
どのツールでも良いUIライブラリは作れるのですが、esbuildは、それらと比較して難易度的に中間に位置するので苦しむことなく、知見を得ることができると考えています。
使用技術
- React19
- TypeScript
- esbuild
- tsup
- pure CSS
- Next.js(Playgroundとして)
コンポーネントを用意
まずは、サンプルとしてCardコンポーネントと、Buttonコンポーネントを作成します。
Card
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
.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
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
.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
については後の章で解説します。
// 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
をスキップできます。
module.exports = require('./dist')
export * from './dist'
CSS Pluginの定義
デフォルトのesbuildはコンポーネントで用いているCSSを一つのファイルにまとめてくれます。しかし、そのままでは./dist
に生成され、import文が長くなってしまうので、esbuildのプラグインを作り、ビルド生成物である./dist/index.css
を./style.css
として移動します。
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としてレジストリに登録する際の設定を示します。
{
"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を定義します。
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の設定の詳細は以下の文献を参照してください。
インストールができたらスタイルファイルとコンポーネントを読み込むことができます。
まとめ
本稿では、esbuildを用いてReactコンポーネントをビルドし、GitHub Packagesで配信するというUIライブラリを自作する第一歩を紹介しました。
ライブラリ開発のほんの入り口ではありますが、読んでいただいた方の開発の一助になれば幸いです。
Discussion