Wantedly の Engagement Squad で エンジニアをしている小林です。Wantedly では、社内共通のReactコンポーネントライブラリによって複数のリポジトリ間にまたがるUIの共通化を行っているのですが、この記事では、そのライブラリの基盤改善の取り組みについて紹介します。
Wantedly共通のReactコンポーネントライブラリ
なぜ必要なのか?
Wantedly のフロントエンドはReactで書かれており、そして、フロントエンドコードは複数のリポジトリに分かれて存在しています。元々は一つのモノリシックなリポジトリでフロントエンドも管理していたのですが、最近リニューアルした企業側管理画面などの一部のページは、新しいフロントエンドのリポジトリで書かれています。そして、この新しいフロントエンドのリポジトリも、企業側管理画面、ユーザー側画面など複数存在しています。
リポジトリは分かれていますが、ヘッダーやサイドバー、フッターなどといったナビゲーションなど、リポジトリ間で共通のUIがあります。これらUIは、できれば同じReactコンポーネントを使用して共通化したいです。そこで、それらをWantedly共通のReactコンポーネントライブラリに切り出しています。
以下は実例です。これらのページは異なるリポジトリで管理されていますが、ピンクで囲われたヘッダーは共通の振る舞いを持ったUIで、ライブラリの中で実装されています。
従来の構成と課題
社内共通のReactコンポーネントライブラリが作られた当初は、一部のReactコンポーネントを共通化することを目的とした小さなライブラリでした。コンパイルのフローもシンプルで、 babel と tsc で、 entry となる1つのJSと型を出力したものを npm にパッケージとしてリリースしていました。
しかし、新しいフロントエンドリポジトリで開発が進むにつれて、様々なReactコンポーネントがライブラリに入ることになり、次のような課題が出てきました。
- ロジック(データ取得)までを責務として持ちうるReactコンポーネントの扱いが不定
- 一部のリポジトリでしか使わないReactコンポーネントに依存してしまう
- 巨大かつ限られたリポジトリでしか使わないReactコンポーネントを移行したい
この課題を解決するために、ライブラリの基盤を見直すことになりました。
責務の範囲を決める
「ロジック(データ取得)までを責務として持ちうるReactコンポーネントの扱いが不定」の課題についてまずは考えました。論点としては、ライブラリに存在するReactコンポーネントがデータの取得・更新のロジックまで持つか、interface になっていて外(プロダクト側)から渡すかになります。すでに実装されているReactコンポーネントには、データの取得や更新などのロジックが直接書かれていて、これをライブラリが今後許容すべきかどうかをメンバーで議論しました。
結論としては、ロジックまで含めても良いということに決めました。一番は移行コストの問題で、ロジック部分を切り出すのは時間がかかること、ルールを制限することで生産性が下がってしまうことが決断理由としてあります。
責務の範囲をはじめに定義したことで、次に行うパッケージ分割の作業が明確になりました。
複数パッケージへの分割
上記で挙げられた課題のうち、残りの2つは、ライブラリが単一のパッケージであるために、Reactコンポーネントが増えるに従いパッケージのサイズが肥大化することが問題となっています。なので、次にパッケージ分割に取り組みました。
どう分割するかを決める
UIのまとまり(ヘッダー、サイドバーなど)ひとつひとつに対して、Reactコンポーネントを分割する方法も考えられたのですが、今課題なのは、リポジトリで必要のないReactコンポーネントにまで依存してしまうことであったため、それよりも粗い、ライブラリを使用するリポジトリ(企業側管理画面、ユーザー側画面)を軸にパッケージを分割することにしました。
また、それとは別に、 i18n, API Client など、共通で使用するユーティリティーも1つのパッケージとして切り出しました。
Lerna によるパッケージ分割
パッケージを分割するために、Lerna + yarn workspaces を使用した monorepo 構成に変更しました。
Lerna とは複数のパッケージに分かれた JavaScript パッケージを一つのリポジトリで管理するための monorepo 管理ツールです。npm や yarn もそれ自体がワークスペースという概念で一つのリポジトリで複数パッケージを管理する機構を用意していますが、それらでは足りない部分を補完するようなツールになっています。
Lerna による monorepo 管理の導入には以下の記事が参考になりました。
記事と異なる点として、今回は元々複数のリポジトリにあったパッケージをまとめたのではなく、一つのパッケージを分割するという違いがありました。その部分は、 lerna create
ではじめに必要なパッケージを作成し、地道にそれぞれのパッケージにファイルを移動し、その後 import のパスを変更するという工程を取りました。
Reactコンポーネントのコンパイルについては、今回のケースでは、全てのパッケージで共通であったため、babel や tsconfig の base をルートに用意して、各パッケージでそれらを extends する形にしました。これによりビルド構成を全てのパッケージで共通化しました。
// babel.config.js
const configBase = require("../../babel.config.base");
module.exports = configBase;
この分割により、元々達成したかった肥大化を防ぐという点で、分割前は、1600kB あったパッケージのサイズが、分割後では、最も大きなパッケージサイズで 180kB と、個々のパッケージのサイズを小さくすることができました。
新たなパッケージを追加しやすくする
パッケージを分割する一方で、開発者がパッケージの追加を伴うような新たな共通Reactコンポーネントを実装しにくくなるという課題が出てきました。
この解決策として、テンプレート生成を使用して、コマンド一つで新規パッケージの追加を行えるようにしました。次のコマンドを入力し、質問に入力するだけで、パッケージの追加に必要となるテストの設定やコンパイル、 storybook の設定などを全て自動で行ってくれるようになっています。
yarn new-package
ℹ Output destination directory: "."
// 追加したいパッケージ名を入力する
? Please enter package name. (e.g. shared-footer) shared-global-header
// テンプレートファイルが自動で出力される
Generated 17 files!
✔ packages/shared-global-header/src/SharedComponent.tsx
✔ packages/shared-global-header/src/index.tsx
✔ packages/shared-global-header/src/tests/SharedComponent.test.tsx
✔ packages/shared-global-header/src/index.stories.test.ts
✔ packages/shared-global-header/src/setupTest.ts
✔ packages/shared-global-header/stories/SharedComponent.stories.tsx
✔ packages/shared-global-header/package.json
✔ packages/shared-global-header/.gitignore
✔ packages/shared-global-header/.npmignore
✔ packages/shared-global-header/babel.config.js
✔ packages/shared-global-header/jest.config.js
✔ packages/shared-global-header/README.md
✔ packages/shared-global-header/tsconfig.json
✔ packages/shared-global-header/.storybook/main.js
✔ packages/shared-global-header/.storybook/manager-head.html
✔ packages/shared-global-header/.storybook/preview-head.html
✔ packages/shared-global-header/.storybook/webpack.config.js
実装には、 scaffdog を利用しました。 以下のような テンプレートとなる Markdown を作成するだけで、テンプレートの生成が実現できるので非常に便利でした。
---
name: 'new-package'
root: '.'
output: '.'
ignore: []
questions:
name: 'Please enter package name. (e.g. shared-admin-global-navigation)'
---
# packages/{{ inputs.name }}/src/SharedComponent.tsx
```tsx
import React from "react";
const SharedComponent: React.FC = () => {
return <div>Shared Component</div>
};
export { SharedComponent };
```
またライブラリでは、 CI で storybook の s3 へのアップロードを commit ごとに行っており、この部分も monorepo 管理する上で、 storybook が存在するパッケージを自動取得しそれぞれに対して storybook を上げ直すように改善しました。
commit を Github に Push する度に、パッケージ毎に確認用のURLが作られることで、レビュワーが変更点を確認しやすくなっています。
重要なのは、開発側が設定をする必要なく新規パッケージを追加することができるようになったことです。これらの変更によって、新規パッケージの追加に関する課題が解決できたと思っています。
その他の小さな改善
脱法import文の駆除
パッケージの使用側をみると、次のように、dist
から直接Reactコンポーネントや型を読み込んでいる箇所がありました。
import Header from "@wantedly/shared-admin-header/dist/header"
今回の改善に合わせて、それらを 正しく import
するように変更しました。
中には、 export されていないReactコンポーネントの Props の型などを取得する必要があり、その場合は、適宜ライブラリから export したり、以下のようなコンポーネントから Props を推論する型を用意するなどして対処しました。
type ExtractProps<TComponentOrTProps> = TComponentOrTProps extends React.ComponentType<infer TProps> ? TProps : never;
まとめと今後の課題
社内共通のReactコンポーネントライブラリの基盤改善の試みとして、パッケージを分割するための手順と、分割することで起きる課題の解消手段について記述しました。
しかし、基盤改善を試みる上でより重要なのは、何が課題になっているかを明確化することだとも感じています。例えば、「ライブラリのReactコンポーネント間でロジックが密結合になっている」が課題であれば、パッケージの分割粒度も違ったと思います。課題ベースで改善に取り組めたことは良かったです。
また、今後の課題として、ライブラリにあるユーティリティパッケージの責務が曖昧であることがあります。元々は、共通Reactコンポーネントの間で使用するためのパッケージでしたが、型や関数などが一部露出してしまっているので、これを解決する必要があります。
また、開発生産性という意味では、リリースせずに確認する基盤を整えようと考えています。現状でも、storybook で UI/UX の振る舞いを確認したり、tag を push するだけでリリースすることができたりと整備はされているのですが、デフォルトスタイルが当たったり、他の z-index
が高いUIコンポーネントに負けたりなど、呼び出し時に問題が分かることがあります。リリースを待たずに確認できることで上記の課題を解決したいです。
Wantedly では、他にデザインシステムの実装も取り組んでいます。こちらも興味があれば是非。