この記事は、ビジネスドメイン(主に、データベース側で保存されている形式)と異なる形式で画面側で入出力を行わなければならない場合に、算出プロパティを用いてビューの入出力形式とモデルの型を切り離し、クリーンに設計する方法について解説します。
例として、進捗率(progress)がデータベース側で比率(0.000~1.000)として保存されている値を、ビュー側ではパーセント形式(0.0%~100.0%)で入出力するようなケースで、APIとやりとりするprogressを比率にするか、パーセント形式にするか、というケースを考えます。
進捗率を比率(0.000~1.000)で入力する
Vueを使ってサーバから取得したモデルデータをinputタグを用いて表示および入力する場合、通常、次のようにv-modelでバインドすることができます。
<div id="app">
<p>{{progress}}</p>
<input type="number" step="0.1" v-model="progress" />
</div>
createApp({
data() {
return {
progress: 0.255, // 実際にはサーバから取得した値が設定される
}
},
}).mount('#app')
上記のコードを用いれば、「0.852」のような値をテキストボックスに直接入力し、ViewModelを更新することができます。
(上記では省略していますが、あとでそれをサーバに「送信」してDBを更新することになるでしょう)
現状では、これで問題はありません。
進捗率をパーセント表記で入出力するよう変更になった
ここで、上記の「progress(進捗率)」を、0.852 という比率を示す実数値ではなく、85.2%というパーセント表記で入出力するよう変更になったとします。
その時に、サーバ側のAPIを変更し、progress を パーセント値として受け取れるようにし、それを比率に変換(つまり100で割って)DBに保存する、という対応を行って、画面側は対応不要!としてしまうのはあまり良くない対応と言えるでしょう。
なぜならば、このシステムにおいて「進捗率」とはパーセント値ではなく、比率を示す実数値としてビジネスドメインで定義されているものだからです。
パーセント値でやりとりするというのは、あくまでも「ユーザーインタフェースの為の仕様」であり、ビジネスドメインが影響を受ける部分ではありません。
APIはそのシステムのユーザーインタフェースに合わせて作られるのではなく、可能な限り、ビジネスドメインの属性にのみ依存すべきです。あるAPIはパーセント値、あるAPIは比率で進捗率を扱う、というのでは、混乱の元となるでしょう。
この場合、APIとのやりとりはパーセント値ではなく比率で行われるのが、シンプルで変更に強く、再利用性の高いシステムとなり得ます。
Vue側で比率とパーセント表記の変換を行う
VueのViewModelにて、data()には変更を加えません。そこにはサーバから取得した値をそのまま渡します。
その代わりに、inputタグにバインドする値をcomputedで次のように別途定義します。
createApp({
data() {
return {
progress: 0.25, // 実際にはサーバから取得した値が設定される
}
},
computed : {
progress_percentage : {
get() {
// progressを100倍した値を返す
return new Decimal(this.progress).times(100).toNumber();
},
set(val) {
// 入力値が数値以外なら0、それ以外は100で割った数値をprogressに設定
this.progress = isNaN(parseFloat(val)) ? 0 : Decimal(val).div(100).toNumber();
}
}
}
}).mount('#app')
上記では、progress_percentage という名前のcomputedプロパティを新たに定義しています。
getterではthis.progressを100倍しています。
javascriptでは実数計算で誤差が出てしまう為、decimal.jsを利用しています。
get() {
// progressを100倍した値を返す
return new Decimal(this.progress).times(100).toNumber();
},
同様に、setterでは、入力値をチェックし、数値以外ならば強制的に0、それ以外は100で割った数値をprogressに設定しています。
こちらも同じくdecimal.jsを利用して実数計算を行っています。
set(val) {
// 入力値が数値以外なら0、それ以外は100で割った数値をprogressに設定
this.progress = isNaN(parseFloat(val)) ? 0 : Decimal(val).div(100).toNumber();
}
つまり、progress
が0.852
の時にconsole.log(progress_percentage)
とすれば、85.2
が取得できるし、progress_percentage = 89.7
とすれば、progress
には0.897
が設定されることになります。
そして、上記で定義したprogress_percentage
を、テキストボックスにバインドします。
但し、普通にv-model="progress_percentage"
としてしまうと問題があります。
<div id="app">
<p>{{progress}}</p>
<input type="number" step="0.1" v-model="progress_percentage" />
</div>
ご存じの通り、v-model="xxx"
バインディングは、実際にはv-bind:value="xxx"
とv-on:input="xxx = $event.target.value"
を自動で行う糖衣構文です。
よって、v-model="progress_percentage"
の指定は、テキストボックスへ文字を入力する度にinputイベントが実行されることになり、その都度上記のgetter/setterが毎回呼び出されてしまいます。
getter/setterの内部で計算をしている以上、入力値は完全な値であることが前提となります。しかし実際には入力値は「入力途中の状態」があり得ます。例えば12.
や、それこそ空文字もあるでしょう。inputのtypeをnumber
にしても、e
などの中途半端な数値表現文字は入力できてしまう問題もあります。その都度setterを呼び出しても、数値として認識されない入力値は0とするようにしている為、正しく入力できません。
上記のような計算プロパティをバインドさせる場合には、inputイベントではなくchangeイベントを利用した方がよいでしょう。changeイベントは、テキストボックスからフォーカスが外れたタイミングで呼び出されるので、それまでは自由に入力することができます。
<div id="app">
<p>{{progress}}</p>
<input type="number" step="0.1" v-bind:value="progress_percentage" v-on:change="this.progress_percentage = $event.target.value" />
</div>
これによって、フォーカスアウト時に値が変更されていた時だけ、progressが更新されるようになります。
何が良いのか
算出プロパティを用いることにより、progressはビューの都合による「パーセント形式」という仕様から切り離され、常に比率での値を保持し続けます。
画面側の他の箇所でこの比率を用いた計算を行っている場合、もしprogressそのものをパーセント形式にしてしまえば、計算処理にも影響が出ます。しかし、算出プロパティによって「パーセント形式との相互変換」がViewModelの内部に閉じ込められている為、progressそのものはこれまで通り、比率で計算することに集中できます。
つまり、ビュー側だけを見ても、入出力の為のデータ変換処理をデータモデルから切り離しておくことには、大きな意味があるのです。
そしてもちろん、サーバ側のAPIもビューの表示形式に依存しない形に統一でき、ソースコードの一貫性が保たれます。
まとめ
ユーザーインタフェースの都合でAPIのデータ型の仕様を決めるのを避けましょう。
それらの変換はユーザーインタフェースの責務であり、APIとのやり取りに用いるデータ型は可能な限りビジネスドメインに近い型に統一されているべきです(例外はもちろんあります)。
ユーザーインタフェースは変更されやすく、その仕様にAPIが依存してしまうのは様々なシーンで弊害を生みます。
ユーザーインタフェースの仕様はユーザーインタフェース側で対処するようにしてください。
この考え方は、例えば「サーバでは円単位で保存しているが、千円単位での入出力を行う」「サーバではdecimalで保存しているが、カンマ区切りでの入出力を行う」「サーバではdate型で保存しているが、yyyy/mm/dd形式で入出力を行う」など、様々なケースで適用できます。
尚、複数の画面で「パーセント表記での入出力」が必要になってきたら、これをVueコンポーネントとして定義し再利用するのが良いでしょう。その方法についてはこの記事では触れません。
この記事のコードサンプルはこちらで確認できます。