Vue3でコンポーネントを書いていると「onMountedは使うけれど、ほかのフックはいつ使えばいいのかわからない」という疑問に突き当たることがあります。Vue3のライフサイクルとは、コンポーネントが生成されてからDOMに挿入され、データ変更に応じて更新され、最終的に破棄されるまでの一連の過程を指します。各フェーズで呼び出される関数がライフサイクルフックであり、適切なフックを選べるかどうかが、バグの少ない効率的な開発に直結します。
Vue3コンポーネントのライフサイクルとは
Vueコンポーネントのインスタンスは、生成から破棄までに4つの主要フェーズを通過します。
- 生成(Creation) – リアクティブデータの初期化、propsの解決、
setup()の実行 - マウント(Mount) – テンプレートのコンパイルとDOMツリーへの挿入
- 更新(Update) – リアクティブデータの変更によるDOMの再描画
- アンマウント(Unmount) – DOMからの取り外しとリソースの解放
ライフサイクルフックは、これらの各フェーズの「直前」または「直後」に独自の処理を差し込むための仕組みです。たとえばAPI通信はマウント後に行い、イベントリスナーの解除はアンマウント時に行う、という形でフェーズに応じた処理を分離できます。
Vue2のOptions APIではcreatedやmountedといったオプション名でフックを定義していましたが、Vue3のComposition APIではonMountedのようにonプレフィックス付きの関数をsetup()内で呼び出す形式に変わりました。Options APIも引き続き利用できますが、Composition APIの方がロジックの分離・再利用に優れているため、新規開発ではComposition APIが推奨されています(参照: Vue.js公式ドキュメント)。
Vue3ライフサイクルの全フック一覧と発火順序
Vue3が提供するライフサイクルフックを、発火する順番に並べて整理します。
フック対応表(発火順)
| 順番 | Options API | Composition API | Vue2対応 | 発火タイミング |
|---|---|---|---|---|
| 1 | beforeCreate | setup()内で自動実行 | beforeCreate | インスタンス初期化直後、props解決後 |
| 2 | created | setup()内で自動実行 | created | リアクティブデータ・computed・watch設定完了後 |
| 3 | beforeMount | onBeforeMount | beforeMount | DOM描画の直前 |
| 4 | mounted | onMounted | mounted | DOMツリーが作成されて親コンテナに挿入された後 |
| 5 | beforeUpdate | onBeforeUpdate | beforeUpdate | リアクティブデータ変更後、DOM更新の直前 |
| 6 | updated | onUpdated | updated | DOM更新が完了した後 |
| 7 | beforeUnmount | onBeforeUnmount | beforeDestroy | コンポーネント取り外しの直前 |
| 8 | unmounted | onUnmounted | destroyed | コンポーネント取り外し完了後 |
| - | activated | onActivated | activated | KeepAliveキャッシュからDOMに再挿入された後 |
| - | deactivated | onDeactivated | deactivated | KeepAliveキャッシュに格納された後 |
| - | errorCaptured | onErrorCaptured | errorCaptured | 子孫コンポーネントでエラーが発生した時 |
| - | renderTracked | onRenderTracked | – | 依存関係が追跡された時(開発モード限定) |
| - | renderTriggered | onRenderTriggered | – | 依存関係が再レンダリングを起こした時(開発モード限定) |
| - | serverPrefetch | onServerPrefetch | – | サーバーサイドレンダリングの描画前(SSR限定) |
Vue2のbeforeDestroy/destroyedはVue3でbeforeUnmount/unmountedに名称変更されました。Composition APIではbeforeCreateとcreatedに対応する専用フックは存在せず、setup()自体がその役割を果たします。
発火順序を実際に確認するコード
全フックがどの順番で呼ばれるかをコンソールで確認できるコンポーネントの例です。
<script setup lang="ts">
import {
ref,
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onRenderTracked,
onRenderTriggered,
} from 'vue'
console.log('1. setup() -- リアクティブデータの初期化開始')
const count = ref(0)
onBeforeMount(() => {
console.log('2. onBeforeMount -- DOM描画の直前')
})
onRenderTracked((e) => {
console.log(' onRenderTracked -- 依存関係を追跡:', e.key)
})
onMounted(() => {
console.log('3. onMounted -- DOMが挿入された')
})
onBeforeUpdate(() => {
console.log('4. onBeforeUpdate -- データ変更後、DOM更新の直前')
})
onRenderTriggered((e) => {
console.log(' onRenderTriggered -- 再レンダリングのトリガー:', e.key)
})
onUpdated(() => {
console.log('5. onUpdated -- DOM更新が完了した')
})
onBeforeUnmount(() => {
console.log('6. onBeforeUnmount -- 取り外しの直前')
})
onUnmounted(() => {
console.log('7. onUnmounted -- 取り外し完了')
})
const increment = () => {
count.value++
}
</script>
<template>
<div>
<p>カウント: {{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
このコンポーネントをマウントするとコンソールに1 → 2 → 3の順で出力され、ボタンをクリックすると4 → 5が表示されます。コンポーネントが画面から取り除かれると6 → 7が出力されます。実際に手元で動かして確認すると、フックの発火タイミングが体感的に理解できます。
各フックの役割と具体的なコード例
setup()(旧beforeCreate / created相当)
Composition APIの<script setup>ブロック内のトップレベルコードは、Options APIのbeforeCreateとcreatedを合わせた役割を担います。setup()はコンポーネントインスタンスが作られた直後、propsが解決された段階で同期的に実行されます。
<script setup lang="ts">
import { ref, computed } from 'vue'
// setup()のトップレベル = 旧beforeCreate + created相当
const userId = ref(1)
const displayName = computed(() => `User #${userId.value}`)
// ここではDOMにアクセスできない(まだ描画前)
console.log(document.querySelector('#app')) // null
</script>
注意点: setup()はDOMが存在しない段階で実行されるため、document.querySelectorなどのDOM操作は行えません。DOM操作が必要な場合はonMountedを使います。
async setupの落とし穴
<script setup>の中でawaitをトップレベルに書くと、コンパイラがコンポーネントをasync setup()として扱います。この場合、<Suspense>で囲まないとコンポーネントが描画されません。
<!-- この書き方はSuspenseが必要 -->
<script setup lang="ts">
const res = await fetch('/api/data')
const data = await res.json()
</script>
意図せずトップレベルawaitを使うと表示されないコンポーネントが生まれるため、API呼び出しはonMounted内で行うのが一般的なパターンです。
onBeforeMount / onMounted
onBeforeMountはDOMが描画される直前に呼ばれます。リアクティブデータはすべてセットアップ済みですが、DOMノードはまだ作られていません。
onMountedはDOMツリーが作成され、親コンテナに挿入された後に呼ばれます。すべての同期的な子コンポーネントのマウントも完了した状態です。
<script setup lang="ts">
import { ref, onBeforeMount, onMounted } from 'vue'
const containerRef = ref<HTMLDivElement | null>(null)
const items = ref<string[]>([])
onBeforeMount(() => {
// DOMはまだ存在しない
console.log(containerRef.value) // null
})
onMounted(async () => {
// DOMにアクセスできる
console.log(containerRef.value) // <div>...</div>
// API呼び出しの典型パターン
try {
const res = await fetch('/api/items')
items.value = await res.json()
} catch (e) {
console.error('データ取得に失敗しました:', e)
}
})
</script>
<template>
<div ref="containerRef">
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
onMountedはVue3で最も頻繁に使われるフックです。主な用途は以下のとおりです。
- API呼び出し: コンポーネント表示時のデータ取得
- DOM操作: Canvas、サードパーティライブラリ(Chart.js、D3.js等)の初期化
- イベントリスナーの登録:
window.addEventListenerなどグローバルなリスナー
onMounted内でのasync処理の書き方
onMountedのコールバックに直接asyncキーワードを付けても問題ありません。Vue3はコールバックの戻り値を使用しないため、Promiseが返されても無視されます。ただし、コンポーネントが即座にアンマウントされた場合にフェッチ結果を適用しないよう、ガード処理を入れるとより堅牢になります。
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const data = ref<Record<string, unknown> | null>(null)
let isMounted = true
onMounted(async () => {
const res = await fetch('/api/data')
const json = await res.json()
// アンマウント後にstateを更新しない
if (isMounted) {
data.value = json
}
})
onUnmounted(() => {
isMounted = false
})
</script>
onBeforeUpdate / onUpdated
onBeforeUpdateはリアクティブデータが変更された後、VueがDOMを更新する直前に呼ばれます。DOM更新前の状態にアクセスする必要がある場合(たとえばスクロール位置の保存)に使います。このフック内でリアクティブデータを変更しても安全です。
onUpdatedはDOMの更新が完了した後に呼ばれます。親コンポーネントのonUpdatedは子コンポーネントのonUpdatedの後に呼ばれます。
<script setup lang="ts">
import { ref, onBeforeUpdate, onUpdated, nextTick } from 'vue'
const messages = ref<string[]>(['初期メッセージ'])
const listRef = ref<HTMLUListElement | null>(null)
let prevScrollHeight = 0
onBeforeUpdate(() => {
// DOM更新前のスクロール高さを保存
if (listRef.value) {
prevScrollHeight = listRef.value.scrollHeight
}
})
onUpdated(() => {
// DOM更新後にスクロール位置を調整
if (listRef.value) {
const newScrollHeight = listRef.value.scrollHeight
if (newScrollHeight > prevScrollHeight) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
}
})
const addMessage = () => {
messages.value.push(`メッセージ ${messages.value.length + 1}`)
}
</script>
<template>
<ul ref="listRef" style="max-height: 200px; overflow-y: auto;">
<li v-for="(msg, i) in messages" :key="i">{{ msg }}</li>
</ul>
<button @click="addMessage">メッセージ追加</button>
</template>
重要な注意点: onUpdated内でリアクティブデータを変更すると、再びDOMの更新が発生し、onUpdatedが再度呼ばれます。結果として無限ループに陥る可能性があります。特定のデータ変更後のDOM状態を取得したい場合は、onUpdatedではなくnextTick()を使います。
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const count = ref(0)
const increment = async () => {
count.value++
// この時点ではDOMはまだ古い
await nextTick()
// nextTick後はDOMが最新の状態
console.log(document.getElementById('count')?.textContent)
}
</script>
onBeforeUnmount / onUnmounted
onBeforeUnmountはコンポーネントがDOMから取り外される直前に呼ばれます。この時点ではコンポーネントインスタンスはまだ完全に機能しています。
onUnmountedは取り外しが完了した後に呼ばれます。すべての子コンポーネントのアンマウントとリアクティブエフェクトの停止が完了した状態です。
この2つのフックはリソースの解放に不可欠です。登録したリスナーやタイマーを解除しないとメモリリークの原因になります。
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
const position = ref({ x: 0, y: 0 })
let timerId: ReturnType<typeof setInterval> | null = null
let ws: WebSocket | null = null
let abortController: AbortController | null = null
// パターン1: イベントリスナーの登録と解除
const handleMouseMove = (e: MouseEvent) => {
position.value = { x: e.clientX, y: e.clientY }
}
onMounted(() => {
window.addEventListener('mousemove', handleMouseMove)
// パターン2: タイマーの開始
timerId = setInterval(() => {
console.log('定期処理実行')
}, 5000)
// パターン3: WebSocket接続
ws = new WebSocket('wss://example.com/ws')
ws.onmessage = (e) => {
console.log('受信:', e.data)
}
// パターン4: AbortControllerを使ったfetchの管理
abortController = new AbortController()
})
onBeforeUnmount(() => {
// 進行中のfetchをキャンセル
abortController?.abort()
})
onUnmounted(() => {
// イベントリスナーの解除
window.removeEventListener('mousemove', handleMouseMove)
// タイマーの停止
if (timerId !== null) {
clearInterval(timerId)
}
// WebSocket切断
ws?.close()
})
</script>
<template>
<p>マウス位置: ({{ position.x }}, {{ position.y }})</p>
</template>
メモリリーク防止のために意識すべきリソースをまとめます。
| リソース | 登録タイミング | 解除方法 |
|---|---|---|
| addEventListener | onMounted | onUnmountedでremoveEventListener |
| setInterval / setTimeout | onMounted | onUnmountedでclearInterval / clearTimeout |
| WebSocket | onMounted | onUnmountedでclose() |
| MutationObserver | onMounted | onUnmountedでdisconnect() |
| IntersectionObserver | onMounted | onUnmountedでdisconnect() |
| fetch / XMLHttpRequest | onMounted | onBeforeUnmountでAbortController.abort() |
| サードパーティライブラリ | onMounted | onUnmountedでdestroy()等のクリーンアップ |
onActivated / onDeactivated(KeepAlive専用フック)
<KeepAlive>でラップされたコンポーネントは、表示・非表示が切り替わっても破棄されずメモリ上にキャッシュされます。通常のコンポーネントでは非表示時にonUnmountedが呼ばれますが、KeepAlive内のコンポーネントでは代わりにonDeactivatedが呼ばれ、再表示時にonActivatedが呼ばれます。
<!-- 親コンポーネント -->
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
const currentTab = shallowRef(TabA)
</script>
<template>
<button @click="currentTab = TabA">タブA</button>
<button @click="currentTab = TabB">タブB</button>
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
</template>
<!-- TabA.vue -->
<script setup lang="ts">
import {
ref,
onMounted,
onUnmounted,
onActivated,
onDeactivated,
} from 'vue'
const lastActivatedAt = ref('')
onMounted(() => {
console.log('TabA: onMounted(初回のみ)')
})
onActivated(() => {
// タブが表示されるたびに呼ばれる(初回マウント時も含む)
lastActivatedAt.value = new Date().toLocaleTimeString()
console.log('TabA: onActivated')
})
onDeactivated(() => {
// タブが非表示になるたびに呼ばれる
console.log('TabA: onDeactivated')
})
onUnmounted(() => {
// KeepAlive内では通常呼ばれない
console.log('TabA: onUnmounted')
})
</script>
<template>
<div>
<p>タブA - 最終表示: {{ lastActivatedAt }}</p>
</div>
</template>
onActivatedは初回マウント時にもonMountedの後に呼ばれます。再表示時はマウントは発生せずonActivatedのみが呼ばれます。タブ切り替えUIやページ遷移でコンポーネントの状態を保持したい場面で役立ちます。
KeepAlive + ライフサイクルの発火順序:
- 初回表示:
setup → onBeforeMount → onMounted → onActivated - 非表示:
onDeactivated(onUnmountedは呼ばれない) - 再表示:
onActivatedのみ
onErrorCaptured(エラーバウンダリの構築)
onErrorCapturedは子孫コンポーネントで発生したエラーを親コンポーネントで捕捉するためのフックです。Reactの Error Boundary に相当する機能をVue3で実現できます。
捕捉できるエラーの発生源は以下のとおりです。
- コンポーネントのレンダリング
- イベントハンドラー
- ライフサイクルフック
setup()関数- ウォッチャー
- カスタムディレクティブフック
- トランジションフック
<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const error = ref<Error | null>(null)
const errorInfo = ref('')
onErrorCaptured((err, instance, info) => {
error.value = err instanceof Error ? err : new Error(String(err))
errorInfo.value = info
// falseを返すとエラーの親への伝播を停止する
return false
})
const reset = () => {
error.value = null
errorInfo.value = ''
}
</script>
<template>
<div v-if="error" class="error-fallback">
<h3>エラーが発生しました</h3>
<p>{{ error.message }}</p>
<p>発生元: {{ errorInfo }}</p>
<button @click="reset">再試行</button>
</div>
<slot v-else />
</template>
<!-- 使用側 -->
<template>
<ErrorBoundary>
<SomeUnstableComponent />
</ErrorBoundary>
</template>
onErrorCapturedのコールバックでfalseを返すと、そのエラーは親コンポーネントやグローバルのapp.config.errorHandlerに伝播しません。falseを返さなかった場合、エラーはコンポーネントツリーを上方に伝播し続けます。
onRenderTracked / onRenderTriggered(開発専用デバッグフック)
この2つのフックは開発モード(dev mode)でのみ動作し、本番ビルドでは呼ばれません。コンポーネントの再レンダリングがどのリアクティブ依存関係によって引き起こされたかを特定するためのデバッグ用フックです。
- onRenderTracked: レンダリングエフェクトがリアクティブな依存関係を追跡した時に呼ばれます。初回レンダリング時に、どのリアクティブデータが追跡対象になったかを確認できます。
- onRenderTriggered: リアクティブな依存関係の変更がレンダリングの再実行を引き起こした時に呼ばれます。「なぜ再レンダリングが起きたのか」を突き止めるのに使います。
<script setup lang="ts">
import { ref, onRenderTracked, onRenderTriggered } from 'vue'
const count = ref(0)
const name = ref('Vue')
onRenderTracked((event) => {
// 初回レンダリング時にcount, nameそれぞれについて呼ばれる
console.log('追跡対象:', {
target: event.target,
type: event.type, // 'get' | 'has' | 'iterate'
key: event.key,
})
})
onRenderTriggered((event) => {
// countやnameが変更されて再レンダリングが発生した時
console.log('再レンダリングのトリガー:', {
type: event.type, // 'set' | 'add' | 'delete' | 'clear'
key: event.key,
newValue: event.newValue,
oldValue: event.oldValue,
})
})
</script>
<template>
<p>{{ count }} - {{ name }}</p>
<button @click="count++">カウント+1</button>
</template>
「特定のコンポーネントが頻繁に再レンダリングされるが原因がわからない」という場面で、onRenderTriggeredを仕込むと、どのrefやreactiveプロパティの変更が引き金になっているかをピンポイントで把握できます。
ユースケース別・フック選択ガイド
「何をしたいか」に応じてどのフックを選ぶべきかを表にまとめます。
| やりたいこと | 推奨フック | 理由 |
|---|---|---|
| APIからデータを取得する | onMounted | DOMは不要だがコンポーネント初期化完了後が安全。SSR時はonServerPrefetch |
| DOM要素の参照(ref)を取得する | onMounted | DOMが存在するのはonMounted以降 |
| Canvas / Chart.js等を初期化する | onMounted | ライブラリがDOM要素を必要とするため |
| グローバルイベントリスナーを登録する | onMounted + onUnmounted | 登録と解除をセットで行う |
| タイマーを開始・停止する | onMounted + onUnmounted | clearIntervalを忘れるとメモリリーク |
| WebSocket接続の開始・切断 | onMounted + onUnmounted | close()を忘れると接続が残る |
| DOM更新前のスクロール位置を保存する | onBeforeUpdate | DOM更新前の状態にアクセスできる唯一のタイミング |
| DOM更新後のサイズを測定する | onUpdatedまたはnextTick | 特定のデータ変更に紐づくならnextTickの方が安全 |
| 子コンポーネントのエラーを捕捉する | onErrorCaptured | Error Boundaryパターン |
| KeepAliveキャッシュの再表示時にデータ更新 | onActivated | 再表示のたびに呼ばれる |
| 再レンダリングの原因を調査する | onRenderTriggered | 開発モード限定のデバッグ用 |
| SSR時にサーバーでデータを事前取得する | onServerPrefetch | サーバー側でPromise完了を待ってから描画 |
watchとcomputedはライフサイクルとどう関わるか
watchとcomputedはライフサイクルフックではありませんが、コンポーネントのライフサイクルと密接に関わります。
computedの再計算タイミング
computedはリアクティブな依存関係が変更された時に再計算されます。ただし計算は遅延評価(lazy)で、実際に値が読み取られるまで再計算は実行されません。
<script setup lang="ts">
import { ref, computed } from 'vue'
const firstName = ref('太郎')
const lastName = ref('山田')
// firstNameまたはlastNameが変わった時に再計算される
// ただし実際にfullNameが参照されるまで計算は保留される
const fullName = computed(() => `${lastName.value} ${firstName.value}`)
</script>
<template>
<p>{{ fullName }}</p>
</template>
computedはゲッターに副作用を含めるべきではありません。データの変換・フィルタリングといった純粋な計算に使うのが正しい用途です。
watchの実行タイミング
watchはリアクティブデータの変更を検知して副作用を実行します。デフォルトではコンポーネントのDOMが更新される前にコールバックが実行されます。
<script setup lang="ts">
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref<string[]>([])
// searchQueryが変わるたびに実行される
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery.length < 2) {
results.value = []
return
}
const res = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
results.value = await res.json()
})
</script>
watchにはflushオプションがあり、コールバックの実行タイミングを制御できます。
| flush値 | 実行タイミング | 用途 |
|---|---|---|
'pre'(デフォルト) | DOM更新前 | データの整合性チェック |
'post' | DOM更新後 | DOM更新後の値を読み取りたい場合 |
'sync' | 即時(バッチングなし) | テストやデバッグ用。通常は非推奨 |
<script setup lang="ts">
import { ref, watch } from 'vue'
const count = ref(0)
// DOM更新後に実行される
watch(count, (newVal) => {
// この時点でDOMは更新済み
console.log(document.getElementById('count')?.textContent)
}, { flush: 'post' })
</script>
watchEffectとの違い
watchEffectは依存関係を明示的に指定せず、コールバック内で参照されたリアクティブデータを自動追跡します。setup()の実行時に即座に1回実行される点がwatchとの大きな違いです。
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const userData = ref<Record<string, unknown> | null>(null)
// 即座に実行され、userId.valueが変わるたびに再実行される
watchEffect(async () => {
const res = await fetch(`/api/users/${userId.value}`)
userData.value = await res.json()
})
</script>
ライフサイクルフックとwatch/computedの使い分けの基準:
- computed: 値の算出(副作用なし)。テンプレートやほかのcomputedで参照される派生データ
- watch / watchEffect: リアクティブデータの変更に応じた副作用(API呼び出し、ログ記録、外部ライブラリの更新など)
- ライフサイクルフック: コンポーネントのフェーズに依存する処理(DOM操作、リソースの確保/解放、初期化/クリーンアップ)
カスタムComposableでライフサイクルを再利用する
Vue3 Composition APIの大きな利点の一つが、ライフサイクルフックを含むロジックをComposable関数(useXxxパターン)として切り出し、複数のコンポーネントで再利用できることです。
useWindowSize – ウィンドウサイズの監視
// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(0)
const height = ref(0)
const update = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width, height }
}
<!-- 使用側 -->
<script setup lang="ts">
import { useWindowSize } from '@/composables/useWindowSize'
const { width, height } = useWindowSize()
</script>
<template>
<p>ウィンドウサイズ: {{ width }} x {{ height }}</p>
</template>
useWindowSize内でonMountedとonUnmountedを呼んでいますが、これはComposableがsetup()の同期的な実行コンテキスト内で呼ばれる限り正常に動作します。コンポーネントがマウントされればonMountedが発火し、アンマウントされればonUnmountedが発火します。
useInterval – 自動停止するインターバル
// composables/useInterval.ts
import { onMounted, onUnmounted } from 'vue'
export function useInterval(callback: () => void, ms: number) {
let id: ReturnType<typeof setInterval> | null = null
onMounted(() => {
id = setInterval(callback, ms)
})
onUnmounted(() => {
if (id !== null) {
clearInterval(id)
}
})
}
<script setup lang="ts">
import { ref } from 'vue'
import { useInterval } from '@/composables/useInterval'
const elapsed = ref(0)
useInterval(() => {
elapsed.value++
}, 1000)
</script>
<template>
<p>経過時間: {{ elapsed }}秒</p>
</template>
useIntersectionObserver – 要素の可視性を監視
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
export function useIntersectionObserver(
targetRef: Ref<HTMLElement | null>,
options?: IntersectionObserverInit
) {
const isVisible = ref(false)
let observer: IntersectionObserver | null = null
onMounted(() => {
if (!targetRef.value) return
observer = new IntersectionObserver(([entry]) => {
isVisible.value = entry.isIntersecting
}, options)
observer.observe(targetRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
return { isVisible }
}
Composable内でライフサイクルフックを使う際の制約は1つだけです。Composable関数はsetup()の同期的な実行フロー内で呼び出す必要があります。setTimeoutやawaitの後で呼び出すと、現在のコンポーネントインスタンスとの紐付けが失われ、フックが正しく登録されません。
<script setup lang="ts">
import { useWindowSize } from '@/composables/useWindowSize'
// OK: setup()の同期フロー内
const { width, height } = useWindowSize()
// NG: awaitの後では正しく動作しない可能性がある
// const data = await fetch('/api')
// const { width, height } = useWindowSize() // 紐付けが切れる
</script>
Vue2からVue3へのライフサイクル移行対応表
Vue2からVue3へ移行する際、ライフサイクルフックの名称や扱いが変わった部分があります。
Options APIの変更点
| Vue2 | Vue3 Options API | 変更内容 |
|---|---|---|
| beforeCreate | beforeCreate | 変更なし |
| created | created | 変更なし |
| beforeMount | beforeMount | 変更なし |
| mounted | mounted | 変更なし |
| beforeUpdate | beforeUpdate | 変更なし |
| updated | updated | 変更なし |
| beforeDestroy | beforeUnmount | 名称変更 |
| destroyed | unmounted | 名称変更 |
Options APIではbeforeDestroy → beforeUnmount、destroyed → unmountedの2つが名称変更されました。Vue3でもbeforeDestroy/destroyedはエイリアスとして一時的に動作しますが、非推奨のため新しい名称に置き換えるべきです。
Composition APIへの対応
| Vue2 Options API | Vue3 Composition API | 備考 |
|---|---|---|
| beforeCreate | setup()の先頭 | 専用フック不要 |
| created | setup()の先頭 | 専用フック不要 |
| beforeMount | onBeforeMount | |
| mounted | onMounted | |
| beforeUpdate | onBeforeUpdate | |
| updated | onUpdated | |
| beforeDestroy | onBeforeUnmount | 名称も変更 |
| destroyed | onUnmounted | 名称も変更 |
| activated | onActivated | |
| deactivated | onDeactivated | |
| errorCaptured | onErrorCaptured | |
| – | onRenderTracked | Vue3で新規追加 |
| – | onRenderTriggered | Vue3で新規追加 |
| – | onServerPrefetch | Vue3で新規追加 |
移行時に特に注意すべきポイント
1. beforeCreate/createdの代替
Vue2でcreatedにAPI呼び出しを書いていた場合、Vue3のComposition APIではsetup()のトップレベルまたはonMountedに移動します。
// Vue2
export default {
created() {
this.fetchData()
}
}
// Vue3 Composition API
// setup()のトップレベルに書くとcreated相当のタイミングで実行される
// ただしAPI呼び出しはonMountedに書くのが一般的
2. thisへのアクセス
Composition APIにはOptions APIのthisに相当するものがありません。this.$refsはref()で、this.$emitはdefineEmits()で、this.$routerはuseRouter()で代替します。
3. VNodeライフサイクルイベントの変更
Vue2ではテンプレート内で@hook:mountedのようにVNodeのライフサイクルイベントを監視できましたが、Vue3では@vnode-mountedに変更されています(参照: Vue3移行ガイド)。
SSR環境でのライフサイクルの違い
Nuxt3やVue3のSSR(Server-Side Rendering)環境では、ライフサイクルフックの挙動がクライアントサイドとは異なります。
サーバー側で呼ばれるフック・呼ばれないフック
| フック | サーバー | クライアント |
|---|---|---|
| setup() | 呼ばれる | 呼ばれる |
| onServerPrefetch | 呼ばれる | 呼ばれない |
| onBeforeMount | 呼ばれない | 呼ばれる |
| onMounted | 呼ばれない | 呼ばれる |
| onBeforeUpdate | 呼ばれない | 呼ばれる |
| onUpdated | 呼ばれない | 呼ばれる |
| onBeforeUnmount | 呼ばれない | 呼ばれる |
| onUnmounted | 呼ばれない | 呼ばれる |
| onActivated | 呼ばれない | 呼ばれる |
| onDeactivated | 呼ばれない | 呼ばれる |
サーバー側ではDOMが存在しないため、DOM操作に関連するフック(onMounted以降)はすべてスキップされます。setup()とその中の同期コードのみがサーバーで実行されます。
onServerPrefetchによるサーバーデータ取得
onServerPrefetchはSSR専用のフックで、サーバーレンダリング時にPromiseを返す非同期処理を実行し、その完了を待ってからHTMLを描画します。
<script setup lang="ts">
import { ref, onServerPrefetch, onMounted } from 'vue'
interface Article {
id: number
title: string
body: string
}
const article = ref<Article | null>(null)
// サーバー側: レンダリング前にデータ取得(高速)
onServerPrefetch(async () => {
article.value = await fetchArticle()
})
// クライアント側: ハイドレーション時にデータがなければ取得
onMounted(async () => {
if (!article.value) {
article.value = await fetchArticle()
}
})
async function fetchArticle(): Promise<Article> {
const res = await fetch('https://api.example.com/articles/1')
return res.json()
}
</script>
<template>
<article v-if="article">
<h1>{{ article.title }}</h1>
<p>{{ article.body }}</p>
</article>
<p v-else>読み込み中...</p>
</template>
このパターンでは、サーバーでデータ取得を完了してからHTMLを返すため、クライアント側では初期表示の時点でデータが入った状態のHTMLが得られます。onMountedのガード条件(if (!article.value))は、クライアントサイドでのみ動的にレンダリングされるケース(ルーティング遷移後など)に対応するためのフォールバックです。
SSR環境での注意点
setup()内でブラウザAPIを呼ばない:window、document、localStorageなどはサーバーに存在しないため、参照するとエラーになります。これらの操作はonMounted内に限定します- 副作用の管理:
setup()でsetIntervalやaddEventListenerを呼ぶと、サーバー側で解放されずリソースリークの原因になります - 条件分岐による防御: サーバーとクライアントで処理を分けたい場合は
import.meta.env.SSR(Vite環境)やtypeof window !== 'undefined'で判定します
よくあるミスとトラブルシューティング
1. onMounted内でtemplate refがnullになる
v-ifで条件付きレンダリングされている要素のrefは、条件がfalseの場合nullになります。
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
const isVisible = ref(false)
const elementRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
// isVisibleがfalseなら、elementRefはnull
console.log(elementRef.value) // null
})
// v-ifの要素には、表示後にwatchで対応する
watch(isVisible, async (val) => {
if (val) {
await nextTick()
console.log(elementRef.value) // <div>...</div>
}
})
</script>
<template>
<button @click="isVisible = !isVisible">切り替え</button>
<div v-if="isVisible" ref="elementRef">条件付き要素</div>
</template>
2. onUpdatedでの無限ループ
onUpdated内でリアクティブデータを変更すると「データ変更 → DOM更新 → onUpdated → データ変更 → …」の無限ループが発生します。
<script setup lang="ts">
import { ref, onUpdated } from 'vue'
const count = ref(0)
// NG: 無限ループになる
onUpdated(() => {
count.value++ // この変更がさらにDOMを更新し、onUpdatedが再び呼ばれる
})
// OK: 条件付きで変更するか、nextTickを使う
onUpdated(() => {
// 条件を設けてループを防止
if (count.value < 10) {
count.value++
}
})
</script>
根本的にはonUpdated内でのstate変更を避け、watchやnextTickで代替するのがベストプラクティスです。
3. setupでのasync/awaitとライフサイクルフックの登録順
awaitの後に書いたライフサイクルフックは、コンポーネントインスタンスとの紐付けが切れる可能性があります。
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
// OK: awaitの前に登録
onMounted(() => {
console.log('マウント完了')
})
const data = await fetch('/api/data') // トップレベルawait
// NG: awaitの後に登録すると紐付けが失われる可能性がある
onUnmounted(() => {
console.log('これは呼ばれない可能性がある')
})
</script>
対策: すべてのライフサイクルフックはawaitよりも前に登録します。
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const data = ref(null)
// すべてのフックをawaitの前に登録
onMounted(() => {
console.log('マウント完了')
})
onUnmounted(() => {
console.log('アンマウント完了')
})
// フック登録後にawaitを実行
const res = await fetch('/api/data')
data.value = await res.json()
</script>
4. メモリリークの典型パターンと対処
メモリリークが起きやすいケースと対処法をまとめます。
パターンA: イベントリスナーの解除忘れ
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
const handleScroll = () => { /* ... */ }
onMounted(() => {
// 登録
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
// 解除を忘れるとコンポーネント破棄後もリスナーが残る
window.removeEventListener('scroll', handleScroll)
})
</script>
パターンB: クロージャによるコンポーネント参照の保持
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const largeData = ref<ArrayBuffer | null>(null)
onMounted(() => {
largeData.value = new ArrayBuffer(1024 * 1024 * 10) // 10MB
})
onUnmounted(() => {
// 大きなデータへの参照を明示的に解放
largeData.value = null
})
</script>
パターンC: サードパーティライブラリのクリーンアップ
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// Chart.jsの例
import { Chart } from 'chart.js'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
onMounted(() => {
if (canvasRef.value) {
chartInstance = new Chart(canvasRef.value, {
type: 'bar',
data: { /* ... */ },
})
}
})
onUnmounted(() => {
// ライブラリ独自のdestroyメソッドを呼ぶ
chartInstance?.destroy()
chartInstance = null
})
</script>
<template>
<canvas ref="canvasRef" />
</template>
5. 親子コンポーネントのフック発火順序
親と子のライフサイクルフックは以下の順序で発火します。
マウント時:
- 親の
setup() - 親の
onBeforeMount - 子の
setup() - 子の
onBeforeMount - 子の
onMounted - 親の
onMounted
更新時:
- 親の
onBeforeUpdate - 子の
onBeforeUpdate - 子の
onUpdated - 親の
onUpdated
アンマウント時:
- 親の
onBeforeUnmount - 子の
onBeforeUnmount - 子の
onUnmounted - 親の
onUnmounted
つまり親のonMountedが呼ばれた時点では、すべての同期的な子コンポーネントのマウントが完了しています。親のonMounted内で子のDOM要素にアクセスしても問題ありません。
まとめ
Vue3のライフサイクルは、コンポーネントの「生成 → マウント → 更新 → アンマウント」という流れの各段階にフックを差し込める仕組みです。
- setup() が
beforeCreate/created相当の初期化処理を担い、Composition APIの起点になります - onMounted はDOM操作・API通信・外部ライブラリ初期化の標準的なエントリポイントです
- onUnmounted ではイベントリスナー・タイマー・接続のクリーンアップを必ず行い、メモリリークを防ぎます
- onActivated / onDeactivated はKeepAliveと組み合わせて、キャッシュコンポーネントの表示・非表示イベントを捕捉します
- onErrorCaptured で子孫のエラーを一括捕捉するError Boundaryパターンが実現できます
- onRenderTracked / onRenderTriggered は開発モードで不要な再レンダリングの原因を特定するのに役立ちます
- onServerPrefetch はSSR環境でサーバー側のデータプリフェッチを可能にします
- watch / computed はライフサイクルフックではなくリアクティビティシステムの一部ですが、フックと補完的な関係にあります
各フックの公式リファレンスは Vue.js Composition API: ライフサイクルフック に詳しくまとめられています。ライフサイクルダイアグラムの図は ライフサイクルフック | Vue.js で確認できます。
