メインコンテンツまでスキップ

コンポーネント間の v-model — defineModel で双方向を組む

4 章では v-model<input> に付けました。同じディレクティブを自作コンポーネントにも付けられます。

ただし展開先が違います。ネイティブ要素なら DOM のプロパティとイベントに展開されますが、コンポーネントでは 6 章で扱った props と emit の組になります。この章では、その組を defineModel でまとめる書き方と、値が実際にどこに置かれているかを見ていきます。

この章で学ぶこと

  • コンポーネントの v-model が何に展開されるか
  • defineModel が生成する props と emit を数えられる
  • 値の持ち主が親と子のどちらなのか、条件込みで説明できる
  • 1 つのコンポーネントに複数の v-model を持たせられる
  • 修飾子のうち自分で実装しなくてよいものを区別できる

v-model は props と emit の組

親が書く v-model は、prop とイベントハンドラの組に展開されます。

<!-- 親が書くもの -->
<TextField v-model="displayName" />

<!-- 展開後 -->
<TextField
:modelValue="displayName"
@update:modelValue="$event => (displayName = $event)"
/>

つまり子から見れば、modelValue という prop を受け取り update:modelValue を emit する形です。Vue 3.4 より前は、子もこの 2 つを手で宣言していました。

components/LegacyTextField.vue
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

const onInput = (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>

<template>
<input :value="props.modelValue" @input="onInput" />
</template>

動きますが、名前を 2 か所で揃える手間と、input イベントから値を取り出す定型が残ります。

defineModel でまとめる

3.4 以降は defineModel を使います。公式ガイドもこちらを推奨としています。

components/TextField.vue
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>

<template>
<input v-model="model" />
</template>

返ってくるのは ref です。読めば親が束ねた値が取れ、書けば親へ通知が飛びます。ref なので <input>v-model にそのまま渡せます。前掲の LegacyTextField.vue と同じ受け渡しを、宣言 1 行で済ませています。

親側は変わりません。ref をそのまま束ねます。

components/ProfileEditor.vue
<template>
<TextField v-model="displayName" />
<p>{{ displayName }}</p>
</template>

コンパイル後に何が生えるか

defineModeldefineProps / defineEmits と同じコンパイラマクロです。実行時に呼ばれる関数ではなく、<script setup> の処理時に書き換えられます。上の TextField.vue は次になります (開発ビルドの出力から抜粋)。

props: {
"modelValue": { type: String, ...{ required: true } },
"modelModifiers": {},
},
emits: ["update:modelValue"],
setup(__props, { expose: __expose }) {
const model = _useModel<string>(__props, "modelValue")

数えると prop が 2 つ、emit が 1 つです。modelValue に加えて modelModifiers が必ず宣言されるのは、後で扱う修飾子を受け取るためです。呼ぶたびに 2 つずつ増えます。

ただし生成される名前が重なると prop が減ります。 既定の model の修飾子 prop だけ modelValueModifiers ではなく modelModifiers になります。そのため defineModel()defineModel('model') を並べると修飾子 prop が重なります。defineModel('a')defineModel('aModifiers') では、後者の model 名が前者の修飾子 prop 名と重なります。どちらも prop は 3 つです。

required: trueuseModel の引数から消えて props 宣言へ移っているのに注意してください。prop のオプションはコンパイル時に props 宣言へ移され、後述する get / set だけが useModel の呼び出しに残ります。

隣に並んでいる type: String は移されたものではなく、型引数から推論して足されたものです。開発ビルドの prop チェック用なので、本番ビルドでは "modelValue": { required: true } になります。

ただし Boolean を含む型は本番でも残ります。 値が渡らなかったときに false にするか、""true と読むかの判定に型が要るからです。オプションを渡した Function 型も残ります。関数の default を「呼ぶ値」と読むか「そのまま使う値」と読むかが型で決まるので、オプションが付いていれば型を残す判断になっています。どの場合も prop の数と emit は変わりません。

この振り分けは、オプションをオブジェクトリテラルで直接書いた場合の話です。{ ...opts } のように spread を含めると分解が走らず、渡したものが props 宣言と useModel の呼び出しの両方に出ます。

opts<script setup> の中で宣言していると、この形は動きません。 props 宣言は setup() の外に置かれるので、そこから <script setup> のローカル変数を参照する出力になり、評価した時点で ReferenceError になります。import した値や通常の <script> ブロックで宣言した値なら module スコープにあるので動きますが、オプションは直接書くのが確実です。

型引数だけを渡した場合、親が prop を渡さない可能性があるので値に undefined が混じります。required: truedefault を付けると外れます。

const model = defineModel<string>()
// ^? Ref<string | undefined>

const title = defineModel<string>('title', { required: true })
// ^? Ref<string>

値はどこにあるか

useModel が返すのは customRef です。値は関数のクロージャに localValue として置かれ、prop の変化は同期の watcher が流し込みます。

watchSyncEffect(() => {
const propValue = props[camelizedName]
if (hasChanged(localValue, propValue)) {
localValue = propValue
trigger()
}
})

書き込み側は、親が v-model を束ねているかどうかで経路が分かれます。判定は「prop とハンドラの両方が渡ってきているか」です。

// 表記のゆれを許して、prop 名とハンドラ名の両方があるかを見る
const hasVModel = !!(rawProps && (name in rawProps || /* ... */) &&
(`onUpdate:${name}` in rawProps || /* ... */))
if (!hasVModel) {
localValue = value
trigger()
}
i.emit(`update:${name}`, emittedValue)

両方あれば localValue には直接書きません。emit した値が親を通って prop として戻ってきたときに、上の watcher が反映します。つまりこの場合の持ち主は親です。

親が値を受け取らなかったとき

持ち主が親だということは、親が値を弾けば子の model も動かないという意味です。

components/GuardedEditor.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import TextField from './TextField.vue'

const stored = ref('a')
const guarded = computed({
get: () => stored.value,
// 3 文字を超える入力は受け取らない
set: (value: string) => {
if (value.length <= 3) stored.value = value
},
})
</script>

<template>
<TextField v-model="guarded" />
</template>

abcd と打つと、stored も子の modela のままです。ただし入力欄の表示は abcd のまま残ります。 model が動いていないので子の再描画が起きず、value の書き戻しも起きないためです。

表示が戻るのは次に子が再描画されたときです。親が受け取れる値を打てば揃いますが、無関係な prop が動いただけでも戻りますv-model の実装は更新の直前に el.value を書き直すので、打った文字が別の理由の再描画で消えます。子の入力欄が既定の v-model なら、IME で変換している間はこの書き戻しが止まるので、日本語入力の途中では戻りません。

入力の検証を親の setter で弾く形にすると、この食い違いを踏みます。弾くのではなく受け取ってから検証し、エラーを別に表示する形のほうが素直です。

親が v-model を付けなかったとき

prop とハンドラの片方でも欠けていれば localValue へ直接書き込みます。だから v-model を付けずに置いたコンポーネントは、内部で状態を持つ普通のコンポーネントとして動きます

<TextField model-value="初期値" /> と置いても入力でき、値は子の中に留まります。ただし required: true を付けた子から prop まで省くと、開発ビルドで Missing required prop の警告が出て modelundefined になります。局所値として使う余地を残すなら required を外します。

ハンドラだけ渡した場合は emit も届きつつ局所値も動きます。prop だけ渡した場合は親の状態が動かないので、子の表示が prop から離れていきます。その後で親が prop を変えれば、watcher が働いて子は追従します。

default を渡すと親子がずれる

default は便利に見えますが、親が値を渡さないときに親子で違う値を持つことになります。

<script setup lang="ts">
const model = defineModel<string>({ default: '既定' })
</script>

親の状態が undefined のまま、子の model既定 になります。公式ガイドもこれを警告として挙げています。初期値は親側で持つほうが安全です。

名前を付けて複数持たせる

defineModel の第 1 引数に文字列を渡すと、prop 名とイベント名がその名前になります。呼び分ければ 1 つのコンポーネントに複数の v-model を置けます。

components/NameFields.vue
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>

<template>
<input v-model="lastName" />
<input v-model="firstName" />
</template>

宣言されるのは firstName / firstNameModifiers / lastName / lastNameModifiers の 4 つの prop と、update:firstName / update:lastName の 2 つの emit です。

親は 2 つの ref を用意して、引数付きの v-model で束ねます。

components/NameForm.vue
<template>
<NameFields v-model:first-name="first" v-model:last-name="last" />
</template>

同じ名前で 2 回 defineModel を呼ぶと duplicate model name でコンパイルが止まります。

引数の表記は片方だけ変換される

defineModel の引数を camelCase で書いた場合、親側の引数は kebab-case でも camelCase でも子に届きます。ただし届き方は一様ではありません。v-model:first-name.capitalize の展開を見ると、変換されるものとされないものが混ざっています。

{
"first-name": _ctx.first, // prop 名は書いたまま
"onUpdate:firstName": /* ... */, // イベント名は camelize される
"first-nameModifiers": { capitalize: true } // 修飾子は書いたまま + Modifiers
}

first-nameModifiers という混ざった名前が生まれるのはこのためです。表記のずれを吸収するのは受け側ですが、値と修飾子で見ている props が違います

値の prop は解決の段で camelCase へ寄せてから子の宣言と突き合わされます。一方 .trim / .number を適用する emit は、親が渡したままの生の props を見ますfirst-nameModifiers のようなキーがそこに残っているので、emit の側で名前を camel と kebab の両方試して拾います。この経路は defineModel を通りません — definePropsdefineEmits だけで書いた子でも v-model:first-name.trim が効くのはこのためです。

defineModel の返り値から modifiers を読む場合は解決済みの props を見るので、camelCase 側でそのまま当たります。

どちらでも動くので、属性は kebab-case、defineModel の引数は camelCase で書けば十分です。公式ガイドもこの組み合わせで書いています。

修飾子を受け取る

親が v-model.capitalize のような修飾子を付けると、<名前>Modifiers prop に印が渡ります。返り値を分割代入すると 2 番目で受け取れます。

const [model, modifiers] = defineModel<string, 'capitalize'>()

if (modifiers.capitalize) {
// ...
}

ref を分割代入できるのは、useModel の返り値に Symbol.iterator が生えているからです。1 番目が ref 自身、2 番目が修飾子のオブジェクトを返します。型のほうも Ref と 2 要素タプルの交差型になっているので、どちらの使い方も通ります。修飾子が付いていないときは空オブジェクトです。

自分で実装しなくてよいもの

.trim.number は子側に何も書かなくても効きます。 emit の内部で update: から始まるイベントを見つけると、渡ってきた修飾子を読んで引数を加工するからです。

const isModelListener = event.startsWith("update:")
const modifiers = isModelListener && getModelModifiers(props, event.slice(7))
if (modifiers) {
if (modifiers.trim) {
args = rawArgs.map((a) => isString(a) ? a.trim() : a)
}
if (modifiers.number) {
args = rawArgs.map(looseToNumber)
}
}

この層は defineModel を使っているかに関係ありません。前掲の LegacyTextField.vue のように defineEmits で書いた子でも、親が v-model.trim と書けば trim されます。ただし modelModifiers を宣言していない子では、この prop が属性として素通りします。単一の要素をルートに持ち inheritAttrs を既定のままにしている子なら、modelmodifiers="[object Object]" がその要素に付きます (行き先の決まり方は 6 章の属性の引き継ぎと同じです)。

ここにあるのは trimnumber の 2 分岐だけです。.lazy を含む他の修飾子は印が渡るだけなので、必要なら子が読んで実装します。コンポーネントに v-model.lazy を付けても更新は遅れません。

この 2 つを並べると .trim の結果が捨てられます。 .number の分岐が加工済みの args ではなく元の引数から作り直すためです。v-model.trim.number' abc ' を入れると、looseToNumber が数値として読めずに元の文字列を返すので、親には空白の付いた ' abc ' が届きます。ネイティブ要素では順に適用されるので 'abc' になります。差が出るのは数値として読めない入力のときだけで、' 42 ' はどちらも 42 になります。

set で値を変換する

get / set オプションを渡すと、読み書きの際に値を変換できます。get は子が読む値だけを変え、親の状態には触りません。set は書き込みを変換します。全角の数字を半角へ寄せる修飾子はこう書けます。

components/PostalCodeField.vue
<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'halfwidth'>({
required: true,
set(value) {
return modifiers.halfwidth ? toHalfWidth(value) : value
},
})

const toHalfWidth = (value: string) =>
value.replace(/[-]/g, (char) =>
String.fromCharCode(char.charCodeAt(0) - 0xfee0),
)
</script>

<template>
<input v-model="model" inputmode="numeric" />
</template>

v-model.halfwidth="postalCode" と書いた親には変換後の値が届き、prop として戻ってくるので入力欄の表示も半角になります。

set の中から modifiers を参照できるのは、get / set が props 宣言へ移されず useModel の呼び出しに残るからです。set が呼ばれるのは書き込みの時点なので、そこでは modifiers が束縛済みです。

変換がかかるのは emit する値です。親が v-model を束ねていない場合、局所値へ書き戻る経路には変換前の値が入ります。.trim / .number も同じで、この経路には乗りません。 子だけで完結させる使い方では、修飾子も set も当てになりません。

まとめ

  • コンポーネントの v-modelprop とイベントハンドラの組に展開されます。既定の名前は modelValueupdate:modelValue です
  • defineModel は 3.4 以降で使えるマクロです。返るのは ref で、読めば親の値、書けば親への通知になります
  • 生成されるのは prop 2 つと emit 1 つ。model の prop と、その修飾子を受け取る prop が宣言されます。修飾子 prop の名前は <名前>Modifiers ですが、既定の model だけ modelModifiers です。生成される名前が重なると prop が減ります
  • オプションをオブジェクトリテラルで書いた場合、prop のオプションは props 宣言へ移り get / set だけが useModel の呼び出しに残ります。spread を含めるとこの分解が走りません
  • 型引数だけでは Ref<T | undefined> です。required: truedefault を付けると Ref<T> になります
  • 値の持ち主は、親が prop とハンドラの両方を渡しているかで変わります。 両方あれば親が持ち主で、子の書き込みは親を経由して戻ってきます。片方でも欠けていれば子の局所値として振る舞い、この経路には修飾子の加工も set の変換も乗りません
  • 親が値を弾くと子の model も動きません。再描画が起きないので入力欄の表示だけが残り、次に子が再描画された時点で親の値へ戻ります (既定の v-model なら IME の変換中は書き戻しが止まります)
  • default を渡して親が値を渡さないと、親子で違う値を持ちます
  • defineModel の引数を camelCase で書けば、親側は kebab-case でも camelCase でも届きます。prop 名と修飾子名は書いたまま渡り、イベント名だけ camelize されます
  • 修飾子は返り値の分割代入で受け取ります。.trim.numberemit の側で適用されるので子側の実装が要りません。.lazy を含む他の修飾子は印が渡るだけです
  • .trim.number を並べると .trim の結果が捨てられます。 空白が付いていて数値として読めない入力では空白が残るので、ネイティブ要素に同じ書き方をしたときと結果が違います
関連リファレンス

次に読む