1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

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アプリケーションを構築してください!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?