ReactやVueなどコンポーネントベースで作っていくViewのライブラリが普及したことで、コンポーネント指向での開発が一般化してきた昨今のフロントエンドですが、このコンポーネントの設計に悩まれる方も多いのではないでしょうか。
コンポーネントをどの粒度、どんな状態で分割するのが良いのか、などなど、特にチームで開発する時に認識が揃っていないとカオスになりがちな部分であると思うので、自分なりの設計をする際の指針を言語化しようというのが本記事の目的です。同じように悩まれている方にも何らか示唆を提供することができたら嬉しいです。
想定読者
- 「コンポーネント設計?なにそれ?おいしいの?」という方
- 初めてコンポーネント設計でアプリ作ってみたけど、本当にこれでいいのか自信の無い方
はじめに: "コンポーネント"とは
まず最初に"コンポーネント"という言葉についてですが、ここでは「GUIのパーツをモジュール化したもの」と定義します。
GUIは基本的にはPC・スマホなどのデバイス上の画面に存在し、その中にアプリケーションで達成したいゴールを満たすための機能を散りばめていくこととなります。アプリケーションは複数の画面で構成されることが多く、同じパーツが繰り返し使われることが頻発します。例えばヘッダーやフッターなんかはほぼ全部の画面に登場するでしょうし、ボタンやテキストフィールドなども同じスタイルを持ったものが複数の画面で繰り返し使われるでしょう。
この様々な粒度のパーツを"コンポーネント"と呼ばれる単位に区切って再利用性を高めるのと同時に、デザインと技術の共同作業をしやすくするのがコンポーネント化の目的です。
そして、このコンポーネントを構成する要素は以下の4つがあります。
- 情報構造
- スタイル
- 状態
- インタラクション・機能
まず「情報構造」、小難しく表現しましたがこれはHTMLが責務を持つ部分です。そして「スタイル」はCSSが責務を持ちます。これらの観点は初めにコンポーネント設計をする際にイメージしやすい部分で、単純にAtomic Designなどの思想に則ってUIパーツを分解して行けば、ここだけならあんまり難しいことはないはずです。
ただ、最近のフロントエンドはそんなことは許してくれず、なぜなら、コンポーネント自身が状態やその変化のトリガーとなるインタラクションについて責務を持つからです。コンポーネントは外部とのデータやコールバックの受け渡しも行う必要があるため、実装効率上の観点での制約が生まれます。コンポーネント化する目的はあくまでエンジニアの生産性向上のため、概念としての綺麗さと実装のしやすさがコンフリクトすることは往々にして起こります。
これらのような複数の役割をカプセル化したものであることがコンポーネントの設計を難しくしている要因なのではないかと思っています。これ以降ではそれぞれの観点に対して「私はこんな感じで軸を置いている」というのをご紹介いたします。
情報構造とスタイル
情報構造とスタイルは密接した概念であるためくくります。
原則としての Atomic Design
まずは概念としてのUIパーツのレイヤー分けの仕方の認識を揃えることが基本となります。
ここに関してはAtomic Designがデファクトスタンダードになっている感がありますので、まずはこれを抑えることをオススメします。
Atomic Designはざっくり説明すると以下の5要素にコンポーネントを分割していくデザイン手法です。
- Atom - UIの最小単位。それ以上機能的に分割できないもの。ボタンとかテキストとか。
- Molecule - Atomを組み合わせて作られる要素。検索フォームとか。
- Organisms - MoleculesやAtomを組み合わせて作られる要素。ヘッダーとかがイメージしやすい。Moleculeとの違いは単一の機能でなく複数の役割を持つこと
- Template - Organismsを組み合わせたもの。いわゆるワイヤーフレーム
- Pages - 実際の文言などのデータがTemplateに注ぎ込まれたもの。
詳しい解説は他にいい記事がたくさんあるので、リンクを貼っておきます。
単一責任の原則
原則としてコンポーネントは一つのことに責任を持つべきです。
これはMolecule単位のコンポーネントをデザインする時に重要な考え方で、一つのコンポーネントが複数の機能・役割を持ってしまっているのはよくない設計の兆候です。
なぜ良くないかと言うと再利用性が低くなるからです。
例えばこんな感じの「ユーザ名の変更」と「外部サービスの連携」の二つの役割を背負わせたものを一つのコンポーネントとして作ってしまったとします。
そして、これを使っているページとは別のページで「"ユーザ名"と"外部サービス連携"を逆の順序にしたい」だったり「"ユーザ名"と"外部サービス連携"の間に"アイコン変更ボタン"を設置したい」などの要求が発生するとします。そうなると、この哀れなコンポーネント内で面倒な条件分岐を作ることになり、また使う側の親コンポーネントもこのコンポーネントのスタイルについて詳しく知らなければならず密結合になってしまいます。これが最初から「ユーザ名の変更」と「外部サービスの連携」でそれぞれ別のコンポーネントになっていれば、単純に並べ替えたり、新しいコンポーネントを追加するだけで済みます。
若干極端な例にはなってしまいましたが、言わんとしていることは伝わったと願います。
ここまで読んで「じゃあOrganisms以上はどう捉えたらいいんだ、複数の役割を持っているじゃないか」と感じる方もいらっしゃる方もいるかもしれません。それに関して私は、Organisms以上の単位のコンポーネントは「機能としての役割」は持たず「レイアウトとしての役割」を持つ、と捉えています。
どういうことかと言うと、例えばQiitaのヘッダー、コミュニティ関連のドロップダウンがあったりお知らせがあったりマイページへのボタンがあったり機能がたくさんありますね。
ただこれはヘッダーというコンポーネントそれ自体が機能を持っているのではなく、あくまで機能を実現しているのはヘッダー内に内包された個別のMolecule or Atomたちです。
ヘッダーはあくまで「これらのMolecule or Atomたちをどうレイアウトするか」にだけ責任を持ちます。
要するにコンポーネントの責務の種類は単純な機能だけでなくレイアウトも含む、と考えると線引きがつけやすくなると思います。
スタイルクローズドの原則
上記の話ですが、スタイルについても同じようなことが言えます。
まずスタイルと呼ぶものの中にも私は以下の2種類があると思っています。
- 見た目のスタイル
- レイアウトのスタイル
"見た目のスタイル" とは対象のコンポーネントの見た目を定義するものです。
対して、"レイアウトのスタイル" についてですが、これは単位がMolecule以上のコンポーネントに関して、そのコンポーネントが内部のコンポーネントたちをどうレイアウトするかを定義するスタイルのことです。
ここで唱えたいのが、この親子関係において
- 「子コンポーネントは親コンポーネントの"レイアウトのスタイル"を知ってはならない」
- 「親コンポーネントは子コンポーネントの"見た目のスタイル"を知ってはならない」
ということです。ちなみにどちらも理由は「再利用性が低くなるから」です。(というか設計のアンチパターンの理由は大体これ)
例えば、こんな感じのアイコンが複数並べたコンポーネントが存在するとします。
アイコンの間にはmargin
が等間隔でありますね。
このmargin
をアイコンコンポーネント内で定義していたとしましょう。
.icon {
...
margin-right: 15px;
}
さて、他のページでこのアイコンを使いたいとなったとします。そして今回はアイコンの右に対してmargin
が100px
必要なレイアウトだとします。この時どこにどうmargin
のスタイルを記述して実現すると良いでしょう?
解決方法は色々ありますが、どんな選択肢であろうとこのアイコンコンポーネント内のmargin
を気にしなくてはならないと思います。そしてそんな解決策が積み重なっていくとどんどん汚いCSSが増えていくことは容易に想像できると思います。
このため子のコンポーネントは親にどんなレイアウトで使われるのかについてのスタイルは記述するべきではなく、また逆もまた然りで親も子の見た目のスタイルについて(あまり)立ち入ってはなりません。スタイルもそのコンポーネントの役割に応じて閉じたものにしましょう。
可変にするスタイルを見極める
若干先ほど述べたことに反するのですが、時には親から子に対してある程度スタイルを指定できるようにした方が利用しやすいいいコンポーネントになります。
例えばなんですがアプリケーション内に以下の2種類のボタンがあるとします。
違うのは幅だけです。それを理由に二つの独立したコンポーネントを作ってしまうのは少々面倒ですよね。なので親が使う際に幅を指定できるようにしてあげると良いでしょう。
これは分かりやすい例ですが、その他にも背景色だったりテキストの色だったり可変にしたいスタイルが色々出てくると思うので、都度判断して行きましょう。
基本的な考え方としては「原則スタイルはコンポーネントに閉じたものにする。可変にする必要・メリットが出てきた時に可変にする」というスタンスで良いと思います。「このスタイル可変にしとくと後々便利そう」というフィーリングで判断するのはオススメできません。大抵必要にならず、また後になってからでも可変にする修正コストは大して高くありません。YAGNIの精神で行きましょう。
状態とインタラクション
続きまして、状態とインタラクション。こちらも括ります。
初めに「状態」という言葉、割と定義がふわふわしがちだと思うので最初に述べておこうと思います。
ツッコミどころはあるとは思いますが、個人的には以下の定義で捉えています。
「アプリケーションの動作時に変動しうる値」
広義の意味では上記の通りですが、その中でも何種類かの状態に分かれます。(他にも色々あると思います)
- データ
- UI
- セッション
- 通信
- Location(Routingとかの話)
「どのコンポーネントがどの状態を扱うのか」という問いは重要なテーマとなります。そして、この状態は「ユーザとのインタラクション」がトリガーとなって変化します。インタラクションは基本的に機能を持つMolecule ないし Atom単位のコンポーネントで発生するため、そのインタラクションがどんな状態の変化に繋がり、その変化の影響範囲はどうなっているのか、このデータの流れを見極めることが状態管理の難しさに繋がっていると思います。
To flux or not to flux
少し"コンポーネント設計"という命題からは脇道それるかもしれないんですが、避けては通れないテーマだと思うので言及しておきます。
"状態管理"に取り組む際にまず考えることが「fluxアーキテクチャを採用するかどうか」だと思います。fluxはざっくり言うとデータの流れを一方向に限定して、状態管理をしやすくするためのアーキテクチャです。fluxを使う場合コンポーネントの外でStore層を設けて状態を管理するので、採用有無はコンポーネントの設計に影響を与えます。
詳しい解説は本記事の趣旨から逸れるので、他の良い記事をよければご覧ください
私はfluxの本質は「データの流れを見えやすくすることによってバグの少ない・デバッグのしやすい状態管理を実現すること」にあると捉えています。逆に言うとあまりコンポーネントの階層も扱う状態も多くない小中規模のアプリケーションであれば必要はないでしょう。登場人物が無駄に増えて辛みが増します。
Redux作者の Dan Abramov も有名な格言を残しています。
Flux libraries are like glasses: you’ll know when you need them.
— Dan Abramov (@dan_abramov) 2016年2月29日
fluxライブラリはメガネのようなものである。それが必要になった時に知るのだ。
個人的にはとりあえずReduxやVuexを入れちゃう風潮がよろしくないと思っていて、ReactやVueはそれ単体でもかなり強力なツールなので、本当に必要なのかはしっかり考えてから導入しましょう。
"Containerコンポーネント" と "Presentational"コンポーネント
Reduxに触れたことのある方なら割と当たり前の概念になりますが、基本的にコンポーネントは"Containerコンポーネント"と"Presentationalコンポーネント"に分けて考えましょう。また、「下位コンポーネントはなるべく状態を持たないようにする」という考え方は何らかのfluxライブラリなどを使っていなくとも、コンポーネント設計においては基本的な考えになると思います。
まず用語の説明ですが、"Presentationalコンポーネント" は名前の通り見た目だけに責務を持ち、自身では状態を持ちません。
▼ "Presentationalコンポーネント"
const Link = ({
active,
children,
onClick
}) => {
if(active){
return <span>{children}</span>;
}
return (
<a href="#"
onClick={e => {
e.preventDefault();
onClick();
}}
>
{children}
</a>
)
}
基本はコンポーネントを扱う親のコンポーネントからデータなどを流し込んでもらいます。
"Containerコンポーネント"は"Presentationalコンポーネント"にデータを注ぎ込んだり、イベント発生時のコールバック関数を渡したりするのが役目です。
const LinkContainer = (props) => <Link {...props.link} />
基本的に状態は可能な限り散らばせずに管理した方が幸せなので、これらの区切りを意識して設計すると良いでしょう。
コンポーネントに閉じた状態
上記で"可能な限り"と言った理由としてグローバルなStore層などにわざわざ渡すと逆に煩雑になるケースがあるからです。
Reduxとか初めて使った時に、みんな疑問に感じるんじゃないかと勝手に思っているのが「テキストフィールドのinputのstateをグローバルStoreに入れるのか?」という状況です。
const hoge = ({
text,
handleChange
}) => {
return (
<form>
<input type="text" onChange={e => handleChange(e.target.value)} value={text}/>
</form>
);
}
こんななんの変哲も無い input
要素があるとして、入力された値はhandleChange
の引数として渡され、その先には更に action
やreducer
が……流石にめんどくさくないですか?ちょっとツールに使われている感がします。
これに対して私は「その状態が一つのコンポーネントに閉じているかどうか」という基準で振り分けています。例えば上記の例、テキストフィールドが編集されている時の値って他のコンポーネントに影響を与えないですよね?また、一度入力した値を送信してしまえばリセットされます。これをわざわざそのコンポーネント外に展開して管理するメリットが見当たりません。
このように「状態がコンポーネントに閉じている」場合はむしろそのコンポーネントで管理した方が楽になるでしょう。状態は必ずしも一箇所に全て集めなければならない訳ではなく、状況に応じてローカルの状態管理と使い分けていきましょう。
共通の振る舞いを抽出する・その実装のパターンを知る
いろんなコンポーネントで使われる共通の振る舞いは、その振る舞い自体を抽出しましょう。例えば「選択されているものだけスタイルを変える」とか「ログインしている人だけ見れる」などなどありがちな振る舞いは抽出してしまうのが良いです。
これをできるようにしておくことで一つのコンポーネントから様々なコンポーネントが柔軟に生み出せるようになります。
例えば、先の例で言うと、ただのボタンコンポーネントから「選択されているものだけスタイルを変えるボタンコンポーネント」と「ログインしている人だけ見れるボタンコンポーネント」が簡単に生成できるようになります。もし振る舞いを抽出しない場合はそれぞれ別個のコンポーネントとして定義する必要があり、非常に煩雑です。
これを実装するパターンはいくつかあるんですが、メジャーなものとしてはHigher Order Component
やRender Prop
があります。詳細については他の記事を紹介いたします。中身はReactベースですが、考え方は他のコンポーネントベースのライブラリでも適用できると思います。
(自分の記事の宣伝になりますがVue使いの人はこちら を是非ご覧ください)
その他の話
この項では実際にコンポーネント指向で書いていく上で、あるあるな課題を挙げて行きます。正直粒度も中身も若干ゆるふわですがご了承ください。
3度目の法則
タイトルは 「リファクタリング(Martin Fowlerの本)」 からパクってきました。概念上はAtomに分解できなくはないけど、1箇所でしか使われないUIパーツ、あると思います。これはデザインシステムをどう作って行きたいかの方針にもよるんですが、原則コンポーネント化の目的は再利用性を高めることです。
なので、そもそも再利用の必要性がないものに関してはコンポーネントとして切り出さないほうが結果として無駄なファイルが減らせて良いと思います。
ここで提案したいのが 3度目の法則
。要は3回出現したら単一のコンポーネントとして切り出す、というルールを設けると意思決定がスムーズかと思います。
好みによっては 3回でなく2回でもいいかもしれません。
"prop drilling" 問題
コンポーネントの階層が深くなってくると表出してくる問題、それが"prop drilling問題"です。
末端のコンポーネントに3階層4階層と上のコンポーネントのデータを受け渡したいとなった場合に、ひたすら中間のコンポーネントでも余計にデータの受け渡しのインターフェイスを作らなければならないという手間が発生します。
この問題を解決する際によくReduxなりMobXなりが使われるんですが、もしこれだけが解きたい課題なのであればあまりオススメできません。そもそもそれらのツールが解こうとしている課題は別のことであるというのと、それらのツールを使う手間と"props drilling問題"に向き合う手間はそんなに変わらないと思うからです。
ではどうすればいいかという話なんですが、最近だとReactではv16.3から新しくなったContext APIを使えば解決できるでしょう。Vueとかだと…どうすると綺麗なんですかね…?誰か知っていたら教えてください。
あとがき
作るアプリケーションにもよりますが、コンポーネント設計は「これだ!」という正解が見えづらいと思います。なぜなら、ユーザとのインタラクションに責務を持つ部分であるがゆえに概念・論理の綺麗さだけではどうにかできない状況が存在するからです。このインタラクションの品質を上げるために、時には実装上の複雑性を受け入れることも必要でしょう。
ただ、闇雲に複雑にするのではなく、一定のルールを設けることによって負債化のリスクを減らせることはできるはずです。この記事で書いてあることを参考にしとけば大丈夫とはとても言えないですが、多少参考になれば幸いです。
それでは、Happy Coding!