🗄️

【Vue3.4 目玉機能】`const model = defineModel()` で双方向バインディング

2024/04/30に公開

defineModelとは

Vue3.4のリリースでStableとなった新機能です。リリースから時間が経ち、慣れてきたので紹介します。
以前は definePropsdefineEmits を組み合わせて双方向バインディングを行う際に、コードが複雑になりがちでしたが、 defineModel の導入により、コードがシンプルになりました。

公式ドキュメント抜粋
// 親から v-model 経由で使用される、"modelValue" props を宣言する
const model = defineModel()
// もしくは: オプション付きで "modelValue" props を宣言する
const model = defineModel({ type: String })

// 変更された時に "update:modelValue" イベントを発行
model.value = 'hello'

// 親から v-model:count 経由で使用される、"count" props を宣言する
const count = defineModel('count')
// もしくは: オプション付きで "count" props を宣言する
const count = defineModel('count', { type: Number, default: 0 })

function inc() {
  // 変更された時に "update:count" イベントを発行
  count.value++
}

https://ja.vuejs.org/api/sfc-script-setup#definemodel

ナビゲーションドロワーを題材にして考える

VuetifyのVNavigationDrawerを使用すると、 v-model を用いてメニューの開閉を制御できます。ナビゲーションドロワーのメニュー開閉動作は、バインドされる値を視覚的に想像しやすく、 defineModel を理解するのに適しています。
加えて、メニュー開閉の描画ロジックは本質ではなく、Vuetifyに任せることができるという事情も存在します。

実装のゴール

また、親子関係をわかりやすく記述するために、コンポーネントを3つに分割して紹介します。

①呼び出し元コンポーネント

まずは親コンポーネント。
後述する「②ヘッダーコンポーネント」と「③ナビゲーションドロワーコンポーネント」を呼び出しているコンポーネントです。

今回の例では、コード的に共通レイアウトコンポーネントと考えるとわかりやすいかもしれないです。

<script setup lang="ts">
const isOpen = ref(true)
</script>

<template>
  <v-app>
    <!-- ②ヘッダーコンポーネント -->
    <header-component v-model="isOpen" />

    <!-- ③ナビゲーションドロワーコンポーネント -->
    <navigation-drawer-component v-model="isOpen" />

    <v-main>
      コンテンツが入る
    </v-main>
  </v-app>
</template>

呼び出し元でのポイントはメニューの開閉を司る isOpen を定義することです。
Vueにおける双方向バインディングの基礎的な話になりますが、すなわち操作したい値は呼び出し元で定義するということです。

加えて、「②ヘッダーコンポーネント」と「③ナビゲーションドロワーコンポーネント」それぞれに isOpen を渡しています。

②ヘッダーコンポーネント

次はヘッダーコンポーネントです。
ヘッダーがメニューの開閉を操作するトグルボタンを持っています。

青枠で囲まれた箇所がヘッダーコンポーネント

headerComponent.vue
<script setup lang="ts">
const isOpen = defineModel({ type: Boolean })
</script>

<template>
  <v-app-bar>
    <template #prepend>
      <v-app-bar-nav-icon @click.stop="isOpen = !isOpen" />
    </template>
  </v-app-bar>
</template>

トグルボタンがクリックされたときに子コンポーネント(②ヘッダーコンポーネント)から親コンポーネント(①呼び出し元コンポーネント)の isOpen の値を操作しています。

かつてはemit記法で操作を実現していたのが、下記のような操作になりました。

  1. defineModelでバインド変数を取り出す
  2. 好きに更新

③ナビゲーションドロワーコンポーネント

最後が、ナビゲーションドロワーです。
ヘッダーにあるトグルボタンの操作によってメニューが開閉します。

青枠で囲まれた箇所がナビゲーションドロワーコンポーネント

navigationDrawerComponent.vue
<script setup lang="ts">
const isOpen = defineModel({ type: Boolean })
</script>

<template>
  <v-navigation-drawer v-model="isOpen">
    <v-list>
      <v-list-item
        title="アカウント設定"
        to="/account"
      />
    </v-list>
  </v-navigation-drawer>
</template>

孫コンポーネント(Vuetifyの VNavigationDrawer)に isOpen を渡しています。
あとは、 VNavigationDrawer がメニュー開閉の描画ロジックを担当してくれます。

defineModelの立ち位置

親、子、孫…とコンポーネントの世代が続いていくと、値をバケツリレーすることになります。
親の値が子孫に渡るまで、 const model = defineModel() を書き続けなければなりません。
また、値が子孫に渡るまでに中間層で値を書き換えることができてしまいます(が、定義時に readonly をつけることで回避可能)。

各マクロ、APIの特徴をまとめると下記の表のようになります。

特徴 defineModel defineProps/defineEmit Provide/Inject
バケツリレーの有無
子が状態を持つか
書きやすさ(筆者主観)

トレードオフですが、子孫が遠くなったら Provide/Inject を使い、基本的には const model = defineModel()readonly にする運用がよくなされるだろうなという気がしています。

まとめ

  • 親コンポーネントの責務
    • 双方向バインディング変数を単に定義する
    • v-model で双方向バインディング変数を子コンポーネントに渡す
  • 子コンポーネントの責務
    • const model = defineModel()で双方向バインディング変数を捕捉
    • 好きに読んだり更新したりする

Vueの双方向バインディングのコードはずっとややこしい印象でしたが、この度のリリースでかなりシンプルになったと感じています。
Vue3.4のリリースにはほかにも v-bind の同名ショートハンドなど魅力的なものがあるのでチェックしてみてください。
https://blog.vuejs.org/posts/vue-3-4

Discussion