Vue 3 Script Setup と defineModel 2026年版:モダン構文とインタビュー対策

Vue 3のscript setup構文とdefineModelによる双方向バインディングを徹底解説。リアクティブprops分割代入、TypeScriptパターン、Vueインタビュー頻出質問をカバーします。

Vue 3 Script Setup と defineModel の構文図解

Vue 3のscript setup構文は、ボイラープレートを排除しTypeScript統合を向上させることで、コンポーネントの記述方法を根本的に変革しました。Vue 3.3で実験的に導入され、Vue 3.4で安定版となったdefineModelと組み合わせることで、双方向データバインディングを持つコンポーネントの構築に必要なコード量は、Options APIと比較して大幅に削減されます。

Vue 3.5 リアクティブProps分割代入

Vue 3.5以降、definePropsからpropsを直接分割代入してもリアクティビティが保持されます。これにより、ほとんどのケースでtoRefsが不要になります。

Vue 3.5におけるScript Setupの基礎

<script setup>構文は、Single File Component内でComposition APIを使用するためのコンパイル時シンタックスシュガーです。トップレベルで宣言された変数は自動的にテンプレートに公開され、インポートは登録なしで直接使用できます。

vue
<!-- UserCard.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { User } from '@/types'

// TypeScriptインターフェースを使用したProps
const props = defineProps<{
  user: User
  showEmail?: boolean
}>()

// リアクティブな状態
const isExpanded = ref(false)

// 算出プロパティ
const displayName = computed(() => {
  return `${props.user.firstName} ${props.user.lastName}`
})

// 型付きペイロードを持つイベントエミッター
const emit = defineEmits<{
  select: [userId: string]
  delete: [userId: string]
}>()

// メソッドは単純な関数として定義
function handleSelect() {
  emit('select', props.user.id)
}
</script>

<template>
  <div class="user-card" @click="handleSelect">
    <h3>{{ displayName }}</h3>
    <p v-if="showEmail">{{ user.email }}</p>
    <button @click.stop="isExpanded = !isExpanded">
      {{ isExpanded ? 'Collapse' : 'Expand' }}
    </button>
  </div>
</template>

コンパイラがexport文とコンポーネント登録を自動的に処理します。これにより、setup()関数から明示的に値を返す場合と比較して、認知負荷が軽減されます。

リアクティブProps分割代入(Vue 3.5以降)

Vue 3.5以前は、propsを分割代入するとリアクティビティが失われていました。これはJavaScriptの分割代入がその時点での値のスナップショットを作成するためです。Vue 3.5では、分割代入されたpropsのリアクティビティを保持するコンパイラレベルの追跡が導入されました。

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
import { watch } from 'vue'

// デフォルト値付きの分割代入 - 3.5以降でリアクティブ
const { query = '', placeholder = 'Search...' } = defineProps<{
  query?: string
  placeholder?: string
}>()

// watchは分割代入されたpropsでも動作
watch(
  () => query,
  (newQuery) => {
    console.log('Query changed:', newQuery)
  }
)
</script>

<template>
  <input
    :value="query"
    :placeholder="placeholder"
    @input="$emit('update:query', ($event.target as HTMLInputElement).value)"
  />
</template>

この構文はVue 3.5以降が必要です。Vue 3.5リリースアナウンスメントにリアクティブprops分割代入の動作の完全なリストが記載されています。

defineModelによる双方向バインディング

defineModel導入以前、カスタムコンポーネントでv-modelを実装するには、propを宣言し、更新イベントを手動でemitする必要がありました。このパターンは数多くのVueコードベースで見られます:

vue
<!-- 従来の方法 - 冗長だが明示的 -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

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

defineModelを使用すると、同じコンポーネントが大幅に短くなります:

vue
<!-- TextInput.vue -->
<script setup lang="ts">
// defineModelはv-modelと同期するrefを作成
const model = defineModel<string>({ default: '' })
</script>

<template>
  <input
    v-model="model"
    type="text"
    class="text-input"
  />
</template>

親コンポーネントは標準のv-model構文を使用します:

vue
<!-- ParentForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TextInput from './TextInput.vue'

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

<template>
  <TextInput v-model="username" />
  <p>Username: {{ username }}</p>
</template>

defineModelは親のバインドされた値と同期を維持するrefを返します。このrefへの変更は自動的に更新イベントをemitします。

Vue.js / Nuxt.jsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

名前付きモデルと複数のv-modelバインディング

コンポーネントは複数の双方向バインディングが必要な場合があります。Vueは名前付きv-modelバインディングをサポートしており、defineModelは名前引数を受け取ることでこれに対応します。

vue
<!-- DateRangePicker.vue -->
<script setup lang="ts">
// 複数のv-modelバインディング用の名前付きモデル
const startDate = defineModel<Date>('start', { required: true })
const endDate = defineModel<Date>('end', { required: true })

// バリデーション用の算出プロパティ
const isValidRange = computed(() => {
  if (!startDate.value || !endDate.value) return true
  return startDate.value <= endDate.value
})
</script>

<template>
  <div class="date-range-picker">
    <input
      type="date"
      :value="startDate?.toISOString().split('T')[0]"
      @input="startDate = new Date(($event.target as HTMLInputElement).value)"
    />
    <span>to</span>
    <input
      type="date"
      :value="endDate?.toISOString().split('T')[0]"
      @input="endDate = new Date(($event.target as HTMLInputElement).value)"
    />
    <p v-if="!isValidRange" class="error">End date must be after start date</p>
  </div>
</template>

親は両方のモデルをバインドします:

vue
<DateRangePicker v-model:start="filterStart" v-model:end="filterEnd" />

defineModelでのv-modelモディファイア

Vueの組み込みモディファイア(.trim.number.lazyなど)はdefineModelで自動的に動作します。カスタムモディファイアは、返されるタプルの2番目の要素を通じて明示的に処理する必要があります。

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// 2番目の戻り値でモディファイアにアクセス
const [model, modifiers] = defineModel<number>({
  default: 0,
  // カスタムモディファイア用の変換関数
  set(value) {
    // 'round'モディファイアが存在する場合は小数点以下2桁に丸める
    if (modifiers.round) {
      return Math.round(value * 100) / 100
    }
    return value
  }
})
</script>

<template>
  <div class="currency-input">
    <span>$</span>
    <input
      type="number"
      step="0.01"
      :value="model"
      @input="model = parseFloat(($event.target as HTMLInputElement).value) || 0"
    />
  </div>
</template>

カスタムモディファイアの使用例:

vue
<CurrencyInput v-model.round="price" />

Script SetupにおけるTypeScriptパターン

<script setup>コンポーネントでの型安全性は、コンパイラマクロに渡されるジェネリクスに依存します。パターンはprops、emits、modelsでわずかに異なります。

vue
<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: string }">
import { computed } from 'vue'

// ジェネリックコンポーネントのprops
const props = defineProps<{
  items: T[]
  selected?: T
}>()

const emit = defineEmits<{
  select: [item: T]
  remove: [id: string]
}>()

// スロットも型付け可能
defineSlots<{
  default: (props: { item: T; index: number }) => any
  empty: () => any
}>()

const hasItems = computed(() => props.items.length > 0)
</script>

<template>
  <ul v-if="hasItems">
    <li
      v-for="(item, index) in items"
      :key="item.id"
      :class="{ selected: selected?.id === item.id }"
      @click="emit('select', item)"
    >
      <slot :item="item" :index="index">
        {{ item.id }}
      </slot>
    </li>
  </ul>
  <div v-else>
    <slot name="empty">No items</slot>
  </div>
</template>

Vue 3.3以降で利用可能なジェネリックコンポーネントは、使用側のコードで型推論を犠牲にすることなく、再利用可能な型付きコンポーネントを実現します。より詳細なパターンについては、TypeScriptに関するVueドキュメントでエッジケースがカバーされています。

Script Setupに関するインタビュー頻出質問

Vueの知識をテストする面接官は、script setupとOptions APIの違い、およびリアクティビティのメカニズムに焦点を当てることが多いです。

質問:なぜpropsの分割代入はリアクティビティを壊すのですか?Vue 3.5はどのように解決しましたか?

JavaScriptでは、分割代入は分割代入時点の値を保持する新しい変数を作成します。後でprops.countが変更されても、const { count } = propsで分割代入された変数は古い値を保持したままです。Vue 3.5のコンパイラは、分割代入された変数をアクセス時に再評価するゲッターに変換し、ランタイムのオーバーヘッドなしでリアクティビティを保持します。

質問:defineModelが使用されるとき、内部では何が起きていますか?

コンパイラはdefineModelをprop宣言とemit登録に展開します。ランタイムでは、ゲッターがprop値を読み取り、セッターがemit('update:modelValue', newValue)を呼び出すrefを作成します。このrefはpropの真のコピーではなく、読み取りと書き込みを適切に転送するプロキシです。

質問:コンポーネントはscript setupと通常のsetup関数のどちらを使用すべきですか?

Script setupは、ボイラープレートを削減しIDEサポートを向上させるため、ほとんどのSingle File Componentで推奨されます。通常のsetup()関数は、複雑なパターンでdefineExposeを介して親コンポーネントにメソッドを公開する必要がある場合、またはレンダー関数を直接返すレンダーレスコンポーネントを構築する場合に必要です。インタビュー対策として、Vue Composablesパターンもご参照ください。

Options APIからScript Setupへの移行

既存のコードベースには、移行によりメリットを得られるOptions APIコンポーネントが含まれていることが多いです。変換は予測可能なパターンに従います:

vue
<!-- 移行前:Options API -->
<script lang="ts">
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  props: {
    items: { type: Array as PropType<string[]>, required: true }
  },
  emits: ['select'],
  data() {
    return { searchTerm: '' }
  },
  computed: {
    filteredItems() {
      return this.items.filter(item =>
        item.toLowerCase().includes(this.searchTerm.toLowerCase())
      )
    }
  },
  methods: {
    selectItem(item: string) {
      this.$emit('select', item)
    }
  }
})
</script>
vue
<!-- 移行後:Script Setup -->
<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps<{ items: string[] }>()
const emit = defineEmits<{ select: [item: string] }>()

const searchTerm = ref('')

const filteredItems = computed(() =>
  props.items.filter(item =>
    item.toLowerCase().includes(searchTerm.value.toLowerCase())
  )
)

function selectItem(item: string) {
  emit('select', item)
}
</script>

移行後のバージョンは短く、PropTypeキャストなしで型推論が機能します。Vueチームは自動移行のためのvue-codemodを提供していますが、複雑なコンポーネントには手動レビューが必要です。

Script Setupにおけるパフォーマンスの考慮事項

Script setupコンポーネントは、コンパイラがビルド時にコンポーネントの正確な形状を把握しているため、より効率的なコードにコンパイルされます。具体的には:

  • テンプレートバインディングはランタイムのプロパティルックアップではなくコンパイル時に解決される
  • 未使用のインポートがより効果的にツリーシェイキングされる
  • ラッピング関数がないためクロージャのオーバーヘッドが削減される

バンドルサイズを優先するアプリケーションでは、script setupとVapor Mode(Vue 3.6以降で利用可能)を組み合わせることで、オプトインしたコンポーネントの仮想DOMを完全に排除し、代わりに直接DOM操作を生成します。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

Vue 3 Script SetupとdefineModelの要点まとめ

  • Script setupは、トップレベルのバインディングを自動的にテンプレートに公開し、コンポーネント登録を処理することで、コンポーネントのボイラープレートを削減する
  • defineModelv-modelの手動prop + emitパターンを置き換え、親のバインドされた値と同期するrefを返す
  • Vue 3.5ではリアクティブprops分割代入が導入され、const { prop } = defineProps()でもリアクティビティが失われない
  • 名前付きモデルはdefineModel('name')を使用して、単一コンポーネントで複数のv-modelバインディングをサポートする
  • TypeScriptジェネリクスはdefinePropsdefineEmitsdefineSlots、およびgeneric属性を持つジェネリックコンポーネントで動作する
  • Options APIからの移行は機械的なパターンに従う:dataはrefに、computedはcomputedのまま、methodsは関数になり、コンパイラがエクスポートを処理する
今日のチャレンジ

Vue.js / Nuxt.js のバグを見つけられますか

実際のコード、隠れたバグ、1日1回。アカウントなしで試せます。

Anthony Fillion-Maillet

執筆

Anthony Fillion-Maillet

SharpSkill 創業者

10 年以上フルスタック開発に携わっています。SharpSkill を運営し、ここで公開される内容に責任を負っています。

2026年9月18日 更新

タグ

#vue
#vue3
#script-setup
#definemodel
#composition-api
#typescript

共有

関連記事