🔗

Vue3のv-modelを調べてみた

に公開

はじめに

Vue3のv-modelは、フォーム要素とコンポーネント間で双方向データバインディングを実現するための強力なディレクティブです。この記事では、v-modelの基本概念から、カスタムコンポーネントでの実装方法、そして実用的なパターンまで詳しく解説します。

この記事で学べること

  • v-modelの基本概念と仕組み
  • フォーム要素でのv-modelの使い方
  • カスタムコンポーネントでのv-model実装
  • 複数のv-modelの活用方法
  • 実用的なパターンとベストプラクティス

v-modelとは?

v-modelは、Vue.jsが提供する双方向データバインディングのためのディレクティブです。フォーム要素の値とコンポーネントのデータを自動的に同期させることができます。

基本的な仕組み

v-modelは以下の糖衣構文(シンタックスシュガー)です:

<!-- v-modelの糖衣構文 -->
<input v-model="message" />

<!-- 実際の展開形 -->
<input 
  :value="message" 
  @input="message = $event.target.value" 
/>

つまり、v-modelは:

  1. :valueでデータを要素に渡す(データ → 要素)
  2. @inputで要素の変更をデータに反映する(要素 → データ)

フォーム要素でのv-model

基本的な使い方

<script setup>
import { ref } from 'vue'

const message = ref('')
const email = ref('')
const age = ref(0)
const isChecked = ref(false)
const selectedOption = ref('')
</script>

<template>
  <div>
    <!-- テキスト入力 -->
    <input v-model="message" type="text" placeholder="メッセージを入力" />
    <p>入力内容: {{ message }}</p>
    
    <!-- メール入力 -->
    <input v-model="email" type="email" placeholder="メールアドレス" />
    
    <!-- 数値入力 -->
    <input v-model.number="age" type="number" placeholder="年齢" />
    
    <!-- チェックボックス -->
    <input v-model="isChecked" type="checkbox" id="checkbox" />
    <label for="checkbox">同意する</label>
    
    <!-- セレクトボックス -->
    <select v-model="selectedOption">
      <option value="">選択してください</option>
      <option value="option1">オプション1</option>
      <option value="option2">オプション2</option>
    </select>
  </div>
</template>

修飾子の活用

v-modelには便利な修飾子が用意されています:

<script setup>
import { ref } from 'vue'

const message = ref('')
const lazyMessage = ref('')
const numberValue = ref(0)
const trimmedText = ref('')
</script>

<template>
  <div>
    <!-- .lazy: changeイベントで更新(デフォルトはinputイベント) -->
    <input v-model.lazy="lazyMessage" placeholder="フォーカスアウト時に更新" />
    
    <!-- .number: 文字列を数値に変換 -->
    <input v-model.number="numberValue" type="number" />
    
    <!-- .trim: 前後の空白を削除 -->
    <input v-model.trim="trimmedText" placeholder="空白は自動削除" />
    
    <!-- 複数の修飾子を組み合わせ -->
    <input v-model.lazy.trim="message" placeholder="遅延更新+空白削除" />
  </div>
</template>

カスタムコンポーネントでのv-model

カスタムコンポーネントでv-modelを使用するには、defineModelマクロまたはpropsemitを組み合わせて実装します。

defineModelマクロを使用(Vue 3.4+)

components/CustomInput.vue
<script setup>
// defineModelマクロでv-modelを定義
const modelValue = defineModel()

// カスタムロジックを追加
const handleInput = (event) => {
  // 入力値を大文字に変換
  const upperValue = event.target.value.toUpperCase()
  modelValue.value = upperValue
}
</script>

<template>
  <div class="custom-input">
    <label>カスタム入力:</label>
    <input 
      :value="modelValue" 
      @input="handleInput"
      class="input-field"
    />
    <p class="preview">プレビュー: {{ modelValue }}</p>
  </div>
</template>

<style scoped>
.custom-input {
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  margin: 8px 0;
}

.input-field {
  width: 100%;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 16px;
}

.preview {
  margin-top: 8px;
  color: #666;
  font-style: italic;
}
</style>

propsとemitを使用(従来の方法)

components/CustomInput.vue
<script setup>
// propsの定義
const props = defineProps<{
  modelValue: string
}>()

// emitの定義
const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

// 入力処理
const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  const value = target.value.toUpperCase()
  emit('update:modelValue', value)
}
</script>

<template>
  <div class="custom-input">
    <label>カスタム入力:</label>
    <input 
      :value="props.modelValue" 
      @input="handleInput"
      class="input-field"
    />
    <p class="preview">プレビュー: {{ props.modelValue }}</p>
  </div>
</template>

親コンポーネントでの使用

components/ParentComponent.vue
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const inputValue = ref('')
</script>

<template>
  <div>
    <h2>カスタムコンポーネントのv-model</h2>
    
    <!-- カスタムコンポーネントにv-modelを適用 -->
    <CustomInput v-model="inputValue" />
    
    <p>親コンポーネントの値: {{ inputValue }}</p>
  </div>
</template>

複数のv-model

Vue 3.3以降では、1つのコンポーネントで複数のv-modelを使用できます。

複数v-modelの実装

components/UserForm.vue
<script setup>
// 複数のv-modelを定義
const name = defineModel('name', { default: '' })
const email = defineModel('email', { default: '' })
const age = defineModel('age', { default: 0 })

// バリデーション関数
const validateForm = () => {
  const errors = []
  
  if (!name.value.trim()) {
    errors.push('名前は必須です')
  }
  
  if (!email.value.includes('@')) {
    errors.push('有効なメールアドレスを入力してください')
  }
  
  if (age.value < 0) {
    errors.push('年齢は0以上である必要があります')
  }
  
  return errors
}

// フォーム送信処理
const handleSubmit = () => {
  const errors = validateForm()
  if (errors.length === 0) {
    console.log('フォーム送信:', {
      name: name.value,
      email: email.value,
      age: age.value
    })
  } else {
    console.error('バリデーションエラー:', errors)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="user-form">
    <div class="form-group">
      <label for="name">名前:</label>
      <input 
        id="name"
        v-model="name" 
        type="text" 
        placeholder="お名前を入力"
        class="form-input"
      />
    </div>
    
    <div class="form-group">
      <label for="email">メールアドレス:</label>
      <input 
        id="email"
        v-model="email" 
        type="email" 
        placeholder="メールアドレスを入力"
        class="form-input"
      />
    </div>
    
    <div class="form-group">
      <label for="age">年齢:</label>
      <input 
        id="age"
        v-model.number="age" 
        type="number" 
        min="0"
        class="form-input"
      />
    </div>
    
    <button type="submit" class="submit-button">
      送信
    </button>
  </form>
</template>

<style scoped>
.user-form {
  max-width: 400px;
  margin: 0 auto;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.form-group {
  margin-bottom: 16px;
}

.form-group label {
  display: block;
  margin-bottom: 4px;
  font-weight: bold;
}

.form-input {
  width: 100%;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 16px;
}

.submit-button {
  width: 100%;
  padding: 12px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
}

.submit-button:hover {
  background-color: #0056b3;
}
</style>

親コンポーネントでの複数v-model使用

components/ParentComponent.vue
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const userName = ref('')
const userEmail = ref('')
const userAge = ref(0)
</script>

<template>
  <div>
    <h2>複数v-modelの例</h2>
    
    <!-- 複数のv-modelを指定 -->
    <UserForm 
      v-model:name="userName"
      v-model:email="userEmail"
      v-model:age="userAge"
    />
    
    <div class="preview">
      <h3>入力内容のプレビュー:</h3>
      <p>名前: {{ userName }}</p>
      <p>メール: {{ userEmail }}</p>
      <p>年齢: {{ userAge }}</p>
    </div>
  </div>
</template>

<style scoped>
.preview {
  margin-top: 20px;
  padding: 16px;
  background-color: #f8f9fa;
  border-radius: 8px;
}
</style>

実用的なパターン

1. 検索コンポーネント

components/SearchBox.vue
<script setup>
import { ref, watch } from 'vue'

const searchQuery = defineModel('query', { default: '' })
const isSearching = ref(false)

// 検索クエリの変更を監視
watch(searchQuery, (newQuery) => {
  if (newQuery.length > 2) {
    isSearching.value = true
    // 実際の検索処理をここに実装
    setTimeout(() => {
      isSearching.value = false
    }, 500)
  }
})

const clearSearch = () => {
  searchQuery.value = ''
}
</script>

<template>
  <div class="search-box">
    <div class="search-input-container">
      <input 
        v-model="searchQuery"
        type="text"
        placeholder="検索..."
        class="search-input"
      />
      <button 
        v-if="searchQuery"
        @click="clearSearch"
        class="clear-button"
      >
        ×
      </button>
    </div>
    
    <div v-if="isSearching" class="searching-indicator">
      検索中...
    </div>
  </div>
</template>

<style scoped>
.search-box {
  position: relative;
}

.search-input-container {
  position: relative;
  display: flex;
  align-items: center;
}

.search-input {
  width: 100%;
  padding: 12px 40px 12px 12px;
  border: 2px solid #e0e0e0;
  border-radius: 25px;
  font-size: 16px;
  outline: none;
  transition: border-color 0.3s;
}

.search-input:focus {
  border-color: #007bff;
}

.clear-button {
  position: absolute;
  right: 12px;
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
}

.clear-button:hover {
  color: #333;
}

.searching-indicator {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  padding: 8px;
  background-color: #f8f9fa;
  border: 1px solid #e0e0e0;
  border-top: none;
  border-radius: 0 0 8px 8px;
  text-align: center;
  color: #666;
}
</style>

2. スライダーコンポーネント

components/CustomSlider.vue
<script setup>
import { ref, watch } from 'vue'

const props = defineProps<{
  modelValue: number
  min?: number
  max?: number
  step?: number
  label?: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: number]
}>()

const sliderValue = ref(props.modelValue)

// スライダーの値が変更されたときの処理
const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  const value = Number(target.value)
  sliderValue.value = value
  emit('update:modelValue', value)
}

// 外部からの値の変更を監視
watch(() => props.modelValue, (newValue) => {
  sliderValue.value = newValue
})
</script>

<template>
  <div class="slider-container">
    <label v-if="label" class="slider-label">
      {{ label }}: {{ sliderValue }}
    </label>
    
    <div class="slider-wrapper">
      <input 
        type="range"
        :min="min || 0"
        :max="max || 100"
        :step="step || 1"
        :value="sliderValue"
        @input="handleInput"
        class="slider"
      />
      
      <div class="slider-values">
        <span>{{ min || 0 }}</span>
        <span>{{ max || 100 }}</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
.slider-container {
  margin: 16px 0;
}

.slider-label {
  display: block;
  margin-bottom: 8px;
  font-weight: bold;
  color: #333;
}

.slider-wrapper {
  position: relative;
}

.slider {
  width: 100%;
  height: 6px;
  border-radius: 3px;
  background: #ddd;
  outline: none;
  -webkit-appearance: none;
}

.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #007bff;
  cursor: pointer;
}

.slider::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #007bff;
  cursor: pointer;
  border: none;
}

.slider-values {
  display: flex;
  justify-content: space-between;
  margin-top: 4px;
  font-size: 12px;
  color: #666;
}
</style>

3. トグルスイッチコンポーネント

components/ToggleSwitch.vue
<script setup>
import { ref, watch } from 'vue'

const props = defineProps<{
  modelValue: boolean
  label?: string
  disabled?: boolean
}>()

const emit = defineEmits<{
  'update:modelValue': [value: boolean]
}>()

const isOn = ref(props.modelValue)

const toggle = () => {
  if (props.disabled) return
  
  isOn.value = !isOn.value
  emit('update:modelValue', isOn.value)
}

// 外部からの値の変更を監視
watch(() => props.modelValue, (newValue) => {
  isOn.value = newValue
})
</script>

<template>
  <div class="toggle-container">
    <label v-if="label" class="toggle-label">
      {{ label }}
    </label>
    
    <div 
      class="toggle-switch"
      :class="{ 
        'toggle-on': isOn, 
        'toggle-disabled': disabled 
      }"
      @click="toggle"
    >
      <div class="toggle-thumb"></div>
    </div>
  </div>
</template>

<style scoped>
.toggle-container {
  display: flex;
  align-items: center;
  gap: 12px;
}

.toggle-label {
  font-weight: 500;
  color: #333;
}

.toggle-switch {
  position: relative;
  width: 50px;
  height: 24px;
  background-color: #ccc;
  border-radius: 12px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.toggle-switch.toggle-on {
  background-color: #007bff;
}

.toggle-switch.toggle-disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toggle-thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  background-color: white;
  border-radius: 50%;
  transition: transform 0.3s;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.toggle-on .toggle-thumb {
  transform: translateX(26px);
}
</style>

ベストプラクティス

1. 型安全性の確保

// 良い例:厳密な型定義
interface UserData {
  name: string
  email: string
  age: number
}

const userData = defineModel<UserData>('userData', {
  default: () => ({
    name: '',
    email: '',
    age: 0
  })
})

// 悪い例:any型の使用
const userData = defineModel<any>('userData')

2. バリデーションの実装

<script setup>
import { computed } from 'vue'

const email = defineModel('email', { default: '' })

// リアクティブなバリデーション
const emailError = computed(() => {
  if (!email.value) return 'メールアドレスは必須です'
  if (!email.value.includes('@')) return '有効なメールアドレスを入力してください'
  return ''
})

const isValid = computed(() => emailError.value === '')
</script>

<template>
  <div>
    <input 
      v-model="email"
      type="email"
      :class="{ 'error': emailError }"
    />
    <p v-if="emailError" class="error-message">
      {{ emailError }}
    </p>
  </div>
</template>

3. パフォーマンスの最適化

<script setup>
import { ref, watch, nextTick } from 'vue'

const searchQuery = defineModel('query', { default: '' })
const searchResults = ref([])
const isSearching = ref(false)

// デバウンス処理
let searchTimeout: NodeJS.Timeout

watch(searchQuery, async (newQuery) => {
  if (searchTimeout) {
    clearTimeout(searchTimeout)
  }
  
  if (newQuery.length < 2) {
    searchResults.value = []
    return
  }
  
  searchTimeout = setTimeout(async () => {
    isSearching.value = true
    
    try {
      // 実際の検索処理
      const results = await performSearch(newQuery)
      searchResults.value = results
    } catch (error) {
      console.error('検索エラー:', error)
    } finally {
      isSearching.value = false
    }
  }, 300) // 300ms後に実行
})
</script>

よくある問題と解決方法

1. v-modelが更新されない

<!-- 問題:オブジェクトのプロパティを直接変更 -->
<script setup>
const user = ref({ name: '', email: '' })
</script>

<template>
  <!-- これは動作しない -->
  <input v-model="user.name" />
</template>

<!-- 解決方法:オブジェクト全体を更新 -->
<script setup>
const user = ref({ name: '', email: '' })

const updateName = (newName: string) => {
  user.value = { ...user.value, name: newName }
}
</script>

<template>
  <input :value="user.name" @input="updateName($event.target.value)" />
</template>

2. カスタムコンポーネントでv-modelが動作しない

<!-- 問題:emitの名前が間違っている -->
<script setup>
const emit = defineEmits<{
  'input': [value: string] // 間違い
}>()
</script>

<!-- 解決方法:正しいemit名を使用 -->
<script setup>
const emit = defineEmits<{
  'update:modelValue': [value: string] // 正しい
}>()
</script>

3. 数値の型変換

<!-- 問題:文字列として扱われる -->
<input v-model="age" type="number" />

<!-- 解決方法:.number修飾子を使用 -->
<input v-model.number="age" type="number" />

まとめ

Vue3のv-modelは、双方向データバインディングを実現するための強力な機能です。適切に使用することで、フォーム処理やコンポーネント間の通信を効率的に実装できます。

重要なポイント

  1. 基本概念: v-model:value@inputの糖衣構文
  2. カスタムコンポーネント: defineModelマクロまたはpropsemitで実装
  3. 複数v-model: Vue 3.3以降で複数のv-modelを同時に使用可能
  4. 型安全性: TypeScriptを使用して厳密な型定義を行う
  5. パフォーマンス: デバウンスや適切なイベント処理で最適化

今後の学習

  • Vue3のComposition APIの詳細
  • 状態管理ライブラリ(Pinia)との連携
  • フォームバリデーションライブラリの活用
  • カスタムディレクティブの作成

この記事で学んだ内容を活用して、より効率的で保守性の高いVue3アプリケーションを構築してください!

GitHubで編集を提案

Discussion