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 の高速化、
defineModel、useId、useTemplateRef、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 は作れるが、規模が大きくなると次の課題が出る。
- 状態と DOM の同期 — 状態が変わるたびに DOM を手で書き換えるとバグが増える。
- コンポーネントの再利用 — 「ボタン」「モーダル」「フォーム」など UI 単位で部品化したい。
- 派生値の自動更新 — 「カート合計 = 商品×個数の和」のような派生値を宣言的に書きたい。
- チームでの保守性 — テンプレート、ロジック、スタイルの分離・整理。
- ツーリング — TypeScript 補完、HMR、テスト、ビルド最適化。
Vue はこれらを「リアクティビティシステム」「Single File Component (SFC)」「Composition API」「Vite との統合」という 4 つの柱で解決している。
1.3 React との比較(要点だけ)
| 観点 | Vue 3 | React 18+ |
|---|---|---|
| 状態管理 | Proxy ベースの細粒度リアクティビティ | 不変 state + 再レンダリング |
| テンプレート | HTML ベースの <template> + ディレクティブ | JSX |
| 学習曲線 | 緩やか(HTML/CSS/JS の延長) | やや急(JSX、Hooks のルール) |
| 型安全 | <script setup lang="ts"> で良好 | TS と JSX が密に統合 |
| ビルドツール | Vite が公式 | Vite/Webpack/Next.js それぞれ |
| メタフレームワーク | Nuxt | Next.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
}
})
}
track と trigger の本体は WeakMap<target, Map<key, Set<effect>>> という三段ネストのデータ構造で、「どの effect がどのオブジェクトのどのキーに依存しているか」を覚える。これにより、user.name を変えても user.age を読んでいる effect は再実行されない、という細粒度な更新が実現される。
2.3 ref と reactive の使い分け
両者は重複する部分が多く、現場でよく混乱を招く。実用的な指針:
| 状況 | 推奨 | 理由 |
|---|---|---|
| プリミティブ(number, string, boolean) | ref | reactive(0) は意味を持たない(Proxy 化できない) |
| 単一オブジェクトを丸ごと差し替えたい | ref | reactive だと参照を差し替えてもリアクティブが切れる |
| ネストしたオブジェクトを部分更新したい | どちらでも可 | チームの統一性を優先 |
| 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 watch と watchEffect
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 化するため、巨大ツリーで初期化コストがかかる。shallowReactiveやmarkRawで抑制する。 - モジュールスコープでの 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 コンポーザブル設計のベストプラクティス
refを返す — 分割代入してもリアクティビティが保たれる。- 副作用は
onMounted/onScopeDisposeで管理 — テスト可能性とメモリリーク防止。 - 戻り値は最小限 — 「何が必要か」を呼び出し側で読み取りやすく。
- 副作用なしの純粋ヘルパは普通の関数に — コンポーザブルである必要がないものは、コンポーザブルにしない。
- テスタブルに作る —
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>
defineProps、defineEmits、defineModel、defineSlots、defineExpose はコンパイラマクロであり、ランタイムには存在しない。コンパイル後は通常の 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-show | CSS display で表示切替(DOM は残る) |
v-for | 配列・オブジェクト・数値の繰り返し |
v-on (@) | イベントハンドラ |
v-bind (:) | 属性・プロップバインディング |
v-model | 双方向バインディング |
v-slot (#) | スロット |
v-pre | テンプレートコンパイルをスキップ |
v-once | 1 回だけ描画して以降は静的化 |
v-memo | 依存配列で再描画をスキップ |
v-cloak | コンパイル前のチラつき抑制 |
v-if vs v-show の使い分け
v-if— 条件が変わると要素を生成/破棄。初期コストは安いが切替コストが高い。v-show— 常に DOM に存在しdisplay: noneで隠す。切替コスト低、初期コスト高。
頻繁にトグルするモーダル類は v-show、ほとんど切り替えない管理者専用 UI は v-if、というのが目安。
v-for の key
<li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>
key を省略するとアイテムの追加・削除・順序変更で誤った DOM 再利用が起き、内部状態がズレる。ID 等の安定したユニーク値を必ず指定する。v-for と v-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">
修飾子:
.lazy—changeイベントで同期(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 API | Options API | タイミング |
|---|---|---|
setup 直接実行 | beforeCreate / created | インスタンス初期化 |
onBeforeMount | beforeMount | DOM マウント直前 |
onMounted | mounted | DOM マウント後 |
onBeforeUpdate | beforeUpdate | リアクティブ更新後、DOM 反映直前 |
onUpdated | updated | DOM 反映後 |
onBeforeUnmount | beforeUnmount | アンマウント直前 |
onUnmounted | unmounted | アンマウント完了 |
onErrorCaptured | errorCaptured | 子で発生したエラー |
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 親子のライフサイクル順序
理解しておくべき重要な順序:
- 親
setup→ 親onBeforeMount - 子
setup→ 子onBeforeMount→ 子onMounted - 親
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 そのものなので、computed、watch、コンポーザブル、外部 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-macros | Vue マクロ拡張(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 / inject の InjectionKey で型安全にする例は §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-memo、v-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-sfc | SFC コンパイラ |
| Vite | ビルドツール |
| Nuxt | フルスタックメタフレームワーク |
| VitePress | ドキュメントサイト |
@vueuse/core | ユーティリティコンポーザブル集(事実上の標準) |
16.2 VueUse
Composition API ユーティリティの宝庫。useDark、useLocalStorage、useDebounceFn、useEventListener、useIntersectionObserver、useFetch、useElementSize など 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 コンポーネントライブラリ
| ライブラリ | 特徴 |
|---|---|
| Vuetify | Material Design、最古参で機能豊富 |
| PrimeVue | エンタープライズ向け、巨大コンポーネント群 |
| Naive UI | TypeScript ファースト、テーマ柔軟 |
| Element Plus | 中国圏で特に人気、企業向け |
| Ant Design Vue | Ant Design の Vue 版 |
| Quasar Components | Quasar 同梱の巨大セット |
| Nuxt UI | Nuxt 公式、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 Coverage、vue-tsc — 型カバレッジと検査。
17. ベストプラクティスとよくある落とし穴
17.1 設計のベストプラクティス
<script setup lang="ts">を既定にする — 学習コストの大半をここで一度払う。- コンポーネントは「小さく、責務単一」で — 200 行を超えたら分解の合図。
- ロジックはコンポーザブルに切り出す — ファイル間の共有、テスタビリティ、再利用。
- 状態の所在を一段階上に — 兄弟間で必要なら親に上げる。アプリ全体で必要なら Pinia に。
v-modelよりもdefineModel(Vue 3.4+)。- props は immutable — 変更したい時は emit。
- 副作用(タイマー、リスナー、サブスク)は必ず
onUnmountedで解除。 - ローカル登録 + ファイル名 PascalCase を貫く。
scopedCSS を既定、グローバルは限定的に。- 大きなフォームは VeeValidate + Zod で型安全に。
17.2 よくある落とし穴
| 症状 | 原因 | 対策 |
|---|---|---|
reactive から取り出した値が更新されない | 分割代入で素の値に剥がれた | toRefs(obj) を使う |
| 子コンポーネントの状態が壊れる | v-for の key 指定なし or インデックス | 安定 ID を :key に |
watch が初回に発火しない | immediate: false 既定 | { immediate: true } |
SSR で window is not defined | サーバーで DOM API 参照 | onMounted か import.meta.client |
setup 内で await 後に props が空 | async setup のリアクティビティ復帰問題 | <Suspense> で囲む |
| Pinia 分割代入で reactive 切れる | const { items } = store が原因 | storeToRefs(store) を使う |
| メモリリーク | setInterval、addEventListener の解除忘れ | 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.component→app.component、new Vue→createApp。
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 の魅力は、書けば書くほど「なぜこれをわざわざ別の方法でやる必要があるのか分からない」と感じる、その自然さにある。