VueComponent間で再利用可能な部品を実装するための機能がmixinです。mixinを使った共通化の例はよく見かけますし、私もしばしばやってきました。ただ、どうも自分の実装方法だと後々不便になったり見通しが悪かったりと、使い勝手の悪いものになってしまうことが多かったです。

そこで今回は自分の過去の実装例を見返しながら、なぜ失敗したのか、mixinをどうを使うべきかについて、現時点の考えをまとめてみます。

この記事で紹介する失敗例は、私が携わったプロダクト開発においてデメリットの方が大きかった実装例です。

便宜上「アンチパターン」「失敗例」といった表現をしていますが、あくまで個人的にやりたくないパターン程度の意味合いです。

失敗例1. Template Methodパターン意識したmixin

Componentが必ず実装しなければならないmethodsを定義したmixinを用意し、それを読み込んだComponentで必要に応じてmethodsをOverride方法です(既に失敗しそうな匂いがしますね…)。

以下のサンプルコードは、とある1画面を表現する MainPageContainer.vue と、どの画面にも必要な共通処理を抜き出した pageContainerMixin.js です。

pageContainerMixin.js

export default {
  created() {
    document.title = this.getTitle();
  },
  mounted() {
    this.sendPageview();
  },
  methods: {
    getTitle() {
      throw new Error('You must override this function.');
    },
    sendPageview() {
      window.ga('send', 'pageview');
    },
  }
}

MainPageContainer.vue

<template><!-- 省略 --></template>

<script>
import pageContainerMixin from './pageContainerMixin';

export default {
  mixins: [ pageContainerMixin ],
  methods: {
    /** @Override */
    getTitle() {
      return 'メインページ';
    }
  }
}
</script>

mixinの中にはOverrideして使われることを想定したgetTitle関数が実装されていますね。Overrideされなかった場合に例外を投げて教えてくれます。

またmixinの中で、ライフサイクルメソッドからsendPageview関数などを呼び出すことで、Componentに必要な処理が暗黙的に実行される仕組みを実現しました。

暗黙的挙動の危うさ

Containerに必須の処理をmixinに閉じ込め暗黙的に実行させたことで、チームメンバーが簡単に新規Containerを実装できるようになったと当初は満足していました。

しかしアプリケーションが大きくなりContainer毎の独自の仕様が必要になったとき、またはメンバーが新規参入してきたとき、暗黙的に実行される処理の中身やそのOverride方法を読み解く必要性が出てきました。言い換えれば「挙動が読み解きにくく、バグを生みやすい箇所」に化けた、ということになります。

Vueなどのように知識が一般化されたフレームワークの暗黙的挙動ならまだしも、こういった「独自の暗黙的挙動」は負担になりやすいので注意が必要です(もちろんメリットもあるので、天秤にかけて判断する必要があります)。

OverrideではなくMergeしているだけ

また、この仕組みはOverrideを前提としていますが、mixinがやっていることはオブジェクトのMergeです(Overrideではなく!)。擬似的にOverrideに見せていますが、これにはいろいろな罠が潜んでいます。

Overrideする要素の型を束縛できない

  • mixinではstringを返す関数が、Override後はPromiseを返す関数になっている、なんて実装もできてしまう
  • このような問題をコンパイラやIDEでチェックできない
  • mixin側の関数を変更しても、そのOverride箇所を機械的に見つけられない
  • 型を束縛しないならmixinで定義する必要がない(関数名を定義しているだけで、引数も戻り値もなんでもアリになってしまう)

マージストラテジが変更される可能性がある

  • マージストラテジが変更されるとOverrideされなくなる可能性がある
  • マージストラテジ変更により影響の出る箇所をコンパイラで見つけられない
  • マージストラテジが違う箇所を理解している必要がある(createdはOverrideされない等)

Classの継承とmixinは本質的に違う

Template MethodパターンはClassの親子関係を利用したものですが、そもそも抽象Classを定義したりClass継承したりすることは「共通化」や「再利用」が目的ではありませんね。Class継承の目的は「基底と派生」にあります。そのため、規定と派生におけるデザインパターンであるTemplate Methodパターンをmixinに当てはめようとすること自体が誤りであると考えられます。

そんなわけで、mixinを擬似的に継承したりOverrideしたりする実装方法は、今ではやらないようにしています。

失敗例2. Decoratorパターンを意識したmixin

継承(のような)関係でmixinを使うことに危機感を覚えて以後は、もっとシンプルに依存関係の少ない単発の機能を付与するmixinを、以下の2つのルールに基づいて実装していきました。

  • JavaなどのInterfaceにあるように「〇〇able」という命名をする
  • 他のモジュールに依存しない(importしない)

以下は、PC版Twitterのフォロワー一覧画面のように、ユーザープロフィールがカード型の一覧で並ぶ画面のサンプルコードです。画面内で使われるカードComponentの ProfileCard.vue と、もっと読むボタン等などでよく見る「開閉させる機能」を提供する expandable.js というmixinの実装です。

expandable.js

export default {
  data() {
    return {
      isExpanded: false,
    }
  },
  methods: {
    toggleExpand() {
      this.isExpanded = !this.isExpanded;
    }
  }
}

ProfileCard.vue

<template>
  <div>
    <div v-show="!isExpanded"><!-- プロフィールの概要 --></div>
    <div v-show="isExpanded"><!-- プロフィールの詳細 --></div>
    <button @click="toggleExpand">{{ buttonLabel }}</button> 
  <div>
</template>

<script>
import expandable from './expandable';

export default {
  mixins: [ expandable ],
  computed: {
    buttonLabel() {
      return this.isExpanded ? '折りたたむ' : 'もっと見る';
    }
  }
}
</script>

expandableは何にも依存せず、シンプルに「開閉ステータス」と「開閉させる関数」だけを持っています。名前からもその機能が提供されることが予測できますね。Component側も、(正確にはDecoratorパターンとは違いますが)mixinで柔軟かつ簡潔に機能を付け外しできていることがわかります。

名前競合にまつわる問題

mixinを使う以上当たり前の話で、この手法に限った問題ではないのですが、名前の競合が起きる点には注意しなければなりません。mixin一つ一つをシンプルに実装しているだけに、dataやmethodsの命名もシンプルになりがちで競合が起きやすくなっているからです。

それを回避しようとすると、わざとらしく被らない命名になったりします。失敗例1ではあえて同じ命名にしてOverrideしていましたが、今度は名前が被らないように必死ですね…。

結果的に、せっかくmixinでコードを分離しているのに関心は分離されず、常にmixinのことを念頭においてComponentを実装することになります。

実はComponentに依存している

こちらもmixinを使う以上当たり前の話ですが、mixinの中で使われるthisはComponentに依存します。

// share機能を実現する sharable.js

export default {
  computed: {
    // Component側でshareTextとshareUrlが定義されることを期待した実装の例
    shareUrl() {
      return `https://example.com/share?text=${this.shareText}&url=${this.shareUrl}`;
    }
  },
  methods: {
    share() {
      // 省略
    }
  }
}

ちょっと雑な例ですが、外部サービスのintentを利用したshare機能を実現するmixinの実装例です。shareする文言やURLをComponent側で定義してもらう想定で実装しています。この例はかなり極端ですが、mixinがComponentに依存する可能性がある(危険なコードが生まれる仕組みがある)ことがわかります。

Componentのソースが読みにくくなる

これは問題と言うには大げさかもしれませんし、やはり当たり前な話なのですが、mixinを使うと見えないところでComponentのthisにプロパティが増えていくことになります。
単一ファイルコンポーネントのソースを読んでいて「このthis.〇〇はどこに実装されているの?」と思ったことありませんか? mixinが増えるとこの状況が増えるわけですね。

mixin以外に、VuexのmapState等でもthisにプロパティを生やしていくので、VueComponentは見えないところでプロパティが増えがちです。となるとmixinは少ないほうがソースの読みやすさを保てそうですよね。

まとめると、失敗例2のほうはmixinの使い方を根本的に間違えていたわけではなさそうですが、mixinが持つデメリットや懸念点に多く直面した例でした。このあたりから、mixinの利用は最低限にしようと感じるようになります😌

VueComponentにおける共通化の進め方

ここまでで何となく「mixinは使わないほうが良さそう」という感覚を持てるようになりましたが、単純にmixinを排除するだけでは本来の目的は果たせません。mixinの目的である「共通化」について代替案を見つける必要があります。

  • 前提として、mixinは極力使わない
  • 共通化できそうな箇所を把握する
  • 場面に応じて適切な共通化の手段を選択する

という方針で考えてみます。

宣言部分は共通化しない、つまりmixinを使わない

失敗例2でも触れたように、VueComponentは見えないところで(単一ファイルコンポーネント内に明記されることなく)thisにプロパティが増えがちです。これを減らすためには、単純にthisのプロパティの宣言を共通化せず、単一ファイルコンポーネント内に明記すれば良いです。具体的には

  1. data, props などの「値」をmixinで共通化しない
  2. ライフサイクルメソッドをmixinで共通化しない
  3. computed, methods などの「関数」の宣言部分をmixinで共通化しない
  4. 「関数」の共通化が必要な場合は、その内部処理を単純な関数として(mixinを使わず)共通化する

といったことをルール付けると良さそうです。

// 上記のルールを守った単一ファイルコンポーネントの実装例

<template><!-- 省略 --></template>

<script>
import { sharedCreateFunc, sharedCalcFunc } from './shared';

export default {
  data() {
    return {
      value: null,                        // ルール① 値の宣言をmixinに書かない
    }
  },
  created() {                             // ルール② ライフサイクルメソッドをmixinに書かない
    this.initialize();
  },
  methods: {
    initialize() {                        // ルール③ initializeという関数の宣言をmixinに書かない
      this.value = sharedCreateFunc();    // ルール④ 関数内部の処理を、mixinを使わずに共通化する
      // do something
    }
  },
  computed: {
    calculateValue() {                    // ルール③ calculateValueという関数の宣言をmixinに書かない
      return sharedCalcFunc(this.value);  // ルール④ 関数内部の処理を、mixinを使わずに共通化する
    }
  }
}
</script>

こうすることでthisに紐づくプロパティの実装が明記され、単一ファイルコンポーネント内の可読性が上がりました。小さなアプリケーションであれば無理に共通化までしなくても十分かもしれません。実装が分かれているということは、影響範囲が分かれているということですので、これだけでもメリットは得られますね。

ViewModelの観点から共通化できる箇所を見つける

Vueアプリケーションの設計モデルのひとつであるMVVMでは、VueComponentをViewModelとして捉えます。詳細は「Vue+VuexでMVVMなWebApplicationを設計するときに考えたいこと」の記事でまとめていますが、ViewModelが持つロジックをざっくり抜き出すとこんな感じです。

  1. Viewのイベントハンドリング
  2. View用のデータ成形
  3. ローカルStateの管理
  4. Modelの監視(VuexのStoreとのつなぎ込み等)

これらのロジックのうち、①Viewのイベントハンドリング、②View用のデータ成形はViewの状態に大きく依存します。そのため同じようなViewに結びつくViewModel同士では、ロジックを共通化できる可能性があります。例えば表示を切り替えて利用する「Grid Component」と「List Compornent」では、clickイベントのハンドリングロジックやsortロジックが共通化できる可能性があります。

同じ理屈で、④Modelの監視については、同じModelに依存するViewModel同士で共通化できる可能性を秘めていますね。

ただし③ローカルStateの管理については共通化しないほうが良いケースのほうが多そうです。ローカルStateは「Viewに状態をもたせる機能」であり、ViewModelの責務と強く結びつきます。責務を分離するためにViewModel(Component)を分けていると考えれば、責務に密接するローカルStateも共通化せず分けて扱うほうが良いと言えます。逆に言えば、ローカルStateを共通化できるComponentは、Componentの切り方を見直す余地があるということになります。

これにならうと、前述のexpandable.jsの例では、それぞれのComponent毎にisExpanded(開閉ステータス)を実装すべきということですね。そうすることで「このComponentでは最初の1件だけ初期値を開いた状態にする」「このComponentでは1度開いたら閉じない」といったような個別の仕様変更にも対応でき、まさしく責務の分離(影響範囲の分離)が生きてきます。

まとめると

  • 「Viewのイベントハンドリング」は近しいViewを持つViewModel同士で共通化可能
  • 「View用のデータ成形」は近しいViewを持つViewModel同士で共通化可能
  • 「ローカルStateの管理」は共通化しないほうが良さそう
  • 「Modelの監視」は同じModelに依存するViewModel同士で共通化可能

といった具合に共通化できそうな箇所が見えてきました。

mixinを使わずに共通化する

今回は、mixinを除いた3パターンの共通化の方法を考えてみます(これら以外にも有用な案があればぜひ@aloerina_までご連絡ください!)。

  • Util、Helper、Serviceクラスなどを使って共通化する
  • Vuexのgetterなど、Model側で共通化したい処理を持つ
  • slotを使って高階関数的に実装する

Util、Helper、Serviceクラスなどを使って共通化する

先述の「関数の内部処理を共通化する」例のように、共通化したい部分をVanillaJSで実装した関数群として提供する方法です。この関数群はMVVMとは別のレイヤーで考えることが多く、役割に応じてUtil、Helper、Serviceなどと呼ばれたりします(Serviceはビジネスロジックのまとまりなのでドメインレイヤーだと言われたりもしますが、今回は曖昧に使います)。

いずれの方法にしても大事なことはVueComponentに依存する情報は引数で受け取るということです。

Viewのイベントハンドリングのロジックでは「Viewの状態」や「ユーザの入力値」を扱うことになるでしょうし、View用の表示データの成形では「ViewModelがModelから受け取った値」などを扱うでしょう。それらを利用したロジックを共通化するには、それらを引数で受け取ることが必要なわけですね。

これらは特に「Viewのイベントハンドリング」「View用のデータ成形」の共通化で使うことが多いです。

Vuexのgetterなど、Model側で共通化したい処理を持つ

Modelの値を加工する処理等Modelに依存する処理は、Model側に定義しておくのもひとつの手です。元来的なMVVMではViewのための値の加工はViewModelですべきとされていますが、Vuexでは「ViewModel毎に何度も同じ加工ロジックを実装するくらいなら、Model側に寄せてしまえば?」という思想でgetterが用意されています。ですのでVueアプリケーションではこれに則ってModel側で共通化することもひとつの手段と言えるでしょう。

slotを使って高階関数的に実装する

どうしてもローカルStateの管理にまつわる部分を共通化したいケース、またはTempplateをセットで共通化したいケースなどでは、slotを使った高階Componentを定義する方法が使えます。Reactで言えばHOCに相当しますね。VueでReactのようにHOCをすると結局オブジェクトのマージが発生してしまってmixinと大差ないので、slotを使ってComponentの中にComponentを配置することで、高階関数的な挙動を実現します。

まとめ

長くなってしまいました。まとめます。

  • mixinは極力使わない
  • 宣言部分は共通化しない、内部的な処理は共通化しても良い
  • Util、Helper、Serviceクラスなどを使って共通化する
    • Viewのイベントハンドリングで使える
    • View用の表示データの成形でも使える
  • Vuexのgetterのように、Model側で共通化する
    • Modelの監視などで使える
  • slotを使って高階関数的に共通化する
    • どうしてもローカルStateを共通化したいときに使える
    • Templateも含めて共通化したいときに使える
  • どの方法でも代用できない場合はmixinを使う

おわりに

この記事は Vue.js #2 Advent Calendar 2018 25日目の記事として書かせていただきました。Advent CalendarからもVueへの関心や利用実績の高さが伺えるように、きっと来年もVueは盛り上がっていくのだと思います。その中で生まれる一人ひとりの経験がこうして共有されていることは、Vueユーザの一人としてとてもありがたく、楽しくもあります。
今後もVueコミュニティが知見と活気で盛り上がりますよう、クリスマスに願いを込めたところで締めさせていただきます。