本記事では、単一ファイルコンポーネントのモジュール分割について紹介します。
単一ファイルコンポーネントとは
最初に、『コンポーネント指向』という言葉から説明していきます。
コンポーネント指向とは、小さなUI部品を組み合わせて、一つのアプリを作り上げていく開発手法のことです。 LEGOやガンプラと似ている気がします。Vue.jsもこのコンポーネント指向に沿った開発方法が採られています。
コンポーネント指向における各UIパーツを、Vue.jsでは単一ファイルコンポーネント(以下、SFC)という形で管理します。SFCの最大の特徴としてはHTML, Javascript, CSSが1ファイル内にまとまっていることが挙げられます。
例: モーダルコンポーネント |
---|
SFC のイイとこ
-
SFCは直感的なコーディングを可能にします。
SFCの見た目や動作は、ファイルの中身を見るだけでおおよそを把握することが出来ます。何故なら、SFCはUIパーツの持つテンプレート・スタイル・状態・インタラクションを全て内包しているからです。こうした特徴は、開発速度の向上にも繋がります。 -
SFCは未経験者でも、すぐにコーディング出来るようになります。
SFCの大部分はHTML, Javascript, CSS というウェブアプリの基本的な言語のみで構成されています。今までVue.jsを触ったことが無かった人でも、短い学習期間ですぐに開発に参加できるようになります。 -
SFCはチーム間での並行作業に向いています。
それぞれのSFCは基本的に互いに影響し合うことがありません。ですので、メンバー間でのコンフリクトを気にすることなく開発に専念できます。
Vue.jsがスタートアップ企業を中心に流行したのは、SFCによる簡単かつ高速な開発体験が大きな要因だと言えると思います。
SFCの気をつけるべきとこ
...とまぁ、うp主はVue.jsのことを大変気に入ってはいるのですが、中規模以上のアプリ開発では『コードの重複』と『コードの複雑化』に注意しなければなりません。
これらの大本は、関心の分離が上手く出来ていないことに起因します。 繰り返しになりますが、SFCとはデザインとロジックをひとまとめにしたUIパーツのことです。ですので、UIパーツを再利用したい場合は、単に再利用したい部分をSFCとして共通化すれば良いだけです。では、デザインのみを再利用したい場合はどうでしょうか?ロジックのみを再利用したい場合はどうすればいいでしょうか?
ここを正しく整理せず、似たような機能のSFCを作ってしまうとアプリ内にコードが重複し始めます。あるいは、複数のユースケースに対応させるためにSFCに条件分岐が増えていきます。 コードの重複が起こると、単純にアプリのサイズが肥大化する他、アプリの修正が困難にになり、修正漏れによるバグも発生しやすくなります。他方、条件分岐が増えるとコードは読みにくくなりますし、やはりこれもバグの温床となります。何にせよ、一つとして良いことがありません。
SFCの設計論
SFCのコード分割を正しく行うために、設計の観点からSFCを捉えてみましょう。
SFCはアーキテクチャの観点から言えば、プレゼンテーション層(HTML/CSS)・インターフェース層(Vue.js)・アプリケーション層(Javascript)を、UIパーツ単位で1ファイルにまとめたものと私は考えています。 そのため、必然的にSFCは3つの責務を含むことになります。しかし、単一責務の原則から考えると、これはやり過ぎかもしれません。
単一とまではいかなくとも目的に合わせてコードを分割することで、綺麗で重複のないSFCを維持することが出来るようになります。
以下にこの記事のゴールを示します。ちなみにVue.jsはMVVMアーキテクチャを採用していますね。
SFCを必要に応じて分解し、パーツを組み合わせて再構成する。それが、Vue3のComposition(合成)APIの名前の由来にもなっています。
Presentational Container
まずは、デザインとロジックを分離するところから始めましょう。
デザインとロジックの分離を図る設計パターンのことを、一般に『Presentational Container』と呼びます。 馴染みのない言葉かもしれませんが、SFC内のデザイン部分とロジック部分をそれぞれ別のコンポーネントに分けて記述しましょうというだけの話です。
ContainerはPresentationalに画面の描画に必要なデータを渡し、ユーザーの操作によって発生したDOMイベントを受け取ります。 Vue.jsの場合、これはPropsとEmitを用いて行われます。
一方、PretantaionalはContainerから受け取ったデータを画面に反映する役割を持ちます。また、ユーザーアクション等によって発火されたイベントをContainer側に通知します。
このように責務を分離することで、再利用がしやすくなるだけでなく、コード修正の影響範囲を小さくしたり、テスタビリティの向上といった恩恵も受けることが出来ます。
ロジックの分離
続いて、ロジックの分離について考えていきましょう。
フロントエンドにおけるロジックは『グローバルに共有したいか・状態を持つか』によって大きく4つに分類されると自分は考えています。
前者は、データをアプリ全体で単一なものとして扱いたいかどうかという問いです。 モジュール化した関数やクラスのインスタンスは、お互いが独立したスコープを持っています。ですので、あるインスタンス内のデータ変更は、他のインスタンスに影響を及ぼすことはありません。しかし、ユーザーの認証情報などアプリ全体で単一のインスタンスを共有することが望ましいデータも存在します。
後者は、Vue.jsのリアクティブシステムに依存したロジックかどうかという問いです。 リアクティブシステムに登録されたデータは、データの変更されると依存する関数やDOMの再計算・再描画を自動で行ってくれるようになります。こうしたリアクティブ性が必要なロジックかどうかを考慮しなければなりません。
1. 非グローバル&非リアクティブ
まずは、左上の状態を持たないロジック、即ちVue.jsのリアクティブシステムに依存しない、プレーンなJavascriptで構成されるロジックについてです。
Javascriptにおいてモジュール化は、特定の関心事についてのデータと加工演算処理をまとめたものを、関数やクラスとして公開することで行われます。
カウンター関数の例
export const counter = () => {
let count = 0;
const increment = () => count++
const decrement = () => count--
return {
getCount: () => count,
increment,
decrement,
}
}
2. 非グローバル&リアクティブ
続いて、リアクティブなロジックについて考えていきます。
前述の通り、リアクティブとは、データの変更に応じて自動的に再評価・再描画される仕組みのこと指します。 宣言的UIであるVue.jsでは、Composition APIが提供するrefやreactiveといった関数を使用することで、内部処理を気にすることなくリアクティブシステムを構築することが出来ます。
Vue.jsではリアクティブなロジックを部品化したものをComposableと呼びます。
カウンターComposableの例
import { ref } from 'vue'
export const useCounter = () => {
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
return {
count,
increment,
decrement
}
}
3. グローバル&非リアクティブ (表左下)
続いて、関数やクラスのインスタンスをグローバルに共有したい場合について考えます。
1のパターンでは、クラスを作成したり、関数を実行するたびに異なるインスタンスが作成されていました。 ですので、お互いのデータは独立しており、あるインスタンスのデータの変化は、他のインスタンスに影響を及ぼしません。複数箇所でモジュールを使用しても、お互いのデータの状態を気にすることなくコーディングすることが出来ます。
一方、インスタンスをグローバルに共有するためには、関数やクラスのインスタンス生成が最初の一度のみしか行われないようにしなければなりません。 この考え方は、デザインパターン用語では『Singleton』と呼ばれています。
グローバルカウンター関数の例
const globalCounter = () => {
let count = 0
const increment = () => count++
const decrement = () => count--
return {
getCount: () => count,
increment,
decrement
}
}
export default globalCounter()
先程との違いは、関数実行後の結果をエクスポートしている点です。 globalCounterを何回呼び出しても関数は最初の一回のみ実行され、以降は同じインスタンスを返すようになるため、全ての箇所でデータが共有されるようになります。
4. グローバル&リアクティブ (表右下)
最後に、グローバルに共有されるリアクティブロジックについてです。
先程と同じように、ComposableにSingletonパターンを適用することで、リアクティブなロジックをグローバルに共有することが出来るようなります。 ですが、より優れた方法としてVue.jsでは『Pinia』というライブラリが提供されています。
Piniaの良い点は、Composableと全く同じ書き味でロジックを記述出来る点です。さらに、Piniaを導入することには以下のメリットあり、開発体験を向上させることが出来ます。
-
Vue Devtools との連携
devtoolを使用することで、全てのグローバルストアの現在の状態を視覚的に表示することが出来ます。状態の変更履歴も調べることができ、デバックの役に立ちます。 -
Hot Moudele Replacement
ページをリロードすることなく、ストアの修正をアプリに反映させることができます。 -
Plugin
プラグインを使用することで、簡単に機能拡張をすることが出来ます。
piniaを使用したカウンターの例
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
return {
count,
increment,
decrement
}
}
まとめ
長文になってしまいましたが、これでSFCのモジュール分割の話は以上です。
最後に改めて述べておきたいのは、これはSFCを常に分割すべきという話では無いということです。 そもそも堅牢性を重視したアプリ設計をするなら、Vue.jsを選択しない方が良いです。Vue.jsの一番の強みは開発速度の速さと簡潔さです。ですが、モジュール分割をすればするほど、SFCのメリットは段々と損なわれていきます。アプリの開発速度と堅牢性は常にトレードオフの関係性にあります。
ですから、モジュール分割をする際には、本当に必要かどうかを常に考えなければなりません。SFCの強みである開発速度とアプリの堅牢性を両立させることが、私達が目指すべきゴールなのだと思います。