はじめに
アプリケーションエンジニアの Edy です。
今回は、Vue.js での開発する上で、メンテナブルな設計するために、「Container/Presentational Component パターン」を部分的に適用できないか検討した話となります。 「完全に理解」して、少しでも設計力に活かしていきたい所存です。部分的に自身の意見も取り入れつつ考えていきます。
- はじめに
- きっかけ
- Container / Presentational パターンとは?
- Container Component
- Presentational Component
- 切り分け方法
- 実際の画面 UI にデザインパターンを適用してみる
- 設計する上で意識したこと
- まとめ - 構成を変えてどこにメリットを実際に感じられたか
きっかけ
検討し始めた理由ですが、案件開発の中で以下のようなことを思ったのがきっかけとなります。
コンポーネント実装のコストパフォーマンスの悪さ
複数階層のコンポーネントで構築された UI において、props で渡された値の意図を理解するために、途中階層のいくつかの「生成・加工・変換」処理を確認する必要がある。 このような UI に対して、利用する値の取得処理の改修や、描画処理を変更すると、以下の問題によって開発スピードにブレーキがかかってしまいました。
【改修箇所の把握が困難】
処理する関数 (メソッド) がどの階層のコンポーネントで書かれているかすぐに気づけない。 ソースコードエディタや統合開発環境の検索やジャンプ機能を活用しても漏れが出てしまうことがある。
【改修範囲が広がる】
複数コンポーネントにまたがってのコード改修となるため、改修箇所が増え、本来は不要な検証作業が発生する。 併せて、単体テストの修正も重荷となっていく。
コンポーネントを読み解くことによる疲労蓄積
フロントエンド界隈でよく話に上がる、Fat Component や Smart UI 思想で実装されたコンポーネントなどが既存プロダクトにはいくつか存在します。 これらのコンポーネントの処理を改修しようとした際には、当然のごとく大量の変数と処理が存在し、それらの依存関係を理解しないと安心して手を出せない状態にあります。 改修のためにコード読んだ結果、そっとソースファイルを閉じてしまう経験が何度かありました。
【重複した処理がコンポーネントに散見される】
複数人がそれぞれ異なるコンポーネントを担当し、また対応する時期も異なることがあった結果、親子関係にあるコンポーネントにおいて、「同一処理が多数存在する」みたいな実装がいくつかみられます。 いわゆる DRY に書かれていないといった感じです。
Container / Presentational パターンとは?
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
コンポーネントを、以下の 2 つに分類することで、コンポーネントの再利用性の向上と推論のしやすさを提供してくれるデザインパターンの 1 つです。 メンテナブルな設計を行えるようになります。
- ビジネスロジックにのみ責務を置く「Container」
- 描画にのみ責務を置く「Presentational」
このデザインパターンは、 React のコミュニティー内において Dan Abramov 氏が提案したものです。 コンポーネント指向で設計する Vue.js においても、横展開できるデザインパターンとなっています。
Container Component
Container は、子孫要素に必要なデータを渡す役割を担います。 ざっくりと以下の役割を担います。
- アプリケーションのビジネスロジックに関心を持ち、データの生成・加工・変換します。
- 子コンポーネントに対して、データと動作の命令を提供します。
- 子コンポーネントとして Container, Presentational の両方のコンポーネントを複数保持できます。
- データソースとして機能する傾向があるため、多くの場合ステートフルとなります。
- CSS スタイルは一切保持しません。
【メリット】
「ビジネスロジック」に関してのみ責務を負うことにより、以下の恩恵を受けることができ、メンテナブルな設計を享受できます。
- 改修時の変更範囲を限定できます。
- 単体テストの作成容易性を向上できます。
- 子孫コンポーネントを考慮したビジネスロジックの収束が可能となり見通しが良くなります。
Presentational Component
Presentational は、UI 描画の以下の役割を担います。
- props を介してのみ、データの通信 ( input ) を行います。
- 物事がどのように見えるか ( props データをどのように表示するか) に関心を持ちます。
- 多くの場合でステートレスとなります。
- UI の状態を保持するための、データ保持は許可されます。
- CSS スタイルを保持します。
- 子コンポーネントとして Container, Presentational の両方の保持が可能となります。
【メリット】
「描画」に関してのみ責務を負うことにより、以下の恩恵を受けることができ、メンテナブルな設計を享受できます。
- 画面描画にのみ意識を向けることができるため、イベントリスナーのテストなど、観点を絞ることができます。
- ロジックに支配されることがないため、再利用性が高まります。
切り分け方法
【分類パターン】
1 つのコンポーネントを Container か Presentational のどちらかに分類する方法です。 ビジネスロジックと描画を持つようなコンポーネントが存在する場合は、責務のウェイトを考慮して切り離しを行います。 コンポーネント同士の疎結合性を高め、単体テストの書きやすさ、Presentational の再利用性が増加します。
// Before ├── packageA ├── ComponentA.vue ├── ComponentB.vue // After ├── packageA ├── ComponentA+ComponentBから切り離したロジックを持つContainer.vue ├── ComponentAから切り離した描画を持つPresentational.vue ├── ComponentB(Presentational).vue
【分割パターン】
1 つのコンポーネントを Container と Presentational との 2 組 1 セットに分割する方法です。 1 コンポーネントが 2 コンポーネントに倍増するため、ファイル数が増加するのと、Presentational の再利用性は低下します。
// Before ├── packageA ├── ComponentA.vue ├── ComponentB.vue // After ├── packageA ├── A ├── Container.vue ├── Presentational.vue ├── B ├── Container.vue ├── Presentational.vue
実際の画面 UI にデザインパターンを適用してみる
以下は、マイクロアドで提供している Universe Ads の画面の一部ですが、こちらを用いてデザインパターンを適用したときのパッケージ構成を考えます。
実装として、以下の Vue ファイルを用意しています。
├── summary ├── Panel.vue ├── Panels.vue ├── Button.vue ├── Modal.vue └── chart ├── Dropdown.vue ├── Chart.vue ├── LineChart.vue
ワイヤーフレーム化して、対象のコンポーネントを配置すると以下のようになります。
ここから各コンポーネントが持つ内容の洗い出しを行い、デザインパターン適用時の各コンポーネントの機能の移譲を検討していきます。
Panel | Panels | Modal | Dropdown | LineChart | Chart | |
---|---|---|---|---|---|---|
Business logic | Yes | Yes | Yes | No | Yes | No |
View logic | Yes | Yes | Yes | Yes | Yes | Yes |
Style | Yes | Yes | Yes | Yes | No | Yes |
Statefull | No | Yes | No | Yes | Yes | Yes |
Input only from props | No | Yes | Yes | No | Yes | No |
今回挙げた全てのコンポーネントは、「描画」することに主軸を置いているため、Presentational コンポーネントに任命します。 切り分け方は「分類」パターンを利用します。Presentational コンポーネントのルールに沿うように、以下で対応します。
- ビジネスロジックは、親側で処理を保持するように変更し、props として値を受け取る。
- Vuex 関連は、ビジネスロジックと同様、 props で受け取るように変更。
- data は、親側で定義し、props で受け取るように変更。
さらに、Container コンポーネントとして以下 3 つを新規に用意します。 ビジネスロジックや Vuex の呼び出し、data の保持などは全てこのコンポーネントで行うようにします。
- Panel を管轄する「PanelsContainer」
- Chart を管轄する「ChartContainer」
- 2 つの Container を管轄する「SummaryContainer」
これにより、各コンポーネントの機能は以下に変わります。
Panel | Panels | Modal | Dropdown | LineChart | Chart | |
---|---|---|---|---|---|---|
Business logic | No | No | No | No | No | No |
View logic | Yes | Yes | Yes | Yes | Yes | Yes |
Style | Yes | Yes | Yes | Yes | No | Yes |
Statefull | No | No | No | No | No | No |
Input only from props | Yes | Yes | Yes | Yes | Yes | Yes |
. └── summary ├── SummaryContainer.vue ├── MonthlyPanel.vue // UI 左側の Panel は上部の Panel と機能や役割が異なるので新設 ├── Button.vue ├── Modal.vue ├── chart ├── ChartContainer.vue ├── Chart.vue ├── Dropdown.vue ├── LineChart.vue └── panels ├── Panel.vue ├── PanelType.ts ├── Panels.ts ├── Panels.vue └── PanelsContainer.vue
設計する上で意識したこと
【Container】
- Container コンポーネントで保持するビジネスロジックは、同階層以上の他のコンポーネントが持つべきビジネスロジックは保持しないように意識します。
- API/Vuex の呼び出しについては、ものによっては同階層以上での利用を可能にする改修が想定されるため、コンテキストの最上位階層でのみ呼び出すようにします。Vue Router を利用するときなどは、そこで呼び出すコンポーネントなどが最上位階層となるイメージです。
- Vue のコンポーネントは、木構造的に実装設計されるため、Leaf に分類されるコンポーネントは Container になり得ないことを意識します。
【Presentational】
以下 2 点について、ビジネスロジックが混入していないか意識します。
- v-if などで利用する値の算出をコンポーネント内で行う場合
- props の値を掛け合わせた値の算出や利用
まとめ - 構成を変えてどこにメリットを実際に感じられたか
メリット
- 仕様変更が頻繁にある開発においても、早い段階から振れが少ない Presentational コンポーネントの実装を進められる。
- 「ロジック」と「描画」のそれぞれを改修する際に、明示的にコンポーネントが分かれていることで、対応コンポーネントや、影響範囲のキャッチアップに役立った。
- 実装レビュー時に、処理が「ロジック」と「描画」、どちらの責務なのかを意識するようになった。
- 責務の分離が行われていることで vue ファイルへの単体テストの導入が容易になった。
- 「1 パッケージに 1 Container」とすることで、それぞれの Container が管轄する Presentational コンポーネントを把握しやすくなった。
デメリット
- 小規模コンポーネントに対してデザインパターンを適用すると、かえって保守性、可読性と開発工数を比較してコスパが悪い。
- リアクティブではない値の生成・加工処理などは、あえて Container は用意せずに .ts ファイルへのロジック切り出しなどでも対応できそう。
切り分け時期 (デザインパターン適用タイミング)
アプリケーション構築の初期段階では、Container/Presentational の責務を 1 つのコンポーネントに混ぜた形での設計がお勧めされています。
(つまり、特にデザインパターンを意識しすぎない方が良い。)
コンポーネントが肥大化し始めたときにリファクタリングを行い、コンポーネントの役割分担する方が良いようです。 というのも、何をビジネスロジックとして捉えるのか、値がどのコンテキストまで参照されるのかなど、設計的な先を見据えた検討事項が実装コストとして乗っかるためです。
今回は、既存構成を用いてのお試しだったのでロジックの切り分けなどが容易でしたが、新規に実装を進めていく中でのことを考えると、やはり時間はかかるだろうと思いました。 既存 Container の親階層として新たな Container が登場し、一部機能を移譲する必要性が出た時、低コストで機能の移譲は可能そうですが、それに伴う追加検証などもネックにはなりそうです。
一方で、既存コードのリファクタリングを定期的に行える体制でない場合は、初期実装コストをかけてでもチーム内で議論をした上で本デザインパターンを取り入れた方が良いと思いました。 「後でやろう」の積み重ねでいつの間にか負債が積もるなんてことはよくある話です。
まとめとしてですが、以下を考慮した上で、いつのタイミングで/どのコンポーネントにまで本デザインパターンを取り入れるべきかチーム検討する必要がありそうだと思いました。
- 開発するプロダクト規模感
- 開発体制
- メンテナンス頻度
- コンポーネントの規模 (Atomic デザイン的な粒度)
以上となります。