フロントエンドのコンポーネント設計を考えてみた
高い生産性を維持し続けるためのファイル構成とその理由
Soudai Sasada
最近は業務でNuxtJS、プライベートではこのブログ(Gatsby)の他にiOSアプリ(SwiftUI x Combine)を開発しており、いくつか並行して開発を進める中でフロントエンドのコンポーネント設計についていくつか思うことがあったので自身の頭の整理のため記事としてアウトプットしたいと思います。いわゆる「ぼくのかんがえたさいきょうのUIファイル構成」というやつですね。
なお、これはあくまで筆者の持論であり一つの意見にすぎません。また、プロジェクトの規模やフェーズ、採用しているアーキテクチャや考え方、既にある成果物、いわゆるコンテキストによって全く違う設計も全然あると思います。なのであくまで一つのパターンという認識です。もし明らかに微妙だなと思ったらこっそり教えて頂けると泣いて喜びます。
この記事で言及すること
- ファイル構成
- コンポーネント設計
この記事で言及しないこと
- コンポーネントの責務を分離し疎にする手法
- UI以外のこと
先に結論(追記)
長くなってしまったので言いたいことを先にまとめます。
- ファイル構成も設計の一部なのでよく考えましょう
- UIコンポーネントライブラリ使うなら必ずラップしましょう
なおこの記事はチームで開発をしているまたは現在一人でも将来チームで開発をすることになる方を対象とした記事ですのでずっと一人で開発をする方には関係のない記事となっております。
ファイル構成
結論から書くと以下のようなファイル構成が望ましいと考えています。
src
+-- ui
|-- components
| |-- Card.vue
| |-- DefaultLayout.vue
| |-- Footer.vue
| |-- Header.vue
| +-- Pager.vue
+-- pages
|-- users
| |-- Users.vue
| |-- components
| | |-- SearchBox.vue
| | +-- Container.vue
+-- services
|-- Services.vue
+-- components
|-- Card.vue
+-- Grid.vue
src
(またはプロジェクトルート)直下に ui
ディレクトリがあり、その中でコンポーネントとページを配置しています(なお各コンポーネントの拡張子は何も付けないと分かりづらかったためあえて .vue
としました)。
それぞれの役割は以下のとおりです。
ディレクトリ | 役割 |
---|---|
components | 共通コンポーネント |
pages | ページコンポーネント及びページ固有の処理 |
それでは早速なぜこれが良いと思うのかについて説明していきます。
なぜsrc(またはプロジェクトルート)直下にcomponentsやpagesを置かないのか
UIに関するファイルはReactであればjsx(or tsx)、Vueであればvueファイルになるかと思います。これらがどこにあるのかをファイル構成で表現することで 認知的な負荷を減らすこと を目的としています。この例で言えばjsxまたはvueファイルは全て「ui」内にあるということですね。
重要な点はフレームワークを用いる場合はこの限りではないということです。これはこのファイル構成にする主な理由が認知的な負荷を減らすことだからです。
たとえばNuxtではルート直下にUI置き場として components
/ layouts
/ pages
、さらにヘルパー的な扱いで mixins
がありますが、フレームワークは主にイージーに主眼を置いていることが多く、こうしたルールがまさにその価値の源泉の一つなのでむしろフレームワークのルールをそのまま踏襲する方が良いでしょう。この例はあくまでフレームワークを使わない、またはフレームワークがUIに関するルールを提供していない場合の話となります。
なおuiやcomponents、pagesという命名にこだわりがあるわけではありません。例えばSwiftならpagesよりもscreensの方がしっくりくるかもしれませんしチームごとにどういった命名が分かりやすいか議論のうえで命名するのが良いと思います。
なぜ src/ui/components
と src/ui/pages/**/components
があるのか
筆者の経験上ほとんどのコンポーネントは1つのコンポーネントからしか参照されません。一方で少数の特定のコンポーネントは複数(それも大抵の場合は2つや3つよりもっと多く)のコンポーネントから参照されています。
例えばサービス一覧ページ内にある検索ボックスはそのページコンポーネント内でしか使われないでしょう。一方でその検索ボックス内で使われる送信ボタンは他の様々なコンポーネントでも使われていたりします。
ここで重要なのは複数のコンポーネントから参照されているコンポーネントの修正がそのコンポーネントに依存している全てのコンポーネントに影響するということです。たとえばレイアウトのコンポーネントがあったときにそのコンポーネントがインターフェース(例えばプロパティ名)を変更したらそれはそのレイアウトを使っている全てのコンポーネントにも影響します。
ソースコードを修正をする上ではその影響がどこまで及ぶのか必ず考えなければなりません。もし修正の影響がディレクトリ構造として表現できているのであればそれは開発者を助ける一つのヒントになります。そうした背景から複数から参照されうるであろう共通コンポーネントは src/ui/components
に置くのが良いと考えています。
一方で1つのコンポーネントからしか参照されないコンポーネントはそれが使用されるページ内に置くべきだと考えています。これは認知的な負荷の観点から「関係しているものはなるべく近くに置いた方が良い」と言えるためです。
具体的な例を考えてみましょう。例えばある一つのページにA、B、C、Dといったコンポーネントが必要だったとしましょう。極端な話これらのコンポーネントが全て全く別のディレクトリに配置されていたとしたらそれだけで考えることが増えます。もし特定のビジネスロジックを持っているコンポーネントだから、といった理由がある場合、それはビジネスロジックとUIを分離できていない可能性を考える必要があります。
ファイルを修正する理由が複数になってしまうとそのファイルの修正に対して考えることが多くなりミスや見落としが発生しやすくなります。そうした観点からUIの責務を考えると、UIはプレゼンテーション層として表示にのみ関心を持つように設計しビジネスロジックとは分離するべきでしょう。分離することができればUIコンポーネント単体でそのページ固有のものか考えれば良くなります。
ページ固有なコンポーネントをそのページに関するディレクトリ内に配置する考え方はマイクロサービス化と同じような考え方と言えます。例えばそのページが必要なくなったときにそのページをディレクトリごと削除することで関係しているコンポーネントも全て削除することができますし、修正するときにそのページ内に影響が閉じていれば複数人で並行して一つのアプリケーションを開発するときにもページさえ別であれば他の開発者の修正の影響を考える必要はありません。一方でページ内で使われていた固有のコンポーネントが全く別のディレクトリ上にあった場合はページを削除するときに別ディレクトリにあったコンポーネントを削除し忘れるといったことが起きたり変更の影響が思わぬ範囲に飛び火するかもしれません。それをコードレビューで見つけるのは至難の業とまではいかないまでも特定のディレクトリに閉じている場合と比べれば難しくなるでしょう。
ここまでをまとめると src/ui/components
内のコンポーネントはグローバル、 src/ui/pages/**/components
内のコンポーネントはローカルなコンポーネントと言えます。
もしローカルだったコンポーネントが他でも使えるとなった場合には src/ui/components
へ移動させるというルールのもと、新たなコンポーネントを作るときにはそれがアプリケーション全体で使うようなものかを考えます。そしてそれが複数から参照された場合にそれを修正する理由が複数となってしまわないか考えます。もし修正する理由が複数となってしまうようであればそれは共通のコンポーネントにするべきではなくたとえ似たような部分があったとしても使用箇所それぞれで固有のコンポーネントとした方が良いものだったということです(場合によってはHOCのような具象を使用する側から注入可能なコンポーネントを別に共通コンポーネントとして分離するかもしれません)。迷った場合はまず使用するページ内に置くべき、というのが筆者の考えです。
src/ui/components
にはどういったコンポーネントを置くべきか
前述のとおり src/ui/components
内に配置するのは「複数のコンポーネントから参照されることを想定した共通コンポーネント」となりますが、これらはアプリケーション全体で繰り返し使われるためにアプリケーション全体のデザインを決定付けるものでもあります。では色や大きさといった要素はコンポーネントを使う側で指定できるべきでしょうか?
筆者の答えは「アプリケーション固有の抽象的な値(静的型付け言語であれば型エイリアスやEnumを宣言しその型や値)で指定できるべき」です。
アプリケーション固有の抽象的な値とはそのアプリケーションで定められた値のことを指します。例えば色で言えばプライマリやセカンダリ、サイズで言えばラージやスモールといった具合です。SwiftならEnum、TypeScriptならリテラル型を外部に公開するインターフェースとして定義し内部でそれを特定の値として変換し適用します。対義語としては絶対的な値となりますがなぜこれがダメなのかと言えば自由過ぎる値を指定できてしまうことでアプリケーションのトンマナから外れた使われ方をしてしまうためとなります。
他のアプローチとしては色やサイズといった要素の全パターンを固有のコンポーネントにしてしまうという方法もありますが、そうすると指定可能な要素のパターンが多くないうちは全て固有のコンポーネントとして書き出しても問題はないものの要素が増えるに連れパターンは大きく増えるため使う側から指定できた方が良いと考えています(例えば色とサイズで3x2=6だったものがtransitionという要素が増えることで3x2x2=12となりコンポーネント数は倍になります)。
もしUIコンポーネントライブラリを使っている場合はプロジェクト内で使うコンポーネントを全てここでラップし定義します。
なぜライブラリが提供するUIコンポーネントをわざわざラップするのか
理由は2つあります。
- ライブラリへの依存度合いの低減
- インターフェースの整理
ライブラリへの依存度合いの低減
ライブラリへの依存度合いを低減する理由はなんでしょうか。それはライブラリを変更するときの影響を最小限にするためにほかなりません。
フロントエンド全般に言えることかもしれませんがUIは特に流行り廃りの変遷のサイクルが比較的早い傾向にあると考えています。以前まで使っていたライブラリが気が付けば時代遅れとなっていたという経験を感じたことがある方も多いのではないでしょうか。
もし直接UIライブラリが提供するコンポーネントを様々なコンポーネントで直接参照してしまっている場合はライブラリを変更しようとしたときに参照している全てのコンポーネントで修正が必要となるでしょう。一方で1対1の対応付けがされた自前のコンポーネントでラップしていた場合はラップしているコンポーネントそのものを修正するだけで済みます。
重要なこととしてこれはライブラリの変更だけでなくライブラリのアップデートでも同じことが言えるということです。トレンドの変遷が速いフロントエンドでは修正の頻度も高く後方互換性のないアップデートも行われることがあります。こうした場面で自前のコンポーネントでラップしてあることでライブラリの更新も含めた変更がしやすくなります。
インターフェースの整理
UIコンポーネントライブラリは広く使われることを目的の一つとしているため様々なユースケースを想定し非常に多くのオプションを提供しています。が、使う側では全てのオプションを使うことはそう多くはないのが現実です。これは特定の用途に特化したアプリケーションにおいてはある種当然と言えます。
余計なオプションは予期しない使い方を誘発するほか、余計なオプションがあることで認知的な負荷が大きくなります。そうした観点からもインターフェースを制限するという目的でラップする必要があります。
公開するインターフェースを制限することについて疑問がある場合はなぜGetterやSetterが設計上好まれないのかについてググってみると良いでしょう。同じ理由でアクセス可能なインターフェースは必要最小限にするべきという考えです。
src/ui
内にはUIコンポーネントしか置けないのか
src/ui
内にはUIコンポーネント以外にもそのページやコンポーネント固有のものは全てそこに置くべきだと考えています。例えばCSS Modulesを採用していてコンポーネントと対になるcssファイルがある場合やGraphQLを使用しFragment Colocationを採用していてUIコンポーネントと対になるfragmentファイルがある場合、SwiftUIでアーキテクチャにMVVMを採用しページと対になるViewModelがある場合などが該当します。
これはそれらがそのページ固有のものだと言えるためです。そのディレクトリに閉じていることでそこでの変更が他に影響を与えないことを証明してくれます。逆に言えば他に影響するものを置いてはいけません。他に影響しないように設計を疎にするか、それができないようなものであればそれはそこに置くべきではないということです。
なぜファイル構成にこだわるのか
特に開発者が一人しかいないような初期の段階においてファイル構成にこだわる必要はあるのでしょうか?
もしそれが未来永劫自分一人で開発するものであれば特にこだわる必要はないと思います。が、そうでなければこだわるべきだと考えています。
人間には短期記憶と長期記憶というものがあり、考え事をするときには短期記憶を使うと言われています。しかし短期記憶は容量が限られており一度に多くのことを処理できないため複雑な作業をするのは多くのエネルギーを必要とします。
一方でソフトウェアは非常に複雑で考えることも多いです。特にプロジェクトにアサインされたばかりだとそのソフトウェアが解決するドメインの知識やチームに関することだったり日常的に必要なルーティンワークなど覚えなければならないことが多く、さらにソフトウェアは大抵の場合多かれ少なかれ負債があり、それがそのソフトウェア特有の複雑さを生んでいます。これらの理由からすぐに既存メンバーと同程度のパフォーマンスを出すのは難しいでしょう。
思ったようにパフォーマンスが出せない環境というのは必ずしもDXが高いとは言えません。また、暗黙知だらけの古参メンバーじゃないと分からないような複雑怪奇なプロジェクトに参画したいという人はあまり多くはないのではないでしょうか。なぜならそれはそのプロジェクトに限定された知識であり、他のプロジェクトでは必ずしも通用するものではないからです。
このような私見から認知的な負荷を如何に下げるかは重要な尺度だと感じています。
一方で「分かりやすさ」というのは主観的な評価をしてしまいがちなので気をつけなければなりません。
例えばあるプロジェクトで大幅なリファクタを行われ「書き直したことで分かりやすくなった」と聞くと筆者は懐疑的になります。なぜならそれはある種当然で書き直すことでその処理に関する理解が大幅に上がるためです。書き直した本人にとっては間違っていないのですが第三者にとって分かりやすくなったかどうかというのは別の指標が必要です。
ではどうすれば「認知的な負荷」を「客観的」に評価できるのでしょうか?
ここでソフトウェアの設計原則が登場します。
ソフトウェアの設計原則は様々なコンテキストにおいてソフトウェアが直面した問題に対する一種の答えと言っても過言ではありません。なぜなら設計原則は広く公開されており、それそのものがある種のオープンソースとして厳しいレビューに晒されなお生き残ってきた先人の知恵だからです。
設計原則はあくまで原則のためそれが直接の答えとはなり得ないですが議論をする上での有効な指標となりますし、チームや組織を超えた共有知として機能するためそのプロジェクトへ関与した経験や年数に関係なく客観的な評価を可能にしてくれます。チーム開発というのはどうしても意見が対立してしまうことがあります。が、そうした場面で客観性を持った議論ができるというだけでも設計原則を学ぶ価値はあると思っています。
最初から設計にこだわる必要はあるのか
今成功しているサービスやアプリケーションで初期フェーズにおいてとにかく速度最優先で構築したから成功したということは実際にあるのかもしれません。問題はそれと今が同じ状況ではないということです。今は以前と比べソフトウェアを価値の源泉とするビジネスが増え競争が激化し潤沢な資金が投下されています。これは初期の成功を手にするためにも以前とは比較にならない規模が必要になったことを意味します。一方でソフトウェア開発の技術は細分化されそれぞれの領域でより多くの知識を必要とするようになりました。これらの背景からプロジェクトを始め初期の成功を得るために必要となるリソースは以前と比べ大きくなったのではないかと思います。もしそれが真なのだとすれば初期フェーズから修正が容易となるような設計を重視することは非常に重要だと認識しています。それになにより設計に掛けた時間とリリースサイクルがどのような相関関係を描くのかは開発者のスキルによるところが大きいと思っています。設計に長けたメンバーであれば少ない時間でより良い設計を構築でき実装フェーズにおいても高いメンテナビリティを維持しバグが少なく高速なリリースサイクルを実現することは可能でしょう。逆に設計に不慣れなメンバーしかいなければ修正のコストは高くバグが発生しやすくなによりDXが良くないため採用や定着に苦戦するのではないでしょうか。そうした観点から初期の設計というのは非常に重要であり、理想的にはその設計思想がメンバー全員の理解を得られるものであるべきだと考えています。もしそれが難しいのであれば組織的な階層の上下にこだわらずお互いの意見を尊重しより良い設計を議論できるような環境が望ましいのではないでしょうか。個人的にはあまり設計に自身がある方ではないのでそうした学習姿勢を重視するチームに惹かれます。
話が脱線してしまいましたがどういった構成が良いか今の自分なりの答えを考えてみました。本当は安定度・抽象度等価の原則を具体的にどうやって落とし込むかとかHOW TO部分も書こうと思ったのですが力尽きたのでこの記事ではここまでにします。