株式会社HEARTBEATSでフロントエンドエンジニアをやっている何です!今回は会社のプロダクトであるORICALのコードベースについて話をしていきます。
heartbeats-inc.co.jp
エンジニアとして転職活動をする際に、会社の事業内容や技術スタック、開発体制については、面接の中である程度知ることができます。
でも、実際に毎日触るコードベースの実態は、入社するまでほとんど分かりません。
どんな設計思想で書かれているのか。
画面が増えたときに、ちゃんと拡張できる構造になっているのか。
複雑な業務ロジックと UI が絡み合って、触るのが怖い状態になっていないか。
私自身も、入社前にはそういう不安がありました。
特にフロントエンドは、プロダクトが成長するほど画面が増え、状態が増え、例外対応も増えていきます。外から見たサービスがきれいでも、実際のコードがどう設計されているかは、なかなか分かりません。
この記事では、入社して自分が実際にコードを読みながら感じた、当社フロントエンドの Atomic Design をベースにしたコンポーネント分類と、カラーパレットまわりの設計について書きました。
Atomic Design にはいろいろな流儀がありますが、この記事では、通信責務やドメイン分割まで含めた設計論全体ではなく、UI コンポーネントを粒度ごとに分け、その置き場所と命名をどう扱っているかに絞って見ています。
「入社したら、どんなコードに触れることになるんだろう」という不安を少しでも減らせる記事になればうれしいです。
このコードベースの規模感
今のコードベースは執筆現在、以下の規模になっています。
- コミット数は約 10,500
- 実装コードは約 32 万行
- 主な技術スタックは Nuxt、Vue、TypeScript、Pinia、Sass
- テストは Jest、Vitest、Playwright
概算したものなので、厳密な数字ではありませんが、6年以上あるだけあってかなり大きいです。
UI コンポーネントも多いのですが、その整理方法については、このあとの Atomic Design の項目で詳しく書きます。
慣れてきて気づいたコードベースの良いところ
最初は、Atomic Design のレイヤー、カラーパレット、パートナーごとの差分、API リクエストまわりのカプセル化など、プロジェクト独自の書き方もあるので、正直に言うと覚えることが多いです。
ここでいうパートナーとは、ORICAL を導入している球団、スポーツ団体、エンタメ企業などのことです。
サービスの基本構造は共通していますが、各パートナーのブランドやファン体験に合わせた見た目・画面・導線の違いがあります。
ただ、少しずつ読み進めていくと、それらが単なる独自ルールではなく、大きなプロダクトを長く運用するための設計だと分かってきました。
ここからは、実際にコードを読んでいて「これは良いな」と感じたところを紹介します。
1. Atomic Design によって、大量のコンポーネントをチームで扱える形にしている
メインのコンポーネントフォルダだけで 687 個のコンポーネントがあります。
これだけの量があると、分類と命名が曖昧なだけで、すぐに探しづらくなります。
このコードベースでは、コンポーネントの置き場所と名前に Atomic Design のレイヤーがはっきり反映されていて、その混乱を防いでいます。
記事を書いている現在のコードベースを集計すると、メインのコンポーネントフォルダ配下は次のような規模でした。
src/components/UI
├── atoms 91 個
├── molecules 120 個
├── organisms 232 個
└── templates 244 個
それぞれのレイヤーの意味は、だいたい次のように分かれています。
- atoms 最小単位の UI コンポーネント
- molecules atoms を組み合わせた小さな UI コンポーネント
- organisms プロダクト固有の概念を扱う UI コンポーネント
- templates 画面全体に近い構造を組み立てるコンポーネント
たとえば atoms には、ボタン、テキスト、画像、動画、枠、ラベル、ローディングなど、画面の最小単位になるコンポーネントが置かれています。
src/components/UI/atoms
├── AButton.vue
├── ADescription.vue
├── AImg.vue
├── AVideo.vue
├── ARect.vue
├── AProgressBar.vue
└── ...
molecules には、複数の atom を組み合わせた UI パーツが置かれています。
src/components/UI/molecules
├── MCarousel.vue
├── MTabBar.vue
├── MOrica.vue
├── MSelectButtons.vue
├── MPopupBase.vue
└── ...
organisms になると、プロダクト固有の概念を扱うコンポーネントが増えます。
ここでいうプロダクト固有の概念とは、パートナーごとの見た目の違いではなく、ORICAL の機能として出てくるミッション、ランキング、ガチャ、ログイン状態のようなものです。
たとえば、ミッションの進捗や達成状態、報酬の受け取り可否といったプロダクト固有の概念を扱うコンポーネントは、organism として実装します。一方で、見た目や操作だけで完結するボタン、テキスト、タブ、カルーセルのようなコンポーネントは、より下位の layer で実装します。
src/components/UI/organisms
├── cardpack
├── bingo
├── game
├── mission
├── ranking
├── login
└── ...
templates は、ページ内の大きな領域を組み立てるレイヤーです。
ここでいう template は、単なる skeleton というより、organism や molecule を並べて、一覧、詳細、空状態、ローディング中など、ユーザーに見える画面のまとまりを作るものです。必ず「1 ページ \= 1 template」ではなく、画面内の大きな領域ごとに分けることもありますが、API request や細かい業務判定を抱えるというより、渡されたデータを使ってページに近い UI 構造を組み立てる役割に近いです。
src/components/UI/templates
├── cardpack
├── home
├── mvp
├── binder
├── missions
├── collection
└── ...
命名もかなり分かりやすいです。ファイル名とコンポーネント名にはレイヤーを表すプレフィックスが付いています。
AButton atom
ADescription atom
MCarousel molecule
MTabBar molecule
OMissionItem organism
OBingoFooter organism
TMissions template
THomeTop template
A / M / O / T のプレフィックスがあるので、初めて見るコンポーネントでも「だいたいどの粒度のものなのか」が名前だけで分かります。
なお、このコードベースには A / M / O / T 以外のプレフィックスもあります。たとえば CColor のような C* は、見た目のレイヤーに入るものというより、色設定などを配下の UI コンポーネントに渡すためのコンポーネントです。また、MdDialog のような Md* はモーダル用のコンポーネントで、ボタンやテキストなどの基礎コンポーネントを組み合わせて、1 つのポップアップ体験を作ります。
たとえば OMissionItem は、ユーザーが達成するミッションの1つを表示する organism です。
ここでいうミッションは、条件を満たすと報酬を受け取れるタスクのようなものです。概念的には、次のように atom を組み合わせてミッションの UI を作ります。
<template>
<li>
<a-description>
{{ mission.title }}
</a-description>
<a-progress-bar
:value="mission.currentCount"
:max="mission.requiredCount"
/>
<a-button
v-if="mission.isCompleted && !mission.rewardReceived"
color="primary"
>
報酬を受け取る
</a-button>
</li>
</template>
このコンポーネントは「ミッション名」「進捗」「達成済みかどうか」「報酬を受け取れるか」といったプロダクト固有の情報を知っています。
なので atom ではありません。
一方で、ミッション画面全体の一覧、空状態、タブ切り替えまで組み立てるわけではありません。
なので template でもありません。
この粒度だから organism に置かれている、という納得感があります。
Atomic Design の良さは、実装者だけでなくレビュワーにも効いていると感じます。
- atom の変更なら、共通 UI への影響を見る
- molecule の変更なら、組み合わせ方や再利用性を見る
- organism の変更なら、プロダクト固有の概念として自然かを見る
- template の変更なら、画面全体の流れを見る
新しく入った方にとっても、「まずどこを探せばいいのか」「この変更はどのレイヤーに入れるべきか」が分かりやすくなります。
2. 業務データの流れと UI 表現の間に、はっきりした境界がある
まず良いなと思ったのは、UI コンポーネントがすべての責務を抱え込んでいないことです。
このコードベースでは、コンポーネントのレイヤーによって、扱う責務がかなり変わります。
たとえば AButton や ADescription のような atom は、基本的に最小単位の UI 表現を担当します。
<a-button color="primary" font-color="white" :emboss-size="3">
購入する
</a-button>
このレイヤーでは、「このボタンをどう描画するか」「disabled 状態のときにどう見えるか」「リンクとボタンをどう切り替えるか」のような、UI の基礎的な振る舞いに集中します。
一方で、`MCarousel` や `MTabBar` のような molecule は、複数の atom を組み合わせて、少し大きな UI パーツを作ります。
<m-tab-bar
:items="['イベント', '達成済み']"
:routes="['/missions/event', '/missions/completed']"
/>
さらに TMissions のような template になると、画面全体に近い構造を組み立てます。
<t-missions
:loading="loading"
:missions="missions"
:selected-tab="selectedTab"
/>
ここで大事なのは、atom がいきなり API リクエストの詳細を知ったり、画面全体のビジネスフローを抱えたりしないことです。
もちろん、template や organism は、ミッションの進捗や報酬の受け取り可否、ランキングの順位のような、プロダクト固有の状態や表示条件を扱います。完全にビジネスロジックがゼロというわけではありません。
ただ、業務データの流れと、UI をどう表現するかの間には、かなりはっきりした境界があります。
この境界があると、長期的な保守性にかなり効いてきます。
たとえばミッション画面で、API レスポンスに新しい達成状態が追加されたり、報酬を受け取れる条件が変わったりしたとします。
もし ボタン(AButton) や 説明文(ADescription)、タブ切り替えコンポーネント(MTabBar)、カルーセル(MCarousel)のような基礎 UI コンポーネントに、ミッション固有の条件分岐やリクエストの知識が入り込んでいたら、仕様変更のたびに共通コンポーネントを触ることになります。
共通コンポーネントは使われる場所が多いので、変更の影響範囲も広くなります。
本来はミッション画面だけの変更なのに、他画面のボタンやタブ、カルーセルの挙動まで確認しなければならなくなります。
これが続くと、「小さな変更なのに、どこまで壊れるか分からない」という状態になります。
先ほど書いたレイヤー分けのおかげで、コンポーネントの責務も名前と置き場所から読み取りやすくなっています。
AButton はボタンとしての見た目や基本的な挙動のみを扱い、TMissions はミッション画面としての構造や表示条件を扱います。
つまり、変更理由が違うものを同じコンポーネントに押し込まない設計になっています。
API や業務条件が変わったときは、データや画面に近いレイヤーを見ればよく、ボタンやテキスト、タブ、カルーセルのような基礎コンポーネントは安定したまま保ちやすいです。
大きなコードベースでは、「どこを直すべきか」だけでなく、「どこを直さなくてよいか」が分かることも大切です。
この分離は、その安心感を作ってくれていると感じました。
3. 高度にカスタムされた UI と開発効率の間で、いいバランスを取っている
このプロダクトの UI は、カード、動画、アニメーション、MVP、ビンゴ、バインダー、ゲーム的な画面、パートナーごとのホーム画面など、かなりカスタマイズ性が高く、第三者の UI ライブラリを用いた開発では要件に対応しきれません。
つまり、私たちでコンポーネントを細かく作りこみ、管理する必要がありますが、そのやり方にはいくつかの方式があります。大まかには、コンポーネントを別パッケージもしくはリポジトリにするか、同じリポジトリでまとめて管理するかどうかです。
以前、自分がいた大きめの会社では、コンポーネントを別パッケージとして切り出し、npm パッケージとして管理していました。
その方法にも良いところはあります。複数プロダクトで同じ UI を使う場合は、特に効果があります。
ただし、別パッケージにすると、どうしても次のようなコストが出ます。
- UI ライブラリ側とプロダクト側で調整が必要になる
- 修正してからnpmやプライベートレジストリにパブリッシュする必要がある
- プロダクト側でバージョンアップデートが必要になる
- 細かい UI 調整のたびに確認フローが増える
このコードベースでは、UI コンポーネントシステムをメインリポジトリの中に持っています。そのため、業務要件と UI の変更を同じリポジトリの中で一緒に進められます。これは、かなり現実的で効率の良い選択だと感じました。
- 高度にカスタムしたい
- でも開発速度も落としたくない
その両方を満たすために、Atomic Design コンポーネントをメインリポジトリの中で育てている、という印象です。
4. ユーザー体験の一貫性がコンポーネントシステムによって担保されている
良いコンポーネントシステムは、見た目を再利用するだけではありません。ユーザー体験の一貫性も再利用します。
たとえば AButton は、単にボタンの見た目を提供するだけではありません。
内部で button や a などを切り替え、disabled 状態を扱い、グラデーションやシャドウを扱い、クリック時のアナリティクスのトラッキングも行います。
概念的には、次のような責務を atom 側に閉じ込めています。
const componentName = computed(() => {
if (to.value && !disabled.value) {
return "internal-link";
} else if (href.value && !disabled.value) {
return "a";
}
return "button";
});
const click = (e: MouseEvent) => {
if (!disabled.value) {
$analytics.trackUiInteraction({
componentName: "a-button",
eventAction: "click",
pathName: $route.name || $route.path,
label: gaLabel.value,
});
emit("click", e);
}
};
こうした基礎的な振る舞いが atom に入っていることで、画面ごとにバラバラなボタン実装が増えにくくなります。
「ボタンを押したときのトラッキングをどうするか」
「外部リンクと内部リンクをどう扱うか」
「disabled 状態のときにどう振る舞うか」
こういう細かい体験の差が、コンポーネントレイヤーに吸収されています。
小さなことに見えるかもしれませんが、画面数が多いプロダクトではかなりの効果があります。
UI/UX の一貫性は、こういう基礎コンポーネントの積み重ねで守られているのだと思います。
5. カラーパレットとパートナー差分を、散らばった CSS ではなくシステムとして扱っている
カラーパレットまわりも、とても面白い設計です。
画面上には、「購入ボタン」「選択中のタブ」「カードの枠線」のように、同じ意味を持つ UI が何度も出てきます。
ただ、約 30 パートナーそれぞれでブランドカラーやグラデーションが違うため、各コンポーネントが直接カラーコードを持つと、変更箇所がすぐに散らばってしまいます。
このコードベースでは、色そのものではなく、UI 上の意味をキーにして色を管理しています。
たとえば「メインボタンの明るい配色」「選択中タブの配色」のような意味に対して、背景色、文字色、枠線、影、グラデーションなどをまとめて設定します。
そのための型や仕組みとして、ColorConfigNames、Pallet、CColor コンポーネント、カラーパレット(getColorConfigやgenerated/style) などがあります。
名前だけを見ると少し内部実装寄りですが、役割としては「意味のある色設定を定義し、コンポーネントに渡すための仕組み」です。
たとえば Pallet は、概念的には次のように定義されています。
export type Pallet = {
[P in ColorConfigNames]?: ColorParams<ColorValue>;
};
つまり、各パートナーのパレットは、自由な文字列キーを好き勝手に追加するのではなく、ColorConfigNames に定義された意味のあるキーを上書きしていく形です。
たとえばパートナーごとのパレットでは、次のように UI の意味に対して色を指定します。
export const partnerNamePallet: Pallet = {
"main-button-light": {
color: "primary",
gradientType: GradientType.fixed,
gradientOpts: {
to: "bottom",
startColor: "primary",
endColor: "#B38900",
},
fontColor: "white",
},
"tabbar-light-selected": {
color: "white",
fontColor: "#C2961C",
},
};
ここで重要なのは、コンポーネント側が「このパートナーではこのカラーコードを使う」と直接知らないことです。コンポーネント側は、意味のあるトークンを使います。
たとえば先ほど触れた C* の一つである CColor コンポーネントは、指定された colorConfig から設定を取得し、スロット経由で子コンポーネントに渡します。
使う側は、次のように書けます。
<c-color v-slot="{ config }" color-config="main-button-light">
<a-button
v-bind="config"
:emboss-size="3"
>
購入する
</a-button>
</c-color>
この書き方だと、コンポーネントは「このボタンはメインボタンである」という意味だけを知っています。
実際に何色になるかは、カラーパレット側で決まります。
もし各コンポーネントが直接 #B38900 のような値を持っていたら、パートナーごとの差分はあちこちに散らばります。
変更したいときに、どこを直せばいいのか分かりづらくなります。
カラーパレットによって、視覚差分が設定レイヤーに集約されています。
これは複数パートナーを持つプロダクトではかなり大事な設計だと思います。
まとめ
入社前にコードベースの中身を知ることはなかなかできません。だからこそ不安になります。
実際に入社してコードを読んでみると、派手な仕組みというより、日々の開発で迷いにくくするための工夫が多いコードベースだと感じました。
コンポーネントの粒度、名前の付け方、色の扱い方を見ると、ただ機能を作るだけでなく、あとから触る人が読みやすいか、画面ごとの体験が揃うかまで考えられています。
もちろん、規模が大きいので最初からすべてが簡単に理解できるわけではありません。
ただ、複雑なところにちゃんと名前と置き場所が用意されているので、読み進めるほど設計の意図が見えてきます。
こういう細部に、チームの技術力や設計のセンスが出ていると思いました。
入社前の自分がこの情報を読めていたら、もっと安心していたと思いますので、当社にご興味のある方の参考になれれば幸いです。
heartbeats-inc.co.jp
hrmos.co