はじめに

VueをつかってWebアプリケーションを実装するとき、Componentをどう切るかって誰でも一度は悩みますよね(悩みますよね?)。とりあえず思いつくままに切ってみたり、繰り返し使いそうなもので切ってみたり、CSSのスコープで切ってみたり…。いろいろな切り口があると思います。

この「いろいろな切り口」でコンポーネントを切ることができる点が、コンポーネント設計を難しくしている所以だと考えています。

そこで今回は、どのような切り口・観点でコンポーネントを切ればよいのかそのときに気をつけるべきことは何か、といったComponentの設計方法についてまとめてみます。
すべての実用ケースを想定できているわけではないと思いますが、大小いくつかのWebアプリを開発する際に利用してみて今のところいい感じに運用できている方法です(というか自然と収束して出来上がった考え方という感じです)。

基本の考え方は「責務の分離」

コンポーネントを切るときに心がけていることは責務を分けるためにコンポーネントを切るということです。責務を分けるということは、「変更理由」「影響範囲」「役割」を明確にするということに繋がります。

ではVueのコンポーネントには具体的にどんな責務を持ち得るのか。リストアップしてみます。

  • Container」の責務
  • 「Component」の責務
    • MVVMにおける「ViewModel」の責務
    • Templateが肥大化したときに分割されつくられる「View」の責務
    • 汎用UIパーツ」としての責務
    • 動的コンポーネント(Factory)」としての責務
    • Higher-Order Component」の責務

現時点で私が必要だと感じているのは、これら、Containerの責務と5つのComponentの責務です。
少し用語の補足をすると、1つの画面を表現するコンポーネントのことをこの記事ではReactにならって「Container」と呼びます(Vueの世界だと「View」や「Page」と呼ばれたりしていますが、曖昧なのでReactにならいます)。例えばブログを作る場合、「TopページContainer」「記事ページContainer」「カテゴリページContainer」などが考えられます。そしてContainer内に配置される(Containerの子コンポーネントとなる)コンポーネントのことを「Component」と表記します。

では、これらの責務がどんなものなのか考えていきます。

Container の責務

Containerの責務とはざっくり言うと①画面の構成を表現すること②他画面との関係性を表現すること、の2つです。掘り下げると以下のような機能・役割が考えられます。

1. 画面の構成を表現すること

  • 画面内に配置するComponentを定義する
    (Templateを見れば画面内にどんなComponentが並ぶのかが分かる)
  • 画面全体で必要なModelやStateを管理する
    (datacomputedを見ればどんなデータを扱うのかが分かる)
  • 画面内で共通の処理を定義する
    (methodsを見れば共通のロジックが分かる)
  • Component間のイベントバスになる
    (子ComponentAのイベントをトリガーに子ComponentBの状態を変える、等)

2. 他画面との関係性を表現すること

  • 画面同士でデータや状態のやりとりを行う
  • ルーティング処理を定義する
  • vue-routerがurlと紐付けるコンポーネントとなる

Containerは1画面に1つと基準が明確なので、コンポーネントを切るときにはあまり困らないです。が、機能を実装する際に「これは本当にContainerが持つべきもものか」を意識していないと、datamethodsはどんどん肥大化してしまいます。言い換えれば「とりあえずContainerに実装しておけば動く」という機能が多いということです。
なので画面に機能を足すときには、後述する各Componentに機能を移譲できないか? を常に検討するようにし、Containerの責務を必要最低限を保つようにしています。

ViewModel Component の責務

ここからはContainer内に配置するComponentについてのパターンです。
Vueは双方向バインディングができることから、MVVMな設計で利用されることがよくあります。Vue+VuexでMVVMなWebApplicationを設計するときに考えたいことの記事にも書きましたが、VueのコンポーネントはMVVMのV(View)とVM(ViewModel)の機能を併せ持っています。簡単に言えば単一コンポーネントの<template>の部分がViewで、<script>の部分がViewModelという感じです。

つまりComponentは、標準でViewModel(とView)の責務を持っているということです。
ここでViewModelの役割を見直してみます。

  • PresentationロジックとStateを持つ
  • Modelに依存する
  • Modelを監視する
  • Viewと双方向データバインディングする

といったことが挙げられます。これをVueの言葉で言い換えると

  • methodsにコンポーネントを操作するロジックを持つ
  • data, computedにコンポーネントのStateを定義する
  • data, computedにコンポーネントに必要なModelを定義し、リアクティブに更新する
  • Templateにdatacomputedで定義した値を反映する、またv-model等でTemplateの変更を検知する

となります。これらが過不足なく実装されていると、ViewModelとしての責務を持つComponentだと言えそうです。

では過不足なくとはどういう状態か。
これはViewModelの設計の粒度に依るので正解はないと思いますが、ViewModel Component各々が1機能ずつ責務を担っていて、その粒度が揃っていればいいと思います。

これができているか確認するのは案外簡単で、ViewModel Componentの名前をリストアップしてみたときに、「名前から責務が分かるか」「その粒度が同程度に見えるか」で判断できます。責務は1つなのでそれが適切に名前に反映されていれば合格で、もし反映できないなら責務を持ちすぎていている可能性があります。
(Componentを細かく切りすぎてしまって責務のないComponentができているケースもたまにありますが、個人的に多い例は責務を持ちすぎているパターンです……)

Vueのコンポーネントは、Vueの仕様的に見てもそれ単体で動作するように実装できる仕様であり、MVVM的に見てもViewModelはそれ単体で1つの機能を実現するものです。そのためVueのコンポーネントは、基本的にはそれ単体で動くViewModel Componentとして切るのが良いと思います。

ただそれだけだと対応できないパターンがいくつかあるので、そのために以下に別パターンのComponentの切り方を用意しています。

View Component の責務

コンポーネントのTemplateを書いていると、ループ対象を別コンポーネントとして抜き出したい場合や、Templateがでかすぎるので分割したい場合などがあると思います。こういった例で使えるのがView Componentです。

View ComponentはReactのようにステートレスなコンポーネントで、親コンポーネントから受け取るpropsのみで動作させます。そのため、基本的にはmethodsdataは定義しません。

親から受け取ったものをTemplateにはめ込んで表示し、親から受け取った関数をたたく。それだけの責務を持つこのComponentは、独自のViewのみを持ちViewModelを持たないComponentだと言えます。

このView Componentを実装するときに意識していることは、このコンポーネントが「親の一部である」ということです。親のTemplateを分割しただけのものであるということを意識し、ロジックを実装したり過剰に機能を持たせたりしないように注意しています。それができない場合は、コンポーネントの切り方が間違っている可能性を疑います。
例外はあるかもしれませんが、多くの場合は切り方を変えることで解決します。

Design Component の責務

これはView Componentの亜種で、実装の仕方はView Componentと同様です。つまりステートレスでpropsを受け取って動作します。

ただ目的が違い、View Componentが親のTemplate分割を目的に作られるのに対し、こちらはUIパーツのデザイン共通化を目的に作られます。アプリ内でLabel付きButtonのUIを統一したい、ダイアログのUIを統一したい、といった場合に作られます。

そのため、粒度の小さいパーツはTemplateとpropsをHTML標準のタグと同じ状態にしておくと、使うときに楽だったり後々置き換えが効いたりして便利です。

Factory Component の責務

今までのコンポーネントとは性質が少し違い、表示するComponentを動的に切り替える機能を持つComponentがFactory Componentです。 具体的には以下のような役割を持ちます。

  • propsでtypeを受け取る
  • v-bind:isを使ってtypeに応じたComponentをレンダリングする
  • 描画するComponentたちのpropsを抽象化する

漠然としていてピンとこないかもしれないのでサンプルコードを載せます。

<template>

<component :is="componentName" :componentData="componentData" />

<script>

export default {
  props: { type: String, componentData: Object },
  components: { componentA, componentB },
  computed: {
    componentName() {
      switch(this.type) {
        case 'A':
          return 'component-a';
        case 'B':
        default:
          return 'component-b';
      }
    }
  }
}

propsで受け取ったtypeに応じて、描画するComponentをv-bind:isで切り替えています。switch文を使っていますが、type名がそのままComponent名となるようにしてもいいですし、Factory Methodっぽくしてもいいと思います。

また、どのComponentにもcomponentDataを渡しています。この部分がpropsの抽象化です。抽象化しているので、Factory Componentを呼び出している側はどのComponentが描画されるのかを気にする必要がなくなりますね。

Higher-Order Component の責務

Componentを引数にとり新たなComponentを返す関数、言い換えるとComponentを様々な方法で再利用できる仕組みがHigher-Order Componentです。Reactにも同じ概念がありますね。実装方法の差はあれど、責務は似ています。

  • Factoryとなり、表示するComponentを動的に切り替える
  • ComponentをWrapし、Decoratorパターンのように機能を追加する
  • propsをproxyし、加工する

このようなことができます(他にもいろいろできます)。
これはやろうとすればかなりトリッキーなこともできる仕組みで、また(抽象レイヤーであるために)責務が曖昧になりやすいので、個人的にはなるべく使わないようにしています。

おわりに

Containerとしての責務と、Componentとしての責務5パターンをまとめました。
これら以外にも様々な切り口があると思いますが、現時点で自分の中で有用だと思うものを整理してみました。ディスカッションのたたき台にでもなればと思いますので、ご感想・ご意見がありましたらぜひ@aloerina_まで。