Vue

Vue.js 徹底解説 — リアクティビティとコンポーネントシステムの全容

1. はじめに — Vue.js とは何か

Vue.js(ヴュー・ジェイエス)は、Evan You が 2014 年に発表した、宣言的・コンポーネントベースの UI フレームワークである。React の「Virtual DOM とコンポーネント指向」と、AngularJS の「テンプレート指向のディレクティブ」を組み合わせた折衷案として始まり、その後独自の道を歩んで進化してきた。現在では、React、Angular と並ぶ主要 SPA フレームワークの一角を占め、特にアジアと欧州での採用が厚い。

Vue 自身は「プログレッシブ・フレームワーク」を標榜している。これは、HTML ファイル 1 枚に <script> タグで読み込んで使う最小構成から、Vite や Nuxt と組み合わせた本格的なフルスタックアプリまで、段階的に Vue の機能を採用できるという設計思想を指す。「すべてかゼロか」の React や Angular とは異なる立ち位置だ。

1.1 歴史的背景

  • 2014 年: Vue 0.x — Evan You が個人プロジェクトとして公開。
  • 2015 年: Vue 1.0 — 名称が定着し、コミュニティが形成される。
  • 2016 年: Vue 2.0 — Virtual DOM 導入、SSR 対応、エコシステム整備(Vue Router、Vuex)。
  • 2020 年: Vue 3.0「One Piece」— Composition API、Proxy ベースのリアクティビティ、TypeScript ファースト、複数ルートノード対応、Fragment、Teleport、Suspense、Tree-shakable コア。
  • 2022 年: Vue 3 がデフォルトバージョンに昇格。Vite が公式ビルドツールに。
  • 2024〜2025 年: Vue 3.4 / 3.5 — Reactivity の高速化、defineModeluseIduseTemplateRef、reactive props destructure などの DX 強化。Vapor Mode(Virtual DOM を使わないコンパイル戦略)の RFC 進行中。

Vue 2 は 2023 年末に EOL(End of Life)を迎え、現在の開発はすべて Vue 3 系で行われている。本稿も特に断りのない限り Vue 3.4+ を対象とする。

1.2 Vue が解決する問題

素の JavaScript / DOM API でも UI は作れるが、規模が大きくなると次の課題が出る。

  1. 状態と DOM の同期 — 状態が変わるたびに DOM を手で書き換えるとバグが増える。
  2. コンポーネントの再利用 — 「ボタン」「モーダル」「フォーム」など UI 単位で部品化したい。
  3. 派生値の自動更新 — 「カート合計 = 商品×個数の和」のような派生値を宣言的に書きたい。
  4. チームでの保守性 — テンプレート、ロジック、スタイルの分離・整理。
  5. ツーリング — TypeScript 補完、HMR、テスト、ビルド最適化。

Vue はこれらを「リアクティビティシステム」「Single File Component (SFC)」「Composition API」「Vite との統合」という 4 つの柱で解決している。

1.3 React との比較(要点だけ)

観点Vue 3React 18+
状態管理Proxy ベースの細粒度リアクティビティ不変 state + 再レンダリング
テンプレートHTML ベースの <template> + ディレクティブJSX
学習曲線緩やか(HTML/CSS/JS の延長)やや急(JSX、Hooks のルール)
型安全<script setup lang="ts"> で良好TS と JSX が密に統合
ビルドツールVite が公式Vite/Webpack/Next.js それぞれ
メタフレームワークNuxtNext.js / Remix
エコシステム規模大きい(npm DL は React の 1/4 程度)最大

「どちらが優れているか」ではなく、HTML/CSS が好きで、テンプレートとロジックを明確に分けたい人には Vue が、JSX で全部 JavaScript として書きたい人には React が向く、という棲み分けである。

1.4 本稿の射程

A4 30 枚相当で、Vue 3 の以下を扱う。

  • リアクティビティシステム(ref / reactive / computed / watch の内部)
  • Composition API と Options API の設計判断
  • SFC と <script setup> のコンパイラマジック
  • テンプレート構文(ディレクティブ、修飾子、スロット)
  • コンポーネント間通信(props / emits / provide-inject / v-model / defineModel
  • 組み込みコンポーネント(Teleport、Suspense、Transition、KeepAlive)
  • ライフサイクル
  • Vue Router 4 の設計
  • Pinia による状態管理
  • ツーリング(Vite、Volar、vue-tsc)
  • TypeScript との統合
  • テスト戦略(Vitest、Vue Test Utils、Playwright)
  • パフォーマンス最適化(v-memo、defineAsyncComponent、Vapor Mode)
  • SSR とメタフレームワーク(Nuxt、VitePress、Quasar)
  • ベストプラクティスと落とし穴

2. リアクティビティシステム

Vue の心臓部はリアクティビティシステムである。状態(reactive state)が変わると、それに依存する UI(およびその他の副作用)が自動的に再評価される。React の「setState で再レンダリングをトリガーする」モデルとは根本的に異なり、依存追跡が細粒度である点が特徴。

2.1 4 つの基本 API

  • ref(value) — プリミティブ値・オブジェクトをラップして .value で読み書きするリアクティブ参照。
  • reactive(obj) — オブジェクトを Proxy 化し、プロパティ単位でリアクティブにする。
  • computed(getter) — 依存値から派生する遅延評価値。依存が変わらなければキャッシュを返す。
  • watch(source, callback) / watchEffect(fn) — リアクティブ値の変化に応じた副作用。
import { ref, reactive, computed, watch, watchEffect } from 'vue'

const count = ref(0)
const user = reactive({ name: 'Taro', age: 30 })

const doubled = computed(() => count.value * 2)

watch(count, (next, prev) => {
  console.log(`count: ${prev}${next}`)
})

watchEffect(() => {
  console.log(`name=${user.name}, doubled=${doubled.value}`)
})

count.value++          // → "count: 0 → 1" と "name=Taro, doubled=2" が出力される
user.name = 'Hanako'   // → "name=Hanako, doubled=2"

2.2 Proxy ベースの仕組み

Vue 2 は Object.defineProperty で getter/setter を上書きしていたため、配列の index 代入や、初期化されていないプロパティの追加を検知できなかった。Vue 3 は ES2015 の Proxy を採用してこの制約を解消している。

// 概念的に reactive はこうなっている
function reactive(target) {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)                       // 依存登録
      const v = Reflect.get(obj, key, receiver)
      return typeof v === 'object' && v !== null ? reactive(v) : v
    },
    set(obj, key, value, receiver) {
      const result = Reflect.set(obj, key, value, receiver)
      trigger(obj, key)                     // 副作用を再実行
      return result
    }
  })
}

tracktrigger の本体は WeakMap<target, Map<key, Set<effect>>> という三段ネストのデータ構造で、「どの effect がどのオブジェクトのどのキーに依存しているか」を覚える。これにより、user.name を変えても user.age を読んでいる effect は再実行されない、という細粒度な更新が実現される。

2.3 refreactive の使い分け

両者は重複する部分が多く、現場でよく混乱を招く。実用的な指針:

状況推奨理由
プリミティブ(number, string, boolean)refreactive(0) は意味を持たない(Proxy 化できない)
単一オブジェクトを丸ごと差し替えたいrefreactive だと参照を差し替えてもリアクティブが切れる
ネストしたオブジェクトを部分更新したいどちらでも可チームの統一性を優先
Composition API のコンポーザブルから返すref 中心分割代入してもリアクティビティが維持される
const state = reactive({ a: 1, b: 2 })
const { a, b } = state              // ❌ a, b は素の number に剥がれる

const refState = ref({ a: 1, b: 2 })
const { value: { a, b } } = refState // ❌ 同様

// コンポーザブルでは ref を返す
function useCounter() {
  const count = ref(0)
  return { count, increment: () => count.value++ }
}
const { count, increment } = useCounter()  // ✅ count はリアクティブ

2.4 computed の詳細

computed は「値が必要になったときに初めて評価し、依存が変わるまでは結果をキャッシュする」遅延評価のリアクティブ参照。

const items = ref([{ price: 100, qty: 2 }, { price: 200, qty: 1 }])
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))

console.log(total.value)   // 400 を計算してキャッシュ
console.log(total.value)   // キャッシュから即返却(再計算なし)
items.value.push({ price: 50, qty: 4 })
console.log(total.value)   // 依存が変わったので再計算 → 600

書き込み可能 computed:

const fullName = computed({
  get: () => `${first.value} ${last.value}`,
  set: (v) => {
    [first.value, last.value] = v.split(' ')
  }
})

2.5 watchwatchEffect

  • watch(source, cb, options) — 監視対象を明示。prev / next を取れる。
  • watchEffect(fn)fn 内で参照したリアクティブ値が依存になる。前後値は取れない。
// watch — 明示的依存
watch(() => route.params.id, async (id) => {
  user.value = await fetchUser(id)
}, { immediate: true })

// 複数の source
watch([first, last], ([f, l]) => console.log(`${f} ${l}`))

// deep, flush
watch(user, () => save(user), { deep: true, flush: 'post' })

// watchEffect — 自動依存追跡
watchEffect(() => {
  document.title = `${count.value} 件`
})

flush オプションは副作用の実行タイミング:

  • 'pre'(既定)— DOM 更新の前
  • 'post'— DOM 更新の後(DOM を読みたいなら)
  • 'sync' — 即時実行(パフォーマンス影響に注意)

watch は対象が変わるたびに新しい副作用を走らせる前に、前回の副作用をクリーンアップする仕組みも提供している(onCleanup コールバック)。

watch(query, async (q, _, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())   // 古いリクエストをキャンセル
  const res = await fetch(`/api/search?q=${q}`, { signal: controller.signal })
  results.value = await res.json()
})

2.6 Shallow / Readonly / Raw

  • shallowRef, shallowReactive — トップレベルのみリアクティブ。巨大データの最適化に。
  • readonly(obj) — 再帰的に readonly な Proxy を返す。
  • markRaw(obj) — その後 reactive に渡してもリアクティブ化されない。Class インスタンスや三者ライブラリのオブジェクトを保持する用。
  • toRaw(proxy) — Proxy を剥がして元のオブジェクトを得る。
const map = shallowRef(new Map())
map.value.set('a', 1)              // ❌ ミューテーションは検知されない
map.value = new Map(map.value).set('b', 2)  // ✅ 参照差し替えで通知

const config = readonly({ apiBase: '/api' })
config.apiBase = '/v2'             // ❌ コンソール警告 + 変更されない

2.7 リアクティブのリーク・落とし穴

  • 分割代入で剥がれる: reactive した値は、変数に取り出すと素の値になる。toRefs(obj) を使う。
  • ネストの reactive 化: reactive は再帰的に Proxy 化するため、巨大ツリーで初期化コストがかかる。shallowReactivemarkRaw で抑制する。
  • モジュールスコープでの ref: モジュール先頭で const x = ref(0) するとアプリ全体で共有される。SSR では複数リクエストで状態が混線する。コンポーザブルとして関数経由で渡す。
  • ref を別の ref にラップしない: ref(ref(0)) は内側の ref が自動 unwrap されるため一見問題ないが、TypeScript の型が複雑になる。
  • 配列要素の差し替え: Vue 3 では arr[0] = x も検知される(Vue 2 では Vue.set が必要だった)。

3. Options API と Composition API

Vue には 2 つの主要なコード記述スタイルがある。Vue 2 から続く Options API と、Vue 3 で導入された Composition API。両者は等価ではなく、それぞれに長所がある。

3.1 Options API

<script>
export default {
  data() {
    return { count: 0, message: 'Hello' }
  },
  computed: {
    doubled() { return this.count * 2 }
  },
  methods: {
    increment() { this.count++ }
  },
  watch: {
    count(next, prev) { console.log(prev, next) }
  },
  mounted() {
    console.log('mounted')
  }
}
</script>
  • オプション名(data / computed / methods / watch / mounted)が固定で、新規参入者にとって場所が分かりやすい。
  • this 経由で全プロパティにアクセスできる。
  • 機能(カウンタ、検索、フォーム)が単一コンポーネントに混在すると、関連コードが options 間で散らばり、見通しが悪くなる。

3.2 Composition API

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

const count = ref(0)
const message = ref('Hello')
const doubled = computed(() => count.value * 2)
const increment = () => count.value++

watch(count, (next, prev) => console.log(prev, next))
onMounted(() => console.log('mounted'))
</script>
  • 機能ごとにコードを近接配置できる。「カウンタ機能はここ、検索機能はここ」と物理的に分離可能。
  • ロジックを コンポーザブル関数 として切り出して再利用できる(次節)。
  • TypeScript と非常に相性が良い(this の型が複雑にならない)。

3.3 どちらを選ぶか

公式は「新規プロジェクトでは Composition API + <script setup> を強く推奨」と明言している。Options API は Vue 2 からの移行や、シンプルな小規模アプリのために残されている。本稿でも以降は Composition API を前提とする。

3.4 コンポーザブル — ロジック再利用の決定打

コンポーザブルとは「Composition API を使って状態とロジックをまとめた関数」。React の Custom Hooks と同等の役割を果たす。

// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  const update = (e: MouseEvent) => {
    x.value = e.pageX
    y.value = e.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>

<template>マウス位置: {{ x }}, {{ y }}</template>

慣習として、コンポーザブルは use プレフィックスを付け、composables/ ディレクトリに置く。Mixin(Vue 2 時代の再利用機構)の問題点(名前衝突、暗黙の依存、TypeScript 不親和)をすべて解決した。

3.5 コンポーザブル設計のベストプラクティス

  1. ref を返す — 分割代入してもリアクティビティが保たれる。
  2. 副作用は onMounted / onScopeDispose で管理 — テスト可能性とメモリリーク防止。
  3. 戻り値は最小限 — 「何が必要か」を呼び出し側で読み取りやすく。
  4. 副作用なしの純粋ヘルパは普通の関数に — コンポーザブルである必要がないものは、コンポーザブルにしない。
  5. テスタブルに作るuseMouse({ target = window }) のように DI 可能に。
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const pending = ref(false)

  const execute = async () => {
    pending.value = true
    error.value = null
    try {
      const u = toValue(url)
      data.value = await fetch(u).then(r => r.json())
    } catch (e) {
      error.value = e as Error
    } finally {
      pending.value = false
    }
  }

  watchEffect(execute)
  return { data, error, pending, execute }
}

toValue() は「値・ref・getter どれでも取れる」ためのヘルパで、コンポーザブルの引数を柔軟にする現代的なパターン。


4. Single File Component (SFC) と <script setup>

Vue の特徴の一つが Single File Component、すなわち .vue 拡張子の単一ファイルにテンプレート・スクリプト・スタイルをまとめる仕組みである。

4.1 SFC の基本構造

<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">Count is {{ count }}</button>
</template>

<style scoped>
button {
  background: var(--color-primary);
  color: white;
  border-radius: 0.5rem;
  padding: 0.5rem 1rem;
}
</style>

3 つのトップレベルブロック:

  • <template> — Vue テンプレート構文(HTML スーパーセット)。
  • <script setup> — コンポーネントロジック。コンパイラがマクロを処理する(後述)。
  • <style scoped> — このコンポーネントだけに適用される CSS。

ブロックは複数定義可能で、<script><script setup> を併存させたり、<style module> で CSS Modules を有効化したりできる。

4.2 <script setup> のコンパイラマジック

<script setup> は単なるシュガーではなく、Vue コンパイラ(@vue/compiler-sfc)が解析するコンパイル時 DSL と捉えるのが正確である。

<script setup lang="ts">
const props = defineProps<{ msg: string; count?: number }>()
const emit = defineEmits<{ change: [value: number]; submit: [] }>()
const model = defineModel<string>()
const slots = defineSlots<{ default(): unknown; header(props: { id: string }): unknown }>()
</script>

definePropsdefineEmitsdefineModeldefineSlotsdefineExposeコンパイラマクロであり、ランタイムには存在しない。コンパイル後は通常の Vue コンポーネントオプションに変換される。

利点:

  • TypeScript 型から型情報を生成 — 別途のランタイム宣言が不要。
  • 完全な Tree-shaking — 使っていないコンポーザブルは消える。
  • return 不要 — トップレベルで定義した変数・関数はテンプレートからそのまま参照可能。

4.3 <style scoped> の挙動

<style scoped>
.btn { color: red; }
</style>

コンパイラは [data-v-xxxxx] 属性をテンプレート要素とセレクタの両方に付与し、CSS の影響範囲をコンポーネントに閉じ込める。

<!-- コンパイル後の DOM -->
<button data-v-7ba5bd90 class="btn">...</button>

<!-- コンパイル後の CSS -->
.btn[data-v-7ba5bd90] { color: red; }

子コンポーネントには影響しないが、子のルート要素には届く(hash がそのまま付与される)。深く子に介入したい場合は :deep() セレクタ:

.parent :deep(.child) {
  color: blue;
}

その他、<style module> で CSS Modules、<style> のみで非 scoped グローバル CSS、v-bind() でスクリプト値を CSS に注入できる。

<script setup>
const color = ref('tomato')
</script>

<style scoped>
button { background: v-bind(color); }
</style>

4.4 SFC のカスタムブロック

<docs><i18n> のような任意のブロックを定義してビルドツール(Vite プラグイン)で処理できる。例えば vue-i18n<i18n> ブロックでメッセージを定義できる。

<i18n lang="json">
{
  "ja": { "hello": "こんにちは" },
  "en": { "hello": "Hello" }
}
</i18n>

4.5 SFC を使わない選択肢

ビルドツールを通さない場合、defineComponent でランタイムにコンポーネントを定義することもできる。

<script type="module">
import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
createApp({
  setup() {
    const count = ref(0)
    return { count }
  },
  template: `<button @click="count++">{{ count }}</button>`
}).mount('#app')
</script>
<div id="app"></div>

学習用や、既存ページに少しだけ Vue を導入したい時に使える。SPA 規模では SFC + Vite が標準。

5. テンプレート構文

Vue のテンプレートは HTML スーパーセットである。標準 HTML として書けば標準 HTML として動き、そこに {{ }} 補間と v- ディレクティブで拡張する。

5.1 補間とバインディング

<template>
  <!-- テキスト補間 -->
  <p>{{ message }}</p>

  <!-- HTML として展開(XSS に注意) -->
  <div v-html="trustedHtml" />

  <!-- 属性バインディング -->
  <a :href="url">link</a>          <!-- v-bind:href の省略 -->
  <img :src="imgSrc" :alt="title">

  <!-- 動的属性名 -->
  <button :[attrName]="value">dynamic</button>

  <!-- class / style バインディング -->
  <div :class="{ active: isActive, error: hasError }" />
  <div :class="['btn', size, { disabled }]" />
  <div :style="{ color, fontSize: size + 'px' }" />

  <!-- 双方向バインディング -->
  <input v-model="name" />
</template>

{{ }} の中は JavaScript 式が書ける(文ではない)が、複雑な式は computed に切り出すべきである。

5.2 主要ディレクティブ

ディレクティブ用途
v-if / v-else-if / v-else条件描画。要素自体を生成・破棄する
v-showCSS display で表示切替(DOM は残る)
v-for配列・オブジェクト・数値の繰り返し
v-on (@)イベントハンドラ
v-bind (:)属性・プロップバインディング
v-model双方向バインディング
v-slot (#)スロット
v-preテンプレートコンパイルをスキップ
v-once1 回だけ描画して以降は静的化
v-memo依存配列で再描画をスキップ
v-cloakコンパイル前のチラつき抑制

v-if vs v-show の使い分け

  • v-if — 条件が変わると要素を生成/破棄。初期コストは安いが切替コストが高い。
  • v-show — 常に DOM に存在し display: none で隠す。切替コスト低、初期コスト高。

頻繁にトグルするモーダル類は v-show、ほとんど切り替えない管理者専用 UI は v-if、というのが目安。

v-forkey

<li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>

key を省略するとアイテムの追加・削除・順序変更で誤った DOM 再利用が起き、内部状態がズレる。ID 等の安定したユニーク値を必ず指定する。v-forv-if を同じ要素に書くと優先度の問題で意図しない挙動になるため、<template> で包んで分けるのが推奨。

5.3 イベント修飾子

<form @submit.prevent="onSubmit"> <!-- preventDefault -->
<button @click.stop="handler">    <!-- stopPropagation -->
<input @keyup.enter="search">     <!-- Enter キーのみ -->
<input @keyup.ctrl.k.exact="open"><!-- Ctrl+K のみ -->
<div @click.self="onlyMe">        <!-- 自分自身がターゲットの時だけ -->
<div @scroll.passive="onScroll">  <!-- passive listener -->
<button @click.once="fireOnce">   <!-- 1 回だけ -->

修飾子はチェーンして組み合わせられる。

5.4 双方向バインディング v-model

v-model は内部的には :value バインディングと @input(またはコンポーネントごとに定義された custom event)の組み合わせ。

<input v-model="text">

<!-- 等価な記述 -->
<input :value="text" @input="text = $event.target.value">

修飾子:

  • .lazychange イベントで同期(input ではなく)
  • .number — 自動で数値に変換
  • .trim — 前後の空白を除去
<input v-model.number="age">
<input v-model.trim="username">

5.5 スロット

スロットはコンポーネントに「中身を差し込む」ための仕組み。

<!-- 子: BaseCard.vue -->
<template>
  <div class="card">
    <header><slot name="header" /></header>
    <div class="body"><slot /></div>
    <footer><slot name="footer" :year="year" /></footer>
  </div>
</template>
<script setup>
const year = new Date().getFullYear()
</script>
<!-- 親 -->
<BaseCard>
  <template #header>タイトル</template>
  <p>本文</p>
  <template #footer="{ year }">© {{ year }}</template>
</BaseCard>
  • 名前付きスロット: <slot name="header" /><template #header>
  • スコープ付きスロット: <slot :year="year" /><template #footer="{ year }">
  • デフォルトスロット: 名前なし、または #default

スコープ付きスロットを駆使すると、Headless UI(描画は親に委ね、ロジックだけを子が提供する)パターンが書ける。

5.6 動的コンポーネントと <component :is>

<component :is="currentTab" />

currentTab にはコンポーネント定義または**コンポーネント名(ローカル登録時)**を渡す。タブ切り替え、フォームの種別ごとのフィールド切り替えなどに使う。

<script setup>
import TabHome from './TabHome.vue'
import TabAbout from './TabAbout.vue'
const tabs = { home: TabHome, about: TabAbout }
const current = ref('home')
</script>

<template>
  <button v-for="(_, name) in tabs" @click="current = name">{{ name }}</button>
  <component :is="tabs[current]" />
</template>

5.7 v-memo による最適化

<div v-memo="[item.id, item.updatedAt]">
  <ExpensiveChild :item="item" />
</div>

依存配列の値がすべて等しければ、その部分木の再評価とパッチを完全にスキップする。リスト中の重い要素に有効だが、過剰に使うと「画面が更新されない」バグの温床になるので注意。


6. コンポーネントシステム

Vue の UI はコンポーネントの木として構成される。コンポーネント間通信の手段は段階的に用意されている。

6.1 Props — 親 → 子

<script setup lang="ts">
const props = defineProps<{
  title: string
  count?: number
  items: { id: string; label: string }[]
}>()

// 既定値とバリデーション付き
const props2 = withDefaults(defineProps<{
  size?: 'sm' | 'md' | 'lg'
  rounded?: boolean
}>(), {
  size: 'md',
  rounded: true
})
</script>

Vue 3.5+ では Reactive Props Destructure(リアクティブな分割代入)が安定化:

const { count = 0, title } = defineProps<{ count?: number; title: string }>()
// count, title はリアクティブとして使える(コンパイラが置換)

これは「props は immutable、変更したい時は emit で親に頼む」という Vue の基本ルールを覆すものではない。あくまで読み取り側の DX 向上である。

6.2 Emits — 子 → 親

<script setup lang="ts">
const emit = defineEmits<{
  change: [value: number]
  submit: [payload: { name: string; email: string }]
}>()

const onClick = () => emit('change', 42)
</script>
<!-- 親 -->
<MyChild @change="handleChange" @submit="handleSubmit" />

イベント名はテンプレート上では kebab-case で書くのが慣習だが、emit('myEvent')@my-event の自動変換が走る。

6.3 v-model のカスタム — defineModel

Vue 3.4 で defineModel が安定化し、双方向バインディングの実装が劇的にシンプルになった。

<!-- 子: MyInput.vue -->
<script setup lang="ts">
const model = defineModel<string>()
const isOpen = defineModel<boolean>('open')      // v-model:open
</script>

<template>
  <input v-model="model" />
  <button @click="isOpen = !isOpen">toggle</button>
</template>
<!-- 親 -->
<MyInput v-model="text" v-model:open="modalOpen" />

以前の「modelValue props と update:modelValue emit を手書きする」パターンが要らなくなった。

6.4 provide / inject — 階層を超えた依存性注入

// 親または祖先
import { provide } from 'vue'
provide('theme', { primary: '#3b82f6' })

// 任意の子孫
import { inject } from 'vue'
const theme = inject('theme', { primary: '#000' })

型安全にするには InjectionKey を使う:

// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface UserContext { user: Ref<User | null>; logout: () => void }
export const UserKey: InjectionKey<UserContext> = Symbol('User')

// 親
provide(UserKey, { user, logout })

// 子
const { user, logout } = inject(UserKey)!  // ! は「絶対に provide されている」前提

provide / inject はテーマ、ユーザーセッション、フォーム文脈、i18n など「グローバル状態よりも狭く、props バケツリレーよりも広い」領域に最適。

6.5 Template Refs と useTemplateRef

子コンポーネントや DOM への参照は ref を介して取得する。Vue 3.5+ では useTemplateRef がより明示的な書き方を提供する。

<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

const inputEl = useTemplateRef<HTMLInputElement>('myInput')
onMounted(() => inputEl.value?.focus())
</script>

<template>
  <input ref="myInput" />
</template>

子コンポーネントの公開 API には defineExpose:

<!-- 子 -->
<script setup lang="ts">
const open = () => { /* ... */ }
const close = () => { /* ... */ }
defineExpose({ open, close })
</script>

<!-- 親 -->
<script setup lang="ts">
const dialog = useTemplateRef('dialog')
const show = () => dialog.value?.open()
</script>
<template>
  <MyDialog ref="dialog" />
</template>

<script setup> の変数は既定では非公開であるため、defineExpose で明示的に export する必要がある点に注意。

6.6 グローバル登録 vs ローカル登録

// グローバル登録(main.ts)
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'

const app = createApp(App)
app.component('BaseButton', BaseButton)   // どこからでも <BaseButton /> として使える
app.mount('#app')
<!-- ローカル登録(推奨) -->
<script setup>
import BaseButton from '@/components/BaseButton.vue'
</script>

<template>
  <BaseButton>OK</BaseButton>
</template>

ローカル登録は Tree-shaking が効き、未使用コンポーネントが本番バンドルから消える。グローバル登録は本当に「全画面で使う」もの(デザインシステムのアトム)に限る。Nuxt のようなメタフレームワークは自動登録機能でこの面倒を肩代わりする。


7. 組み込みコンポーネント

Vue 3 はいくつかの強力な組み込みコンポーネントを提供する。

7.1 <Teleport> — DOM ツリーを跨いで描画

モーダルやトーストのように「論理的には子だが DOM 的にはルートに置きたい」要素のための仕組み。

<template>
  <button @click="open = true">開く</button>

  <Teleport to="body">
    <div v-if="open" class="modal">
      <p>モーダル内容</p>
      <button @click="open = false">閉じる</button>
    </div>
  </Teleport>
</template>

z-index の問題、CSS の overflow 切り抜き問題から解放される。to には CSS セレクタか実 DOM 要素を渡す。

7.2 <Suspense> — 非同期コンポーネントの境界

async setup() を持つコンポーネント、または defineAsyncComponent で非同期化したコンポーネントのローディング/エラー境界を宣言する。

<template>
  <Suspense>
    <template #default>
      <UserProfile />     <!-- async setup を持つ -->
    </template>
    <template #fallback>
      <p>Loading...</p>
    </template>
  </Suspense>
</template>
<!-- UserProfile.vue -->
<script setup lang="ts">
const user = await fetchUser()   // setup の中で await できる
</script>

エラーは親で onErrorCaptured でキャッチ、もしくは <Suspense><template #default> に分割せず、エラー境界として別途 errorCaptured を実装する。

7.3 <Transition> / <TransitionGroup> — アニメーション

<Transition name="fade" mode="out-in">
  <p :key="message">{{ message }}</p>
</Transition>

<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from,    .fade-leave-to    { opacity: 0; }
</style>

CSS クラスの自動付け外し(*-enter-from, *-enter-active, *-enter-to, *-leave-*)でトランジションを実現する。<TransitionGroup> はリストの追加・削除アニメーション用。

JavaScript フックも提供される:

<Transition
  @before-enter="el => el.style.opacity = 0"
  @enter="(el, done) => animate(el, done)"
  @after-leave="el => onDone(el)"
  :css="false"
/>

GSAP や Motion One と組み合わせる際は :css="false" を指定して、Vue の CSS クラス処理を無効化する。

7.4 <KeepAlive> — コンポーネント状態の保持

<KeepAlive :include="['Home', 'Search']" :max="10">
  <component :is="currentView" />
</KeepAlive>

タブ切り替え時に再マウントされず、状態とスクロール位置が保たれる。activated / deactivated ライフサイクルが追加で利用できる。

7.5 <component><slot> も組み込み

形式上は組み込みコンポーネントとして扱われ、is や スロット名でカスタム動作を制御できる。


8. ライフサイクル

Vue コンポーネントには明確なライフサイクルがある。

Composition APIOptions APIタイミング
setup 直接実行beforeCreate / createdインスタンス初期化
onBeforeMountbeforeMountDOM マウント直前
onMountedmountedDOM マウント後
onBeforeUpdatebeforeUpdateリアクティブ更新後、DOM 反映直前
onUpdatedupdatedDOM 反映後
onBeforeUnmountbeforeUnmountアンマウント直前
onUnmountedunmountedアンマウント完了
onErrorCapturederrorCaptured子で発生したエラー
onActivated / onDeactivated同名<KeepAlive> 配下
onServerPrefetch同名SSR で await できる
<script setup>
import { onMounted, onUnmounted, onBeforeUnmount } from 'vue'

let timer: number | null = null

onMounted(() => {
  timer = window.setInterval(() => console.log('tick'), 1000)
})

onBeforeUnmount(() => {
  if (timer) clearInterval(timer)
})
</script>

<script setup> のトップレベル実行 = setup() であるため、「setup 相当の初期化処理」は単に直書きすればよい。beforeCreate / created フックは <script setup> には存在しない。

8.1 親子のライフサイクル順序

理解しておくべき重要な順序:

  1. setup → 親 onBeforeMount
  2. setup → 子 onBeforeMount → 子 onMounted
  3. onMounted

つまり「子の onMounted は親の onMounted より先」。これは React 系経験者がハマりやすいポイント。

8.2 エラーハンドリング

import { onErrorCaptured } from 'vue'

onErrorCaptured((err, instance, info) => {
  console.error('caught:', err, info)
  return false   // false で伝播を止める
})

親で onErrorCaptured を仕込んでおくと、子孫で投げられた同期/非同期エラー(async setup を含む)をキャッチできる。アプリ全体のエラーハンドリングは app.config.errorHandler:

const app = createApp(App)
app.config.errorHandler = (err, instance, info) => {
  Sentry.captureException(err, { extra: { info } })
}

8.3 副作用のスコープ管理

effectScope API でリアクティブな副作用をまとめて停止できる。コンポーザブルや状態管理ライブラリの実装で頻出。

import { effectScope } from 'vue'

const scope = effectScope()
scope.run(() => {
  watchEffect(() => /* ... */)
  watch(source, cb)
})

// 後でまとめて止める
scope.stop()

9. Vue Router 4

公式のルーティングライブラリ。SPA で URL ↔ コンポーネントの対応を扱う。Vue Router 4 は Vue 3 専用、Composition API ファースト。

9.1 基本セットアップ

// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  { path: '/',          name: 'home',  component: () => import('@/pages/Home.vue') },
  { path: '/about',     name: 'about', component: () => import('@/pages/About.vue') },
  { path: '/users/:id', name: 'user',  component: () => import('@/pages/User.vue'), props: true },
  {
    path: '/admin',
    component: () => import('@/layouts/Admin.vue'),
    meta: { requiresAuth: true },
    children: [
      { path: '',          name: 'admin-home', component: () => import('@/pages/admin/Home.vue') },
      { path: 'analytics', name: 'analytics',  component: () => import('@/pages/admin/Analytics.vue') }
    ]
  },
  { path: '/:catchAll(.*)*', name: 'not-found', component: () => import('@/pages/NotFound.vue') }
]

export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, saved) {
    if (saved) return saved
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  }
})
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

9.2 ルーターの基本要素

  • <RouterView /> — マッチしたコンポーネントの描画位置。
  • <RouterLink to="/about"><a> を生成。アクティブ判定とプリフェッチを含む。
  • useRouter() — プログラマティックナビゲーション。
  • useRoute() — 現在のルート情報(params、query、meta)。
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()

const goToUser = (id: string) => router.push({ name: 'user', params: { id } })
</script>

<template>
  <RouterLink to="/" active-class="active">ホーム</RouterLink>
  <RouterLink :to="{ name: 'user', params: { id: 1 } }">ユーザー1</RouterLink>
  <p>現在パス: {{ route.path }}</p>
  <RouterView />
</template>

9.3 動的ルートと props

{ path: '/users/:id', component: User, props: true }

props: true を付けると、URL パラメータがコンポーネントの props として注入される。useRoute() を呼ばずに済む。

<script setup lang="ts">
const props = defineProps<{ id: string }>()
</script>

関数形式の props も可能:

{ path: '/search', component: Search, props: route => ({ q: route.query.q }) }

9.4 ナビゲーションガード

// グローバル
router.beforeEach(async (to, from) => {
  if (to.meta.requiresAuth && !await isAuthenticated()) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})

// ルート単位
{ path: '/admin', beforeEnter: (to) => { /* ... */ } }
<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value && !confirm('破棄しますか?')) return false
})

onBeforeRouteUpdate(async (to) => {
  await fetchUser(to.params.id)
})
</script>

ガードは false または route location オブジェクトを返すと遷移をキャンセル/リダイレクトできる。

9.5 遅延ロード

{ path: '/admin', component: () => import('@/pages/Admin.vue') }

動的 import() を使うと Vite/Webpack がコード分割し、そのページに遷移したときだけ JS をダウンロードする。Vue Router は <RouterLink> のホバー時に自動でプリフェッチする(マウスダウン前にダウンロード)ため、UX 体感も悪化しない。

9.6 メタフィールドと型拡張

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    role?: 'admin' | 'editor' | 'viewer'
    title?: string
  }
}

これで to.meta.requiresAuth などが TypeScript で補完される。

9.7 タイプ付きルート(ファイルベース)

unplugin-vue-router を使うと、src/pages/ 配下のファイル構造から Vue Router の設定と TypeScript 型を自動生成できる。Nuxt と同じ DX を素の Vue + Vite プロジェクトでも得られる。

// vite.config.ts
import VueRouter from 'unplugin-vue-router/vite'
export default defineConfig({
  plugins: [VueRouter({ routesFolder: 'src/pages' }), Vue()]
})

10. Pinia — 公式状態管理

Vue 3 時代の状態管理デファクトは Pinia。Vuex 5 として始まり、独立ライブラリとして公式化された。Vuex 4 は Vue 3 互換だが、新規プロジェクトでは Pinia を選ぶべきである。

10.1 セットアップ

pnpm add pinia
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
createApp(App).use(pinia).mount('#app')

10.2 ストア定義(Setup ストア — 推奨)

// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // state
  const items = ref<{ id: string; name: string; price: number; qty: number }[]>([])

  // getters
  const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))
  const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0))

  // actions
  const add = (item: { id: string; name: string; price: number }) => {
    const existing = items.value.find(i => i.id === item.id)
    if (existing) existing.qty++
    else items.value.push({ ...item, qty: 1 })
  }
  const remove = (id: string) => {
    items.value = items.value.filter(i => i.id !== id)
  }
  const clear = () => items.value = []

  return { items, total, count, add, remove, clear }
})

Setup ストアは Composition API そのものなので、computedwatch、コンポーザブル、外部 API 呼び出しなど何でも書ける。

10.3 Options ストア — Vuex 風

export const useUserStore = defineStore('user', {
  state: () => ({ id: null as string | null, name: '' }),
  getters: { isLoggedIn: (s) => s.id !== null },
  actions: {
    async login(email: string, password: string) {
      const user = await api.login(email, password)
      this.$patch({ id: user.id, name: user.name })
    },
    logout() { this.$reset() }
  }
})

this で state/getters/actions にアクセスできる。Vuex からの移行はこちらが書きやすい。

10.4 利用側

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()
const { items, total } = storeToRefs(cart)   // 分割代入してもリアクティブ維持
const { add, remove } = cart                 // メソッドはそのまま分割代入 OK
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }} × {{ item.qty }}
      <button @click="remove(item.id)">削除</button>
    </li>
  </ul>
  <p>合計: ¥{{ total }}</p>
</template>

storeToRefs を必ず使うこと。素の分割代入では state への参照が切れてリアクティブでなくなる。

10.5 ストアの合成

ストアは他のストアを参照できる。

export const useOrderStore = defineStore('order', () => {
  const cart = useCartStore()
  const user = useUserStore()

  const placeOrder = async () => {
    if (!user.isLoggedIn) throw new Error('ログインが必要です')
    await api.createOrder({ userId: user.id, items: cart.items })
    cart.clear()
  }

  return { placeOrder }
})

10.6 永続化

pinia-plugin-persistedstate で localStorage に同期できる。

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// ストア定義側
export const useUserStore = defineStore('user', {
  state: () => ({ token: '' }),
  persist: true   // または { storage: sessionStorage, paths: ['token'] }
})

10.7 DevTools 連携

Pinia は Vue DevTools と統合し、各ストアの state、getters、action 履歴、タイムトラベルデバッグが可能。$subscribe で state 変更を、$onAction で action 呼び出しをフックできるため、ロギングや認可チェックの差し込みにも使える。

10.8 Pinia の哲学

  • 複数の小さなストアを使う(Vuex の 1 つの巨大ストアとは対照的)。
  • モジュール構造を強制しない。defineStore のキーがストアの ID。
  • TypeScript で完全に型推論される(Vuex 4 はジェネリクスが煩雑だった)。
  • テスタブル: setActivePinia(createPinia()) でテスト用インスタンスを差し込める。

11. ツーリング — Vite, Volar, vue-tsc

11.1 Vite — 公式ビルドツール

Vue 3 時代のビルドツールは Vite(同じ作者 Evan You による)。dev では ESM ネイティブ配信 + esbuild 変換、本番では Rollup でバンドル。

pnpm create vue@latest my-app

このコマンドが対話的に質問してきて、Vue + Vite + 公式オプション(TypeScript、JSX、Vue Router、Pinia、Vitest、Cypress、ESLint、Prettier)の選択肢から雛形を生成する。生成される vite.config.ts:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue(), vueJsx()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
  },
  server: { port: 5173, host: true },
  build: { sourcemap: true, target: 'esnext' }
})

@vitejs/plugin-vue が SFC をコンパイルする本体。@vitejs/plugin-vue-jsx は JSX/TSX を Vue として解釈する(テンプレートが嫌いな人向け)。

11.2 Volar / Vue Language Tools — エディタ統合

VS Code の Vue (Official) 拡張(旧 Volar)が Vue 3 専用の言語サーバを提供する。

  • SFC 内の <template><script><style> をシームレスに型チェック。
  • <script setup> の TypeScript 型からテンプレート式の型を逆算(Template Type-Check)。
  • 自動インポート、リファクタリング、定義ジャンプ。

vue.server.hybridMode: true を設定すると、.ts ファイルも Vue Language Tools のスコープで処理され、Volar takeover として一貫した体験になる。

11.3 vue-tsc — CLI 型チェック

tsc は Vue の SFC を理解しないため、vue-tsc という Vue 専用の TypeScript コンパイララッパーを使う。

pnpm add -D vue-tsc typescript
pnpm dlx vue-tsc --noEmit

CI のチェック手段として:

// package.json
"scripts": {
  "type-check": "vue-tsc --noEmit"
}

11.4 ESLint と Prettier

eslint-plugin-vue が Vue 構文(<template> 内)の lint を担う。Flat Config:

// eslint.config.js
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import ts from 'typescript-eslint'

export default [
  js.configs.recommended,
  ...vue.configs['flat/recommended'],
  ...ts.configs.recommended,
  {
    files: ['**/*.vue'],
    languageOptions: {
      parserOptions: { parser: ts.parser }
    }
  }
]

Prettier は SFC を解釈して <template><script><style> を個別に整形する。@vue/eslint-config-prettier で重複ルールを切り離せる。

11.5 unplugin 系

Vite/Webpack/Rollup で動く unplugin プラグインは Vue 開発の生産性を底上げする。

プラグイン機能
unplugin-vue-componentsコンポーネントの自動 import
unplugin-auto-import関数(ref, computed など)の自動 import
unplugin-vue-routerファイルベースルーティング
unplugin-vue-macrosVue マクロ拡張(defineProp など)
unplugin-iconsアイコンセットを Vue コンポーネントとして取り込む
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    vue(),
    Components({ dts: true }),
    AutoImport({ imports: ['vue', 'vue-router', '@vueuse/core'], dts: true })
  ]
})

これで import { ref } from 'vue' などを書かずに済む。

11.6 Vue DevTools

ブラウザ拡張版に加え、Vite プラグイン版(vite-plugin-vue-devtools)が登場し、ブラウザ画面下部にフローティングパネルとして DevTools を表示できる。Nuxt DevTools と同じ UX。コンポーネント検査、Pinia ストア、ルーティング、タイムライン、パフォーマンスフレーム計測などを含む。

import VueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({ plugins: [vue(), VueDevTools()] })

12. TypeScript 統合

Vue 3 は TypeScript ファーストで設計されており、<script setup lang="ts"> で書けば、テンプレート内の式まで型チェックされる。

12.1 tsconfig.json の基本

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "isolatedModules": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "useDefineForClassFields": true,
    "lib": ["esnext", "dom", "dom.iterable"],
    "types": ["vite/client"],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src/**/*", "src/**/*.vue"],
  "vueCompilerOptions": {
    "target": 3.4
  }
}

vueCompilerOptions.target は Vue Language Tools が想定するバージョン。

12.2 Props / Emits の型定義

// 型ベース宣言(Vue 3 推奨)
const props = defineProps<{
  id: string
  count?: number
  onDone?: () => void
}>()

const emit = defineEmits<{
  change: [value: number]
  submit: [payload: { name: string; email: string }]
}>()

ランタイム宣言と型宣言を両立したい場合(古いパターン):

const props = defineProps({
  id: { type: String, required: true },
  count: { type: Number, default: 0 }
})

型ベース宣言の場合、デフォルト値は withDefaults で:

const props = withDefaults(defineProps<{
  size?: 'sm' | 'md' | 'lg'
}>(), { size: 'md' })

12.3 リアクティブ値の型

const count = ref(0)              // Ref<number>
const user = ref<User | null>(null)
const list = reactive<Item[]>([])

// computed の型は推論される
const total = computed(() => list.reduce((s, i) => s + i.price, 0))   // ComputedRef<number>

12.4 グローバル型拡張

provide / injectInjectionKey で型安全にする例は §6.4 で扱った。app.config.globalProperties に追加した $ 系プロパティをテンプレートでも型補完したい場合:

// vue.d.ts
import 'vue'
declare module 'vue' {
  interface ComponentCustomProperties {
    $http: typeof import('axios').default
    $t: (key: string) => string
  }
}

12.5 Generic コンポーネント

<script setup lang="ts" generic="T extends ..."> で汎用コンポーネントが書ける。

<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{ items: T[] }>()
const emit = defineEmits<{ select: [item: T] }>()
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id" @click="emit('select', item)">
      <slot :item="item">{{ item.id }}</slot>
    </li>
  </ul>
</template>

呼び出し側では T が具体型に推論され、@select のハンドラ引数も正しい型になる。

12.6 Vue + TSX/JSX

JSX は Vue でも使える。

import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup() {
    const count = ref(0)
    return () => (
      <button onClick={() => count.value++}>
        Count: {count.value}
      </button>
    )
  }
})

ライブラリ作者や、ロジックの分量がテンプレートを大きく上回る局面では JSX が選ばれることもある。

13. テスト戦略

Vue アプリのテストは「単体(コンポーザブル)」「コンポーネント」「E2E」の三層で構成するのが王道。

13.1 Vitest — 単体・コンポーネントテスト

Vite と同じトランスパイラを共有するため、設定が最小限で、起動が高速。Jest 互換 API。

pnpm add -D vitest @vue/test-utils happy-dom @vitest/ui
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    globals: true,
    coverage: { provider: 'v8' }
  }
})

13.2 コンポーザブルのテスト

// composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('increments', () => {
    const { count, increment } = useCounter()
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })
})

副作用を持つコンポーザブルは、effectScope で囲って scope.stop() できるようにテストするとリーク防止になる。

13.3 Vue Test Utils

公式のコンポーネントテストユーティリティ。

// components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('renders initial count and increments', async () => {
    const wrapper = mount(Counter, { props: { initial: 5 } })
    expect(wrapper.text()).toContain('Count: 5')

    await wrapper.find('button').trigger('click')
    expect(wrapper.text()).toContain('Count: 6')

    expect(wrapper.emitted('change')?.[0]).toEqual([6])
  })

  it('renders slots', () => {
    const wrapper = mount(Counter, { slots: { label: 'カウンタ' } })
    expect(wrapper.html()).toContain('カウンタ')
  })
})

主要 API:

  • mount(Component, options) — 完全マウント。
  • shallowMount — 子コンポーネントをスタブ化。
  • wrapper.find(selector) — 要素探索。
  • wrapper.findComponent(Component) — コンポーネント探索。
  • wrapper.trigger('click') — DOM イベント発火。
  • wrapper.setValue(...), wrapper.setChecked() — 入力。
  • wrapper.emitted() — emit 履歴。
  • wrapper.vm — コンポーネントインスタンス(defineExpose した API)。

13.4 Pinia ストアのテスト

// stores/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'

describe('cart store', () => {
  beforeEach(() => setActivePinia(createPinia()))

  it('adds items and computes total', () => {
    const cart = useCartStore()
    cart.add({ id: '1', name: 'A', price: 100 })
    cart.add({ id: '1', name: 'A', price: 100 })
    expect(cart.count).toBe(2)
    expect(cart.total).toBe(200)
  })
})

コンポーネント側からストアを使うテストでは createTestingPinia()@pinia/testing)が便利。actions を自動でスタブ化してくれる。

13.5 Playwright — E2E

pnpm dlx create-playwright@latest
// tests/home.spec.ts
import { test, expect } from '@playwright/test'

test('home loads and displays heading', async ({ page }) => {
  await page.goto('http://localhost:5173')
  await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible()
})

test('login flow', async ({ page }) => {
  await page.goto('http://localhost:5173/login')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill('secret')
  await page.getByRole('button', { name: 'Login' }).click()
  await expect(page).toHaveURL(/\/dashboard$/)
})

Cypress も人気だが、Playwright は並列実行とトレーシングの強さで近年シェアを伸ばしている。

13.6 Component Testing in Real Browser

Vitest 3 では Browser Mode が安定し、実ブラウザ(Playwright/WebDriver)でコンポーネントテストを走らせられる。happy-dom では再現できない CSS や Layout を含むテストが可能。

// vitest.config.ts
test: {
  browser: { enabled: true, name: 'chromium', provider: 'playwright' }
}

14. パフォーマンス最適化

14.1 リアクティビティのコスト管理

  • shallowRef / shallowReactive — 巨大データ(例: 1 万件のテーブル)はトップレベルだけリアクティブにし、要素の比較は手動で。
  • markRaw — 三者ライブラリのインスタンス(地図、エディタなど)を Proxy 化しないように。
  • readonly — 子に渡す前に防御的に読み取り専用化。

14.2 コンポーネント分割

「依存範囲が変わるたびに大きなコンポーネントが再描画される」のを避けるため、変化の激しい部分を子コンポーネントに切り出す。Vue の更新は子コンポーネント単位なので、props が変わらなければ子は再評価されない。

14.3 defineAsyncComponent と遅延ロード

import { defineAsyncComponent } from 'vue'

const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: Spinner,
  errorComponent: ErrorCard,
  delay: 200,
  timeout: 8000
})

<Suspense> と組み合わせれば、複数の非同期コンポーネントの境界を一括で扱える。

14.4 v-memov-once

  • v-once — 描画後は完全静的化。プライバシーポリシーやヘルプテキストに。
  • v-memo — 依存配列が変わったときだけパッチ。重い v-for に有効。

14.5 リスト最適化

  • 必ず :key を ID で指定。
  • 仮想スクロール: vue-virtual-scroller@tanstack/vue-virtual などで 10 万行も滑らかに扱える。
  • 大量描画前にデータをソート・フィルタしておき、computed の中で重い処理を毎フレーム動かさない。

14.6 バンドル最適化

Vite は既定で十分な最適化を行う。追加で:

  • import.meta.glob で動的に複数ファイルを取り込む(コード分割対応)。
  • manualChunks で意図的なチャンク分割。
  • vite-plugin-compression で gzip/brotli を事前生成。
  • rollup-plugin-visualizer でバンドルサイズを可視化。

14.7 Vapor Mode(実験段階)

Vue 3.5+ で実験的に投入された Vapor Mode は、Virtual DOM を使わず、コンパイル時にテンプレートを直接 DOM 操作コードに変換するレンダリング戦略。

  • 実行時のメモリ使用量が大幅に減る。
  • 初期描画と更新の両方が速い。
  • 既存の SFC とほぼ同じ書き方。<script setup vapor> を付けるだけ。
<script setup vapor lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">Count: {{ count }}</button>
</template>

将来的にはコンポーネント単位で Vapor を選べるようになり、徐々に既存コードへ導入できる予定。SolidJS や Svelte と同じ「コンパイル時最適化」の流れ。

14.8 計測

Chrome DevTools の Performance タブと、Vue DevTools の Component / Timeline タブを併用する。Vite の build --report で Rollup のバンドル分析、pnpm dlx vite-bundle-analyzer で詳細分析。


15. SSR とメタフレームワーク

Vue 3 自身がサーバーサイドレンダリングをサポートする(vue/server-renderer)。だが実用上は、メタフレームワークを選ぶのが現実的だ。

15.1 素の Vue + SSR

// server.ts(最小例)
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'
import App from './App.vue'

export const render = async () => {
  const app = createSSRApp(App)
  return await renderToString(app)
}

ハイドレーション側は createSSRApp + mount で行う。実装は煩雑なので、99% のケースでは Nuxt 等を使う。

15.2 Nuxt — 最も統合された選択肢

  • ファイルベースルーティング、Auto-imports、SSR/SSG/ISR の宣言的切り替え、Nitro サーバーエンジン、デプロイターゲット抽象化。
  • Vue + Pinia + Vue Router をビルトイン。
  • 詳細は本シリーズの「Nuxt 徹底解説」記事を参照。

15.3 VitePress — ドキュメント特化

  • Markdown 中心の静的サイトジェネレータ。
  • Vue コンポーネントを Markdown 内で使える。
  • 公式 Vue / Vite のサイトもこれで作られている。
pnpm add -D vitepress
pnpm dlx vitepress init
// .vitepress/config.ts
export default {
  title: 'My Docs',
  themeConfig: {
    sidebar: [
      { text: 'Guide', items: [{ text: '入門', link: '/guide/' }] }
    ]
  }
}

ブログ・OSS ドキュメント・社内 wiki に最適。

15.4 Quasar — マルチプラットフォーム

Vue ベースで SPA、SSR、PWA、モバイル(Cordova/Capacitor)、デスクトップ(Electron)まで一つのコードベースで作れる。Material Design ベースの巨大コンポーネントライブラリを内包。

15.5 Astro + Vue

Astro は「コンテンツファースト」のメタフレームワークで、Vue コンポーネントを島(Islands)として埋め込める。マーケティングサイトやブログで Vue の DX を欲しいときに有用。

15.6 SSR の落とし穴

  • モジュールスコープの状態: サーバーで複数リクエストにまたがって共有される。コンポーザブルとしてラップ。
  • window / document の参照: onMounted 内に閉じ込める。if (import.meta.client) でガード。
  • データ取得の重複: ハイドレーション時に同じデータを再取得しがち。Nuxt の useFetch のような payload 機構を使う。
  • ルーター ↔ store の初期化順序: Pinia は app.use(pinia)app.use(router) の前に。

16. エコシステムと周辺ライブラリ

16.1 公式・準公式

ライブラリ用途
Vue Routerルーティング
Pinia状態管理
Vue Test Utilsコンポーネントテスト
@vue/language-tools言語サーバ・型チェッカ
@vue/compiler-sfcSFC コンパイラ
Viteビルドツール
Nuxtフルスタックメタフレームワーク
VitePressドキュメントサイト
@vueuse/coreユーティリティコンポーザブル集(事実上の標準)

16.2 VueUse

Composition API ユーティリティの宝庫。useDarkuseLocalStorageuseDebounceFnuseEventListeneruseIntersectionObserveruseFetchuseElementSize など 200 以上のコンポーザブル。

import { useDark, useToggle, useLocalStorage } from '@vueuse/core'

const isDark = useDark()
const toggleDark = useToggle(isDark)
const username = useLocalStorage('username', '')

DOM API、Sensor、Browser API を Vue リアクティブに包んだものが多く、書く必要のあるコードを劇的に減らす。

16.3 UI コンポーネントライブラリ

ライブラリ特徴
VuetifyMaterial Design、最古参で機能豊富
PrimeVueエンタープライズ向け、巨大コンポーネント群
Naive UITypeScript ファースト、テーマ柔軟
Element Plus中国圏で特に人気、企業向け
Ant Design VueAnt Design の Vue 版
Quasar ComponentsQuasar 同梱の巨大セット
Nuxt UINuxt 公式、Tailwind ベース
Headless UI for Vueスタイル無しで挙動だけ提供
Reka UI / Radix Vueアクセシビリティ重視のヘッドレス

UI 設計の自由度を取るなら Tailwind + ヘッドレス(Reka UI、Headless UI)、すぐ作るならフル装備(Vuetify、PrimeVue)。

16.4 フォーム・バリデーション

  • VeeValidate — Yup/Zod スキーマと統合可能なフォームバリデーション。
  • Formkit — フォーム UI と検証を一体化したフルセット。
  • Vorms — 軽量な型安全フォーム。

16.5 アニメーション

  • @vueuse/motion — 宣言的なモーション。
  • GSAP — Vue から普通に使える。<Transition> の JS フックと組み合わせる。
  • Auto Animate — リスト変更を勝手にアニメーション化。

16.6 ドキュメント・分析

  • Histoire / Storybook for Vue — コンポーネントカタログ。
  • Volar Coveragevue-tsc — 型カバレッジと検査。

17. ベストプラクティスとよくある落とし穴

17.1 設計のベストプラクティス

  1. <script setup lang="ts"> を既定にする — 学習コストの大半をここで一度払う。
  2. コンポーネントは「小さく、責務単一」で — 200 行を超えたら分解の合図。
  3. ロジックはコンポーザブルに切り出す — ファイル間の共有、テスタビリティ、再利用。
  4. 状態の所在を一段階上に — 兄弟間で必要なら親に上げる。アプリ全体で必要なら Pinia に。
  5. v-model よりも defineModel(Vue 3.4+)。
  6. props は immutable — 変更したい時は emit。
  7. 副作用(タイマー、リスナー、サブスク)は必ず onUnmounted で解除
  8. ローカル登録 + ファイル名 PascalCase を貫く。
  9. scoped CSS を既定、グローバルは限定的に。
  10. 大きなフォームは VeeValidate + Zod で型安全に。

17.2 よくある落とし穴

症状原因対策
reactive から取り出した値が更新されない分割代入で素の値に剥がれたtoRefs(obj) を使う
子コンポーネントの状態が壊れるv-forkey 指定なし or インデックス安定 ID を :key
watch が初回に発火しないimmediate: false 既定{ immediate: true }
SSR で window is not definedサーバーで DOM API 参照onMountedimport.meta.client
setup 内で await 後に props が空async setup のリアクティビティ復帰問題<Suspense> で囲む
Pinia 分割代入で reactive 切れるconst { items } = store が原因storeToRefs(store) を使う
メモリリークsetIntervaladdEventListener の解除忘れonScopeDispose で一括解除
グローバル CSS が他コンポーネントに漏れるscoped 忘れデフォルトで scoped を付ける
TypeScript で props のデフォルト値が型に出ないwithDefaults を使っていないwithDefaults(defineProps<...>(), {...})
Vue 2 風 data() { return ... } で書きたいComposition API のメリットを失うref/reactive に置き換え

17.3 マイグレーションメモ

Vue 2 → Vue 3:

  • フィルター({{ price | currency }})廃止。computed か関数呼び出しで。
  • $on / $off / $once 廃止。EventBus は外部ライブラリ(mitt)へ。
  • 関数コンポーネント(functional component)の API 変更。
  • v-model の API 刷新(複数 v-model OK)。
  • keyCode 修飾子廃止。@keyup.enter などの名前ベースに。
  • グローバル API の変更: Vue.componentapp.componentnew VuecreateApp

17.4 今後の展望

  • Vapor Mode の正式化 — Virtual DOM を使わない代替レンダラ。Solid に近い性能特性。
  • より強力な型推論defineProps の generics 制約、JSX/TSX のさらなる磨き込み。
  • Server Components — Nuxt 経由で進化中(*.server.vue*.island.vue)。
  • Runtime / Compiler の最適化 — リアクティビティの再実装による更なる速度向上。

17.5 まとめ

Vue は「HTML/CSS/JS の延長線で書ける」直観性と、「Composition API + TypeScript + Vite」による現代的な DX を両立した、極めてバランスの良いフレームワークである。ファイル単位で関心を集約する SFC、細粒度のリアクティビティ、Pinia と Vue Router の公式提供、メタフレームワーク Nuxt によるエンドツーエンド体験、これらが組み合わさって、小さな個人サイトから大規模 SPA まで一貫した手触りで開発できる。

<script setup lang="ts">defineModel、Pinia の Setup ストア、VueUse、Volar、Vite — この最小セットを身につければ、現代的な Vue 開発のほぼすべてを快適に書ける。次のステップは、本シリーズ姉妹編の「Nuxt 徹底解説」と「Nuxt オンボーディングガイド」に進むか、自分の好きな小さな UI を一つ Vue + Vite で書き起こしてみることだ。Vue の魅力は、書けば書くほど「なぜこれをわざわざ別の方法でやる必要があるのか分からない」と感じる、その自然さにある。