🔖

ログラスでのコンポーネントライブラリ構築備忘録

に公開

ログラスのフロントエンドチーム所属の gege4 です。

はじめに:ログラスにおけるコンポーネントライブラリの必要性

ログラスでは 2 年で 10 個の新規事業立ち上げに向けてのマルチプロダクト開発が絶賛進行しています。
https://note.com/tomosooon/n/nc7e72c2d19b9

これまでメインプロダクトは monorepo 開発で完結しており、デザインシステムと連携した共通コンポーネントやデザイントークンも同リポジトリ内に閉じていました。
しかし、事業の拡大に伴う、統一された UI/UX の提供を目指したフロントエンド開発のスケールにはライブラリ化は避けられませんでした

この記事では、既存のコンポーネント資産を活かしながら、
複数プロダクトで再利用可能な UI ライブラリとして再構築するプロセスでの
以下の 3 つのポイントの設計意図を備忘録的に記していきます。

  1. CSS の分離による異なる CSS フレームワークとの共存戦略
  2. 効率的な開発とバンドルサイズ最適化を両立するビルド構成
  3. 実行環境に応じた最適化されたテスト基盤の構築

逆に、以下についてはこの記事では詳細には触れませんのでご認識ください。

  • コンポーネントライブラリの作り方
  • ライブラリ化に採用した技術スタック

1. CSS の分離とフレームワーク共存戦略

デザインシステムとフレームワーク選択の両立

ログラスではもともとデザイナーチームとフロントエンドチームにより整備されたデザインシステムがあり、それに準拠した開発を Emotion で実現していました。
ライブラリ化後も、もちろんメインプロダクトでは Emotion での実装が続きます。
https://zenn.dev/yuitosato/articles/9db2a0fe90313e
しかし、AI エージェントや v0 との親和性、将来性を考慮し、新規に開発に取り掛かるプロダクトでは Tailwind CSS を採用したいという開発チームの声に応える必要もありました。
一般的に、Tailwind CSS のみの採用が前提であれば、tailwind.config ファイルの配布がデザインシステムの構築には手っ取り早いですが、現行のリプレイスに割ける時間がないため、
今回はデザインシステムの原則となる CSS 変数定義をライブラリから配布し、Emotion と Tailwind CSS へそれぞれ組み込んでいくことにしました。

/* ui-library/variables.css - デザイントークンを定義した以下のようなCSSを配布 */
:root {
  /* カラーパレット例 */
  --color-primary: #2352c8;
  /* ... */
  /* その他のデザイントークン */
  /* ... */
}

Emotion と Tailwind CSS への組み込み

Emotion を採用している既存のメインプロダクトは、単に CSS 変数定義の import 元がライブラリになるだけなので特段ライブラリ化の影響はありません:

/* main-product/globals.css */
@import "@ui-library/styles/variables.css";
// FooComponent.tsx - Emotion採用
import { css } from "@emotion/react";

const style = css`
  background-color: var(--color-primary);
`;

export const FooComponent: React.FC<Props> = ({ children }) => {
  return <div css={style}>{children}</div>;
};

一方、Tailwind CSS を採用した新規プロダクトには、
4 系 から導入された CSS First Configurations を活用することで、ライブラリから提供される CSS 変数を Tailwind CSS でのスタイリングに取り込みました。
https://tailwindcss.com/blog/tailwindcss-v4

/* new-product/globals.css */
@import "@ui-library/styles/variables.css";

@layer theme {
  /* TailwindCSSのデフォルトのCSS変数を初期化 */
  --color-*: initial;
  --spacing-*: initial;

  /* カラーパレット */
  --color-primary: var(--color-primary);
  /* その他トークンとthemeのマッピング */
  /* ... */
}
// FooComponent.tsx - Tailwind CSS採用
export const FooComponent: React.FC<Props> = ({ children }) => {
  return <div className="bg-primary">{children}</div>;
};

この例では、Tailwind CSS のbg-primaryクラスが、CSS ファイルで定義した--color-primary変数を参照しています。これにより、Emotion と Tailwind CSS のどちらを使用しても、同じデザイントークンに基づいた一貫性のある UI を構築できます。

2025 年 4 月時点での CSS First Configurations の辛み

これには現時点では多少の辛みがありましたが、現在まで開発が難しくなる課題感では無いと判断し続行しております。

  • 上記の例を見て明らかなように CSS 変数と theme のマッピングを手動で行わないといけない
    これは、将来的には CSS 変数が theme の命名規則に則っていればそのまま輸入できる何かしらのアップデートを期待しつつ、
    今は Cursor へのマッピング指示で十分に対応できているため、これを運用回避策としています
    (手動マッピング早く辞めたいです、切実に...)
  • eslint-plugin-tailwindcss が 4 系に未対応で、未使用や不正な class 指定が検出しづらい
    こちらは下記の通り現在も絶賛 WIP 中です、首を長くしてお待ちしております...!

https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/325

2. ライブラリ開発体験を重視した階層的インポート設計

続いては、ライブラリ開発者の開発体験を考慮したコンポーネントの build 設計についてです。
一般的に、npm のライブラリ構築を行うには、以下のような手順が多いと思います。

  1. エントリーポイントとなる index.ts をルート直下に配置
  2. 配布するものを全てエントリーポイントのファイルから export する
  3. Vite などの build ツールの entry 指定と、 package.json の main フィールドでエントリーポイントを明記する

ここではルートのエントリーポイント設置を行わない配布方法とメリットについて記載していきます。

コンポーネント単位の分散型ディレクトリ構造の採用

コンポーネントの開発と保守を効率化するため、co-location の原則に基づいた開発を行いつつ、分散したエントリーポイントのみによる build を採用しました。
https://www.mizdra.net/entry/2022/12/11/203940

src/
└── components/ # ← 直下にindex.tsを配置せず、mainの単一エントリーポイントは存在しない
    ├── button/
    │   ├── Button.tsx
    │   ├── Button.stories.tsx
    │   ├── Button.test.tsx
    │   └── index.ts
    ├── modal/
    │   ├── Modal.tsx
    │   ├── Modal.stories.tsx
    │   ├── Modal.test.tsx
    │   └── index.ts
    └── forms/
        └── text-field/
            ├── TextField.tsx
            ├── TextField.stories.tsx
            ├── TextField.test.tsx
            └── index.ts

この構造により、各コンポーネントの実装、テスト、Story 定義が一箇所にまとまるのはもちろんのこと、
各コンポーネントディレクトリ内の index.ts の export = dist に含まれる(公開される)ため、開発者は開発対象のコンポーネントディレクトリ内の作業のみで完結します
また、後述する Vite と package.json の設定により、import する側もこのディレクトリ構造と一致したパス指定になるため、ライブラリ開発者はより直感的な開発を行えます

Vite による自動エントリーポイント検出

このディレクトリ構造を活かすため、Vite の設定で複数のエントリーポイントを自動的に検出する仕組みが以下になります:

// vite.config.ts の核心部分
const componentIndexFiles = glob
  .sync(["./src/components/*/index.{ts,tsx}"])
  .map((file) => {
    const fileName = file.match(/src\/components\/([^/]+)\/index\.(ts|tsx)/)?.[1];
    return [`components/${fileName}`, file];
  });

// 第二階層にも対応(forms/text-field など一部の階層表現が必要なもののみ別途抽出)
const SECOND_LEVEL_DIRS = ["forms"] as const satisfies string[];

const secondLevelIndexFiles = SECOND_LEVEL_DIRS.flatMap((dir) => {
  return glob.sync([`./src/components/${dir}/*/index.{ts,tsx}`]).map((file) => {
    const componentName = file.match(new RegExp(`src/components/${dir}/([^/]+)/index\\.(ts|tsx)`))?.[1];
    return [`components/${dir}/${componentName}`, file];
  });
});

// すべてのエントリーポイントを統合
const mergedEntries = Object.fromEntries([
  ...componentIndexFiles,
  ...secondLevelIndexFiles,
]);

export default defineConfig({
  ...,
  build: {
    lib: { entry: mergedEntries },
  },
});

ビルド後のdistディレクトリは、ソースコードの構造を反映した以下のような構成になります:

dist/
└── components/
    ├── button/
    │   └── Button.mjs          # Buttonコンポーネントの実装本体
    ├── button.mjs              # Buttonコンポーネントのエントリポイント
    ├── modal/
    │   └── Modal.mjs           # Modalコンポーネントの実装本体
    ├── modal.mjs               # Modalコンポーネントのエントリポイント
    └── forms/
        ├── text-field/
        │   └── TextField.mjs   # TextFieldコンポーネントの実装本体
        └── text-field.mjs      # TextFieldコンポーネントのエントリポイント

型定義の生成は tsc をシンプルに使用

コンポーネントの型定義の生成には、Vite ではなく純粋な TypeScript コンパイラ(tsc)を使用しています。
このアプローチの最大の利点は、生成される型定義ファイルがソースコードと同じディレクトリ構造を維持することです。

TypeScript の設定は非常にシンプルで、最低限必要な設定のみを含んでいます。型定義の生成に特化した専用のtsconfig.build.jsonファイルを用意することで、開発時の設定と分離しています(開発時は story,test ファイルも型の恩恵を得たいため):

// tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "dist/types",
    "declaration": true,
    "emitDeclarationOnly": true,
    "noEmit": false
  },
  // ↓ co-locationによりコンポーネントと同一ディレクトリにこれらのファイルも含まれるためdistには含まれないようにexcludeする
  "exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
}
// package.json
  "scripts": {
    "build:type": "tsc --project tsconfig.build.json",
    ...
  }

この設定により、例えば src/components/button/index.ts から生成される型定義ファイルは dist/types/components/button/index.d.ts となります。
この構造は後述の package.jsonexports フィールドの設定と完全に一致するため、特別な型マッピング設定を行わなくても、利用側は TypeScript の恩恵を受けられます

この設定によって生成される型定義ファイルのディレクトリ構造は以下のようになります:

dist/
└── types/
    └── components/
        ├── button/
        │   ├── Button.d.ts       # コンポーネント実装の型定義
        │   └── index.d.ts        # エントリポイントの型定義
        ├── modal/
        │   ├── Modal.d.ts        # コンポーネント実装の型定義
        │   └── index.d.ts        # エントリポイントの型定義
        └── forms/
            └── text-field/
                ├── TextField.d.ts  # コンポーネント実装の型定義
                └── index.d.ts      # エントリポイントの型定義

package.json の最小限の exports 設定

最後に、package.json で exports フィールドを設定し、ビルド出力とのマッピングを行いました:

"exports": {
  "./styles/*.css": "./dist/styles/*.css",
  "./*": {
    "import": {
      "types": "./dist/types/components/*/index.d.ts",
      "import": "./dist/components/*.mjs"
    }
  }
}

この設定により、コンポーネントごとの階層的インポート(のみ)が可能になります:

// ライブラリ利用側は個別コンポーネントをインポート
import { Button } from "@ui-library/button";
import { TextField } from "@ui-library/forms/text-field";
// ↑ 型定義が同梱されているので、もし存在しないパスやexportsの対象外のパスを指定するとts-errorとなる

階層的インポートのみをサポートした狙い

  1. バンドルサイズの最適化:
    未使用コンポーネントがバンドルに含まれない
    (利用側にモダンなバンドラーが採用されていれば現代においては誤った export をしなければ考慮は不要なケースが多いですが...)
  2. 影響範囲の明確化:
    利用側は@ui-libraryから全ての import を記載せず、常に from 句に@ui-library/xxxと階層コンポーネントの明示を強制されるため、今後の改修による影響範囲調査や IDE による一括置換の利便性を向上する
  3. コンフリクト・作業漏れの防止:
    ライブラリ開発時も作業コンポーネントの同ディレクトリの index.ts のみを変更するだけで完結するため、コンフリクトしにくく作業漏れが発生しにくい
  4. 型定義の明確な対応付け:
    tsc を使用して生成された型定義ファイルは、ソースコードのディレクトリ構造を保持するため、階層的インポートパスと型定義ファイルのパスが自然に一致する。
    そのため、独自の型マッピング設定なしで、型補完やエラーチェックが正確に機能する。

3. テスト実行基盤の最適化

テスト実行環境の最適化という課題

UI コンポーネントライブラリと言っても、内部には UI にまつわるカスタム hooks や util 関数を多く含んでいます。
コンポーネントの数も多く、それに応じたコンポーネントの単体テスト、およびこれら関数のテストも既に資産として多くありました。
これらの実行時間は既にボトルネックになりつつあったため、ライブラリとして持ち出すにあたって、実行基盤を刷新しました。

Vitest による環境別テスト設定

移行前は Jest によるテストランナーで一括実行されていましたが、ライブラリ化後は Vitest を採用しました。
Vitest のワークスペース機能を活用し、テストファイルの命名規則に基づいて適切な環境で実行される設定を実装しました。
https://vitest.dev/guide/workspace

  • *.node.test.ts
    → Node 環境(最も軽量・高速で、utils などの純粋関数のカバレッジに最適)
  • *.dom.test.tsx
    → ブラウザ環境(Playwright を用いてレンダリングを行う、コンポーネントの単体テストに最適)
  • *.dom.test.ts
    → Node 環境での JSDOM(ブラウザを立ち上げるほどではないが renderHook などの DOM のレンダリング環境が最低限必要な際に最適)
// vitest.workspace.ts
export default defineWorkspace([
  {
    extends: "vitest.config.mts",
    test: {
      name: "node",
      environment: "node",
      include: ["src/**/*.node.test.ts"],
      setupFiles: [
        /* node環境テスト用のセットアップ */
      ],
      pool: "threads",
    },
  },
  {
    extends: "vitest.config.mts",
    plugins: [react()],
    test: {
      name: "dom(jsdom)",
      environment: "jsdom",
      include: ["src/**/*.dom.test.ts"],
      setupFiles: [
        /* node環境(jsdom)テスト用のセットアップ */
      ],
      pool: "threads",
    },
  },
  {
    extends: "vitest.config.mts",
    plugins: [react()],
    test: {
      name: "dom(browser)",
      include: ["src/**/*.dom.test.tsx"],
      browser: {
        enabled: true,
        provider: "playwright",
        instances: [{ browser: "chromium" }],
        headless: true,
      },
      setupFiles: [
        /* browserテスト用のセットアップ */
      ],
      pool: "browser",
    },
  },
]);

この設定により、テストファイルの命名規則だけで適切な環境が自動的に選択され、異なるワークスペースどうしが並列実行されます

テスト環境別の実装例

1. Node 環境テスト (ユーティリティ関数向け)

// date.node.test.ts
import { formatSlashDate } from "./date";

it("formatSlashDateのテスト実装例", () => {
  expect(formatSlashDate(new Date(2021, 0, 1, 0, 0, 0, 0))).toEqual("2021/1/1");
});

2. ブラウザ環境テスト (コンポーネント向け)

// Button.dom.test.tsx
export { render, screen } from "@testing-library/react";
export { userEvent } from "@vitest/browser/context";
import { Button } from "./Button";

describe("Button", () => {
  it("onClick が呼び出されること", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    const { getByRole } = render(
      <Button variant="primary" appearance="fill" onClick={onClick}>
        テスト
      </Button>
    );
    // テストコード実装...
  });
});

3. JSDOM 環境テスト (カスタム hooks 向け)

export { renderHook } from "@testing-library/react";
import { useTimeout } from "./use-timeout";

describe("useTimeout", () => {
  it.each([[10], [1000], [2000]])("useTimeoutのテスト例", (delay: number) => {
    const { result } = renderHook(() => useTimeout());
    // テストコード実装...
  });
});

このテスト実行基盤のメリット

この設計により、以下のメリットを実現できました:

  1. テスト実行の高速化: テストを最適な環境でのみ実行することで全体的な実行時間を短縮、CI の効率化
  2. 開発者体験の向上: テストファイルの命名規則だけで適切な環境が選択されるため、設定の手間が不要
  3. ブラウザの厳密なシミュレート: コンポーネントは vitest の browser モードの採用により、リソース消費が大きいがブラウザ実行環境でのレンダリングによるテストを実行できる

https://vitest.dev/guide/browser/

Playwright が内部的に使用されるためセットアップ処理を cache しておくことで、GitHub Actions 上であっても 200 を超えるテストケースが 25 秒以内に完了する高速化を実現できました。

まとめ

今回のライブラリ化の開発を通じて、既存コンポーネントをライブラリとして再構築する際の重要な技術的決断と実装手法を紹介しました。

  1. CSS の分離 - 異なる CSS フレームワークとの共存を可能にし、アプリケーション側の自由度を向上
  2. 効率的なビルド構成と階層的インポート - コンポーネント単位の開発効率とバンドルサイズの最適化を両立
  3. 環境に応じたテスト基盤 - テストファイルの命名規則による自動環境選択で高速かつ確実なテストを実現

この備忘録が、似た境遇のコンポーネントライブラリ開発に取り組む方の参考になれば幸いです。
また、今回は触れませんでしたが、上述のもの以外には王道的なライブラリ開発構成を採用しており、多くの技術に触れることができた学びある開発でした。ライブラリ開発の設計も楽しいものですね!

We Are Hiring!

ログラスには事業のスケールに合わせてフロントエンド開発基盤をスケールさせるために、
基盤開発チーム内にフロントエンド専任のチームが存在しています。
今回の活動はその一端に過ぎず、裁量広く常にフロントエンドのコアな領域への挑戦を続けております!
ご興味持たれた方はぜひお話しましょう!
https://hrmos.co/pages/loglass/jobs/Eng-FE-001

株式会社ログラス テックブログ

Discussion