ANDPAD のデザインシステム 「Tsukuri」 の Web 向け実装について - リポジトリの構成・開発ツール

1. はじめに

Web フロントエンド開発を中心に行っている寺島です。

この記事はアンドパッドで開発しているデザインシステム 『Tsukuri』 の Web 向け実装である『Tsukuri for Web』の構築について紹介する一連の記事の 3 つ目に相当します。

この記事は以前の記事を前提に作成しているため、先にそれらを読むことをお勧めします

tech.andpad.co.jp

tech.andpad.co.jp

この記事では以下について記載します。

  • Tsukuri for Web の全体的な構成について
  • Tsukuri for Web の開発で用いているツールやライブラリについて

この記事では上記の内容を通して以下を満たすことを目的としています。

  • 各種開発ツールとその特徴などの情報を共有すること
  • 特にモノレポでの開発における知見を共有すること
  • Web フロントエンド開発者がアンドパッドに興味をもってもらうこと

2. リポジトリ全体に関して

以前の記事に示した通り、 Tsukuri for Web で開発する主要な対象は以下です。

  • UI コンポーネント
  • アイコンコンポーネント
  • デザイントークン

これらは密接に関係しており、開発からリリースまでのサイクルが同期的になるものが多いです。また、コンポーネントライブラリは複数の JavaScript ライブラリに対応するという特徴もあります。 各パッケージは独立していた方がいい反面、パッケージ間の関連が強いため同時に開発できることが望ましいです。

このような理由で Tsukuri for Web はモノレポで開発を行うことにしました。

Tsukuri for Web は現在 11 の社内向け公開 npm パッケージと、32 のインターナル npm パッケージで構成されています。 このセクションでは、 Tsukuri for Web の開発を行っているリポジトリ全体についての説明を行います。

2.1. ディレクトリの構造

ディレクトリの構造を主要なものについて示すと以下のようになっています。 基本的にツリーの末端がそれぞれ npm パッケージです。 以下に示したもの以外にも、共通設定をまとめるためのパッケージや、開発で用いるツールなどを実装したパッケージなどが存在しています。

.
|-- example # 各種利用例
|   |-- react # Next の利用例
|   |-- vanilla # フレームワークなどを使わない利用例
|   |-- vue2 # Nuxt 2 / Vue 2.7 の利用例
|   |-- vue26 # Nuxt 2 / Vue 2.6 / @vue/composition-api の利用例
|   `-- vue3 # Nuxt 3 の利用例
`-- packages
    |-- components
    |   |-- react # React コンポーネントライブラリ
    |   |-- vue   # Vue コンポーネントライブラリ
    |   `-- wc    # Web Component コンポーネントライブラリ
    |-- components-generator 
    |   |-- cli  # ラッパーコンポーネント生成を行う CLI
    |   |-- common # ラッパーコンポーネント生成の共通ロジック
    |   |-- react # React の UI コンポーネントを生成するためのロジック
    |   `-- vue # Vue の UI コンポーネントを生成するためのロジック
    |-- icons
    |   |-- svg # アイコンのもとになる SVG を管理するパッケージ
    |   |-- react # React のアイコンコンポーネントライブラリ
    |   |-- vue # Vue のアイコンコンポーネントライブラリ
    |   `-- wc # Web Component のアイコンコンポーネントライブラリ
    |-- icons-generator
    |   |-- cli # アイコンコンポーネント生成を行う CLI 
    |   |-- common # アイコン生成の共通ロジック
    |   |-- react # React のアイコンコンポーネントを生成するためのロジック
    |   |-- vue # Vue のアイコンコンポーネントを生成するためのロジック
    |   `-- wc # Web Component のアイコンコンポーネントを生成するためのロジック
    `-- token
        |-- meta # デザイントークンのメタデータを管理するパッケージ
        |-- css # デザイントークンの CSS ライブラリ
        `-- scss # デザイントークンの SCSS ライブラリ

packages ディレクトリ以下に npm パッケージが格納されており、以降に示すパッケージマネージャで workspace の一部として管理しています。

example ディレクトリ以下にはパブリッシュしたパッケージを実際にインストールをして動作を確認するためのサンドボックスプロジェクトを配置しています。パブリッシュしたパッケージをインストールするので、これらは workspace には含まれていません。

2.2. パッケージマネージャ

これらは pnpm workspace で管理しています。 パッケージマネージャに pnpm を採用した理由は、比較的厳密に npm モジュールの階層構造をつくる pnpm のインストール方式が、モノレポで npm ライブラリを実装する上で適していると考えたからです。

例えば、 npm はデフォルトでこのような方式ではなくワークスペースルートに巻き上げたインストール方式を取ります。 この状態だと、あるパッケージが自身の dependencies フィールドに設定していないパッケージを参照できてしまうことがあります。 結果として npm パッケージのパブリッシュ後にパッケージが不足して動作しないということが起こりえます。 pnpm の方式であれば開発時に package.json に設定されいないパッケージにはアクセスができないので、その心配はありません。

ただ、先日リリースされた npm v9.4.0 ではこの方式を参考にしたインストール方法がサポートされています。 今からパッケージマネージャを選択するなら npm でも十分かもしれません。

pnpm などのパッケージマネージャとそのバージョン管理は corepack を利用しています。 corepack を利用することで package.json の packageManager フィールドに記載されたバージョンのパッケージマネージャを自動で利用できるようになります。この packageManager フィールドは Renovate が認識してくれるためパッケージマネージャのバージョンアップも円滑に行うことができます。

2.3. Node.js

開発に用いる Node.js 自体のバージョン管理には .node-version ファイルを用いています。このファイルは多くの Node.js のバージョン管理をするソフトウェアで用いられているため、ほかの開発者の既存の環境とマッチしやすいです。 さらに、 GitHub Actions の setup-node アクションでもこのファイルを利用することで特定のバージョンを選択しやすくなるためこの方式を採用しています。

Node.js のバージョン管理をするソフトウェア自体は特に定めていませんが、クロスプラットフォームで動作し、.node-version に記載されているバージョンの Node.js が存在しなければインストールするかを聞いてくれるという点が気に入っているため fnm を推奨しています。

2.4. スキャッフォールディングツール

モノレポに新しいパッケージを追加するときなど、定型的なファイル作成タスクが発生することがあります。 このような時に自動的にファイルを作成してくれるツールがあると便利です。

scaffdog を用いてこれを実現しています。同様な機能を持つツールは hygen などよく使われているツールがほかにもありますが、scaffdog はテンプレートを Markdown のコードブロックで記述するという点がユニークです。 この特徴のおかげでテンプレートファイルにシンタックスハイライトやコードフォーマットを効かせることが簡単になります。 他のツールではテンプレート用のファイルフォーマットを利用するため、無効なコードを記述してしまったのですがハイライトがなく気が付かないということもあります。

また、Markdown の中にテンプレートの説明なども記述することができることも気に入っており利用しています。

2.5. そのほかの モノレポツール

複数パッケージの npm タスクを効率的に実行するために、タスクランナーには Turborepo を利用しています。

これに加えて、各パッケージのバージョニング・パブリッシュなどには lerna-lite を利用しています。

モノレポに関するツールには開発に必要な機能を広く備えた NxLerna などもあります。

単一のツールで多くのことをカバーしてくれることは、ある目的に対して手段が明確になりやすく、開発を円滑に進めることを大きく後押ししてくれることがあります。

一方で、目的に対する手段をツールが提供してくれていない場合は、『ツールが対応していないからできない』という結論になることもあります。

Tsukuri for Web では、特定の手段を提供してくれるツールを組み合わせて利用することで、必要に応じてツールを変更し、迅速に要求に応えられることを重視しました。

結果として、タスクランナーやパッケージのパブリッシュなどの目的ごとにツールを選択しています。

3. ビルド・バンドルツールに関して

各パッケージは基本的に esm・cjs の形式でビルドを行っています。 これは、Tsukuri for Web の利用者の中にはまだ esm に対応していない物があるためです。

これをビルドするツールには vitetsup にいずれかを利用しています。 これらはいずれも rollupesbuild などのバンドラーをラップしたもので、設定を簡単にできたり追加の機能があったりするものです。

vite の特徴は高速な hot module replacement (HMR) です。また、rollup のそれを拡張したプラグインが利用できます。そのため、 Vue コンポーネントなどモジュール変換のプラグインを利用するパッケージや、UI コンポーネントライブラリなど開発時ビルドで高速な HMR が必要なものは vite を利用し、Typescript のみで書かれたライブラリなどそれ以外のケースでは tsup を使うことにしています。

4. ドキュメンテーションに関して

UI コンポーネントライブラリ・アイコンライブラリ・デザイントークンライブラリの仕様や利用方法についてのドキュメントはすべて Storybook に記述しています。

また、開発対象が UI コンポーネントライブラリという性質上、開発時の動作確認にも利用します。後述するテストにも利用しており、開発で中心的な役割を担っています。

アイコンライブラリとデザイントークンライブラリはメタ情報から機械的に生成しているため、Storybook の Story もメタ情報をもとに自動的に生成しています。

一方で、UI コンポーネントライブラリはデザインをもとに開発しているため、Story の記述は手で行っています。 作成する Story は、コンポーネントの用途や備えておくべき機能ごとに作成しており、デザインシステムで定義された表示や状態をどのように利用できるかを記載しています。

UI コンポーネントの Storybook

この情報はマークアップ方法やインターフェースが各ライブラリごと微妙にことなるため、それぞれ用意することが利用者にとって望ましいです。 そこで、Vue・React でも Web Component と同じものを実装しています。

ですが、これは単純に実装や機能拡張に伴う変更などの作業量が 3倍 (Web Component ・ Vue ・ React) になり大変です。 この負担を軽減するために Web Component 向けに実装した Story のコードを入力に React 用や Vue 用の Story のコードを出力するようなプログラムを実装しています。

これには jscodeshift を用いています。jscodeshift は対象コードの AST を操作してコードを変換することを補助するツールです。これで、Web Component 用の Story のコードでインポートしている @storybook/web-component@storybook/vue3 に変更するなどができます。

残念ながらこのツールだけで完璧な変換はできませんが、80% - 95% くらいの完成度で変換ができています。 結果として、各ライブラリの Story は多くの場合僅かな修正のみで済み、 Web Component に対するものの実装に集中すればよい状態になっています。

また、Tsukuri for Web には複数の Storybook が存在しています。この Storybook は GitHub Pages にサブディレクトリで分けてそれぞれデプロイし社内向けに共有しています。 (GitHub Enterprise Cloud では GitHub Pages の公開先を限定することができます。)

5. テストに関して

『ブラウザで実行するテスト』と『Node.js で実行するテスト』の二種類を実装しています。

Node.js で実行するテストは基本的には実行速度が早いです。一方でWebブラウザにレンダリングされたあとのグラフィカルな観点ではテストができませんし、 Web ブラウザの実行環境で実装されている API に依存するロジックは場合によっては正しくテストできません。JSDOMhappy-dom など、これらの API を実装した環境を利用すれば一部をテストできますが完全ではありません。

一方で、ブラウザで実行するテストは基本的に完全な動作を検証できますが比較的実行が遅いです。

以降では、これら2種類のテストの使い分けや実装などについて紹介します

5.1. ブラウザで実行するテスト

ブラウザで実行するテストは、Storybook に実装された Story に対して @storybook/test-runner を用いてテストをしています。 また、ブラウザ間での挙動の差に依存するエラーを検知するために @storybook/test-runner は chromiumとwebkit ブラウザで実行しています。

先に記載したとおり、ブラウザで実行するテストは比較的遅く、開発時にPRごとに実行すると開発のペースが落ちてしまいます。 そこで、ブラウザで実行するテストはデイリーでの実行のみとしています。

開発しているのがライブラリであるという性質上、プロダクトへの導入とリリースには一定のリードタイムが存在することから、この程度の頻度で行っても一定の品質を保つ上では十分であり、開発の効率を維持する上では良いバランスであろうと判断しています。

ここで行っているテストの内容は以下の通りです

  • Storybook の play 関数で実装したインタラクションテスト
  • Visual Regression Test
  • @axe-core/playwright による a11y テスト

以降でそれぞれについて説明します。

5.1.1. Storybook の play 関数で実装したインタラクションテスト

Storybook では Story に play というフィールドを定義できます。 これは、Story に記述したコンポーネントに対して実行するインタラクションなどを定義した関数です。

play 関数と以下の npm パッケージを利用することで、例えば、チェックボックスコンポーネントをクリックした後にチェックボックスの状態が変化しているか?というテストが実装できます。

@storybook/testing-library は @storybook/addon-interactions で実行した操作を表示するために testing-library をラップしたものです。 testing-library で提供されているクエリーはロールやラベルを入力とするため、マークアップなどの変更で壊れにくいテストを実装しやすくなります。

ですが、testing-library のクエリーは Web Component の Shadow DOM の内部の要素を取得することはできません。 これは、Web Component が意図せず壊されることを防ぐ強力な仕様ですが、テストをするときには扱いにくいことがあります。

これを回避するために shadow-dom-testing-library というライブラリを利用しています。 このライブラリを @storybook/testing-library 同様に @storybook/addon-interactionsでログを表示するための処理をしたものをインターナルパッケージで実装し、利用しています。

このテストで、デザインドキュメントで定義されたふるまいを期待通り実装しているかや、a11y の観点から実装されているべきキーボードのインタラクションなどに対して適切に動作しているかを評価しています。

5.1.2. Visual Regression Test (VRT)

VRT はすでに広く用いられているテスト方法で、特定のタイミングでスクリーンショットを取得し、実装の変更後に同様に取得したスナップショットと前回のスナップショットを比較することで見た目の変化を検知するものです。

@storybook/test-runner は play 関数の実行後などに任意の操作を実行させることができます。 ここで、@storybook/test-runner の内部で利用している playwright の API を用いてスクリーンショットを取得し、以前に取得したスクリーンショットと比較を行うことで実現できます。

また、この処理の中では Story の パラメータなども参照できます。 そこで、例えば以下の様に特定のパラメータを設定したStory ではスナップショットを撮らないなどの指定をできるようにしています。

export TestStory = {
  parameters: {
     snapshot: {
       disable: true
     }
  }
}

こちらについては公式でレシピを共有してくれているので、これを参考に実装しています。

5.1.3. axe-core による a11y テスト

コントラスト比など色に関する a11y はデザイナーが色を設計する際に担保してくれていますが、適切なマークアップなどは開発時に作り込まれます。これを確認するために実行しています。

こちらも公式で紹介されている例を参考に実装しています。

5.2. Node.js で実行するテスト

テストランナーには vitest を利用しています。

vitest は vite をベースにしたテストランナーです。vite がベースであるため、モジュールの変換の設定などを vite と共有できます。他にもよく利用されているテストランナーには jest があります。変換が必要なコードを jest でテストするためには、適切なトランスフォーマーを設定する必要があります。これらの設定が vite にまとめられるという点は一つのメリットです。

ここで行っているテストの内容は以下の通りです

  • コンポーネントで用いられるロジックのテスト
  • Storybook の play 関数で実装したインタラクションテスト
  • axe-core による a11y テスト

5.2.1. コンポーネントで用いられるロジックのテスト

UI コンポーネントで用いているロジックのテストを記述しています。

vitest には in source testing という機能があり、基本的にはこれを利用してソースコードの中にテストを記述しています。

Node.js はモジュールがファイルで表現されており、モジュールからの公開・非公開のみ指定ができます。 従って、別のファイルにテストを作成した場合、そのロジックをテストするにはモジュールから公開する必要があります。

ユニットテストは公開されているロジックに対してのみテストを記述するべきという主張を支持すれば、この制約は問題にはなりません。

ですが、私は、実装対象の仕様を先に型やテストで記述しながら最終的なインターフェースを検討し、設計や開発を進めるというスタイルをとることがあります。 このような時は最終的に公開するロジックに限らず、テストを書いていくことになります。 この場合は、関数を公開せずにテストを書けることにメリットがあります。

また、UI コンポーネントを実装しているため、多くの場合、最終的に公開する必要があるのはその UI コンポーネントのみです。 UI コンポーネントはそれが保持している状態などによってふるまいが変わります。 すると、内部で利用している特定のロジックを検証するのが難しいことがあるので、そのような場合も in source testing だと必要な部分のテストを書きやすくなります。

5.2.2. Storybook の play 関数で実装したインタラクションテスト

ブラウザのテストでも実行している Storybook のテストですが、Node.js でも JSDOM 環境を使って実行しています。 ブラウザのテストは比較的遅いためデイリーで行っている一方で、誤りに気が付くのが必ず翌日になったりするとそれはそれで開発が速度が遅くなります。 そこで、JSDOM の環境でもテストを動かし、そこでできる範囲のものを確認するようにしています。

ここで、Storybook の Story を JSDOM の環境でも動かす方法ですが、 @storybook/testing-react@storybook/testing-vue3 というパッケージで実現できます。

一方で、Web Component に対応する @storybook/testing-web-components のようなパッケージは存在しません。 そこで、@storybook/testing-react を参考に実装し利用しています。

この実装については以下に記載しているので、気になればそちらを参照してください。

zenn.dev

また、 @storybook/testing-react で公開されている API は @storybook/react から公開されるようになるようですので、ほかのライブラリらも同様に本体から利用できるようになるかもしれません。

5.2.3. axe-core による a11y テスト

こちらも、前述したインタラクションテストと同様の理由で Node.js 環境でも実行しています。 前述の実装を用いて Story で記述したコンポーネントらを JSDOM 環境にレンダリングし、最終的な DOM ツリーに対して axe-core を実行してテストをしています。

マークアップの誤りや不適切なロールの指定などは開発をしながら気が付いた方が良いことが多いので動作が早い JSDOM 環境でチェックするメリットがあります。 一方でブラウザによって評価が違うことがあるようですので、ブラウザでも実行することには意味があります。

6. CI に関して

CI には GitHub Actions を利用しています。開発ブランチからデフォルトブランチに対するPR ごとに以下を実行します。

  • コードフォーマットチェック
  • Lintチェック
  • 型チェック
  • vitest で実装しているテスト
  • ビルド

これらの各タスクについては特筆すべきことはないです。

一連のタスク実行を高速化するために Turborepo のキャッシュを用いています。 これに加えて、コードフォーマットや Lint においては Prettier・ESLint・Stylelint それぞれのキャッシュを作成し利用するようにしています。

各タスクのキャッシュに pnpm のストアを加えたデータを actions/cache/saveactions/cache/restore で保存し、各ジョブで利用しています。

actions/cache を使うと、キャッシュヒットしたときに新しいキャッシュが保存されません。 これだと、フルビルドをするか、キャッシュを使うかしかできなくなり、Turborepo の提供するパッケージ・タスクごとのキャッシュを完全には利用できません。

そこで、キャッシュキーを固定し actions/cache/restore で毎回キャッシュをリストアできるようにします。 そして、ワークフローの最後で、gh コマンドの gh-actions-cache 拡張 を用いて既存のキャッシュを削除し、 actions/cache/save で新しいキャッシュを保存しなおすということを行っています。

pnpm のストアデータをキャッシュしている件ですが、actions/setup-node でもキャッシュができます。 ですが、キャッシュキーが pnpm-lock.yaml になっています。モノレポは全体で多くのパッケージを利用しており、 Renovate でパッケージの更新を日常的に行っている場合、キャッシュの破棄が頻繁に発生して活用できないシーンが多いです。一方で、そもそも pnpm のストアデータは、pnpm-lock.yaml が更新されても活用ができるのでキャッシュを破棄してしまうのはもったいないです。 そのため、上記のようにキャッシュを追加する形をとり、キャッシュヒット率を高めています。

このような運用をしていると、どんどんキャッシュデータが肥大化していきます。キャッシュが肥大化すると使われないキャッシュのリストアやセーブに時間がとられてしまいます。 これを回避するためにキャッシュデータを削除するワークフローを定期的に実行して一定期間でキャッシュをリフレッシュするようにしています。

モノレポにすると含まれるコードが増えるため CI の時間が延びる傾向がありますが、このような工夫をすることで実行時間をできるだけ短くするようにしています。

7. リリースに関して

社内向けの公開ライブラリは GitHub Packages で公開しています。 社内のユーザに向けて限定的に公開する上で十分である上に、明示的にアクセスできるリポジトリーを設定することで GitHub Actions の一時トークンでアクセスすることが可能です。 この特徴は永続化トークンを保持する必要性を低減するためセキュリティ的にも優れていると考えています。

開発からパブリッシュまでの流れは以下ようなものにしています。

  1. デフォルトブランチにコミットが追加される
  2. 各パッケージを適切なバージョンに bump する変更と CHANGELOG が含まれる PR が GitHub Actions で自動作成される
    • バージョンの決定には Conventional Commits のルールに基づいたコミットメッセージを用いる
  3. リリースを行いたいときは自動作成された PR をマージする
  4. マージ後に GitHub Actions で以下が自動実行される
    • パッケージ名@バージョン のタグを作成してリモートに push する
    • パッケージのパブリッシュ
  5. 必要に応じて GitHub Release でリリースノートを作成する
  6. リリースノートが Slack の開発チャンネルに通知される

PR の自動作成には peter-evans/create-pull-request 用いています。バージョンアップなどの処理を行った後でこのアクションを実行すると指定した PR が作成、あるいは、上書きされます。

前述の通りバージョンの決定や GitHub Packages へのパブリッシュには lerna-lite を用いています。 モノレポのパッケージバージョンの管理には changesets など、メタデータを同時に管理することで任意のバージョンに上げるようなツールも存在します。 ですが、私はバージョンが変更の内容によって決定されるべきだと考えており、変更を示すコミットとコミットメッセージ以外の情報を管理する方式はこの考えに合いませんでした。

また、 デフォルトブランチへの PR を Squash マージさせ、マージの際のメッセージに Conventional Commits ルールを用いることで、一つの PR が単一の目的になることを強く後押しできます。

このような考えを踏まえたときに最も適したツールは Lerna だったのですが、Lerna 自体には Tsukuri for Web では使わない多くの機能が備わっているので、ここから機能を切り出した lerna-lite を利用しています。 lerna-lite を利用することで、コミットログからバージョンが決定し、各パッケージの CHANGELOG が自動で生成できます。

リリースノートは CHANGELOG とは異なり、利用者に向けたメッセージであると考えているので、必ず手で書き、必要に応じてイメージなどを添付するようにしています。 これは Slack の Tsukuri for Web の開発に関するチャンネルに通知されるので、利用者は新しいパッケージのリリースとその概要をスムーズに知ることができます。

8. おわりに

Tsukuri for Web のリポジトリ全体や、そこで利用してるツールなどの紹介をしました。 モノレポでの開発に関する情報は比較的少ないものであると感じているので、参考になる情報があれば幸いです。

また、この記事をきっかけにアンドパッドに興味を持った Web フロントエンド開発者がいらっしゃったらお気軽にご連絡をいただけると嬉しいです。

次回の記事では、 Tsukuri for Web の現状や今後についてを共有したいと思います。

UI/UXデザイナー | 株式会社アンドパッド

その他のポジションについては、下記サイトをご覧ください!

engineer.andpad.co.jp

この記事の続編となる記事が公開されました。
現状と今後についてまとめましたので、合わせてお読みください。

tech.andpad.co.jp