Vueにおいてv-model
で双方向バインディングするとき、数値や文字列等の単純型であれば話は簡単なのだが、オブジェクトや配列等の複合型で双方向バインディングする場合の解説がほぼ無い。よってこの記事では私がVueを使っている中で導いた、オブジェクトをv-model
で双方向バインディングする場合の鉄板パターンを解説する。
前提
- Vue3のComposition APIを使用
- Option APIに応用することは可能
状況設定
時刻の範囲を入力するコンポーネント<InputTimeRange>
とそれを使う<App>
コンポーネントの二つを作る。<InputTimeRange>
からはユーザーによって入力されている開始時刻と終了時刻のペアが取得でき、その開始時刻は終了時刻よりも必ず前になる。この制約により開始時刻と終了時刻は互いに独立しておらず、開始時刻と終了時刻が複合した型があると考える必要がある。
<InputTimeRange>
のインターフェース設計
<InputTimeRange>
とは開始時刻と終了時刻の2つの値をやり取りしたい訳だが、そのプロップの取り方として3種類を考える。すなわち
const timeRange = shallowRef({ startTime: '00:00', endTime: '00:00' });
// shallowRefはrefと大体同じ。詳細は後述。
<InputTimeRange v-model="timeRange" />
または
const startTime = ref('00:00');
const endTime = ref('00:00');
<InputTimeRange v-model:start-time="startTime" v-model:end-time="endTime" />
または
const timeRange = reactive({ startTime: '00:00', endTime: '00:00' });
<InputTimeRange :time-range="timeRange" />
の3種類である。結論から言うと一番上のパターンが最良だと考える。
まず2番目は受け取るプロップの数が増えると書くのが大変になるほか、入力によって全く異なる構造のオブジェクトを扱ったり、多段にネストされたオブジェクトを扱ったりする場合に対応しづらく、柔軟性に欠ける。他にも<InputTimeRange>
での問題としては、startTime
とendTime
を同時に書き換えることができないという点もある。これの何が問題かというと例えばユーザの入力によって時刻範囲が00:00-01:00から02:00-03:00に、途中の状態を挟まずに、書き換わったとしよう(Googleカレンダーのようにドラッグで入力するUIならあり得る)。このとき、startTime
がendTime
より先に書き換えられるとすると、一瞬時刻範囲が02:00-01:00という開始時刻が終了時刻より後の無効な値を示してしまう。<InputTimeRange>
は時刻の範囲を入力するのが責務であるから、一瞬でも時刻範囲として意味をなさない無効な状態を取るべきではない。このような挙動は思いもよらないところでバグを生む原因になる。
3番目はv-model
は使わずに、オブジェクトが参照渡しになることを使って、直接親から渡されたオブジェクトのプロパティを書き換える方法である。つまりこの場合の<InputTimeRange>
の実装はprops.modelValue.startTime = XXX;
のようになりイベントは発報しない。この参照渡しの問題点は、まず同一のオブジェクトが引き回されることによりオブジェクトの書き換えが行われる場所が認識しずらくなることである。Vueに限らず一般に参照で渡された変数を内部で暗黙的に書き換えて外部に影響を与えるのは悪手である。さらにオブジェクトの変更をwatch
するのにプロパティごとにwatch
するかオプション{ deep: true }
でディープウォッチする必要があり複雑性が増す。
1番目はこのいずれの問題も起きない。一つのオブジェクトになっているのでどんなデータ構造にも対応できるし、参照渡しによる暗黙の書き換えもない。そして変更が起こりうるのはtimeRange.value
それ自身だけで、その内部のネストされたプロパティが直接変更されることはないものとする。つまりオブジェクトを変更するとき<InputTimeRange>
の内部では
emit('update:modelValue', { startTime: '00:00', endTime: '01:00' });
// props.modelValue.startTime = '00:00'; // ←これはしない
<App>
では
timeRange.value = { startTime: '00:00', endTime: '01:00' };
// timeRange.value.startTime = '00:00'; // ←これはしない
このようにする。つまり、timeRange.value
の中身をイミュータブルオブジェクトとして扱うことで変更が起こりうる場所を限定し、複雑性を減らす。このような規約のインターフェースにすることによって実装を単純にできる。
<InputTimeRange>
の実装
上で決めたインターフェースになるように<InputTimeRange>
を実装する。以下にコードを示す。
<script lang="ts">
import { ref, computed, watch, type PropType } from 'vue';
type ModelValue = { startTime: string, endTime: string };
export default {
props: {
modelValue: { type: Object as PropType<ModelValue>, required: true },
invalid: { type: Boolean },
},
emits: {
'update:modelValue': (_: ModelValue) => true,
'update:invalid': (_: boolean) => true,
},
setup(props, { emit }) {
// ユーザが直接入力する入力値
const startTime = ref('');
const endTime = ref('');
let lastEmittedModelValue: ModelValue | null = null;
// 親コンポーネントからmodelValueが変更されたとき入力値に反映
watch(() => props.modelValue, () => {
if(props.modelValue === lastEmittedModelValue) return;
startTime.value = props.modelValue.startTime;
endTime.value = props.modelValue.endTime;
}, { immediate: true });
// 現在のユーザの入力値が無効かどうかを示す
const invalidity = computed(() => {
const ret: string[] = [];
if(!startTime.value) ret.push('starttime-empty');
if(!endTime.value) ret.push('endtime-empty');
if(startTime.value > endTime.value) ret.push('negativerange');
return ret;
});
watch(invalidity, () => {
emit('update:invalid', !!invalidity.value.length);
}, { immediate: true });
// 現在のユーザの入力値から計算した親コンポーネントに伝えるべきmodelValueの値を示す
// ただし現在の入力値が無効ならnull
const modelValue = computed(() => {
if(invalidity.value.length) return null;
return {
startTime: startTime.value, endTime: endTime.value,
};
});
// 上のmodelValueが変化したらイベントを発火し親コンポーネントに伝える
// ただし現在の入力値が無効なら何もしない
watch(modelValue, () => {
if(!modelValue.value) return;
lastEmittedModelValue = modelValue.value;
emit('update:modelValue', lastEmittedModelValue);
});
return { startTime, endTime };
},
}
</script>
<template>
<div>
<input type="time" v-model="startTime" />
<input type="time" v-model="endTime" />
</div>
</template>
<InputTimeRange>
のmodelValue
は常に有効な値しか示さない(例えば開始時刻が終了時刻よりも後ということはない)。これによりこのコンポーネントを使う側は例外的状況を考える必要がなくシンプルに扱える。入力値が無効なときは別のv-model
、invalid
により明示的にそのことが伝えられる。ここでは有効か無効かの2値しか伝えないようにしたが、使い方によっては、invalidity
の値をそのまま伝えて無効になっている原因まで使う側に知らせるということも可能。
<App>
の実装
この<InputTimeRange>
を使う側のコードは以下。
<script>
import { ref, shallowRef } from 'vue';
import InputTimeRange from './InputTimeRange.vue';
export default {
components: { InputTimeRange },
setup() {
const timeRange = shallowRef({ startTime: '00:00', endTime: '00:00' });
const inputTimeRangeInvalid = ref(false);
return { timeRange, inputTimeRangeInvalid };
},
}
</script>
<template>
<InputTimeRange
v-model="timeRange" v-model:invalid="inputTimeRangeInvalid"
/>
</template>
shallowRef
を使うことでtimeRange.value = XXX
はリアクティブになる(変更検知される)が、ネストされたtimeRange.value.startTime = XXX
はリアクティブにならなくなる。前述のように<InputTimeRange>
はprops.modelValue
自体の変化は検知するが、その内部のネストされたプロパティが変化することを想定していない。よって無用なリアクティビティ検知を省くためにshallowRef
を使っている。繰り返しになるが<App>
内からtimeRange
を書き換えるときも
timeRange.value = { startTime: '12:00', endTime: '13:00' };
// timeRange.value.startTime = '12:00'; // ←これははダメ!
のようにtimeRange.value
全体を置き換える書き方をする必要がある。
上記コードにはないがinputTimeRangeInvalid
は、true
の時に決定ボタンを押せないようにするなどの使い方をすることになる。
あとがき
本稿では<InputTimeRange>
を例として作ったが、もっと入力がたくさんある複雑な場合でも同じパターンで実装できるはずである。イミュータブルオブジェクトとして全体を書き換えるというのはReactのuseState
も同じ。Vueのリアクティブシステムはオブジェクトの内部のDeepな書き換えにも対応していてとても高性能なのだが、内部書き換えをするコードはかえって複雑になって、正しくリアクティブになるコードを書くのが難しくなるので、その機能は極力使うべきではないというのが私の経験則。