メインコンテンツまでスキップ

ライフサイクル: フックの順序と処理の置き場所

コンポーネントには生成から破棄までの流れがあり、その途中に処理を差し込めます。DOM ができた直後に要素の幅を測る、破棄されるときにイベントリスナーを外す、といった用途です。

差し込む場所を間違えると、要素がまだ無い時点で触ろうとして null になります。逆に後片付けを書き忘れると、コンポーネントが消えた後もリスナーが残ります。この章では、どのフックがいつ走るかを順序として押さえます。

この章で学ぶこと​

  • フックを登録できる場所の制約を説明できる
  • 親と子でフックが走る順序を把握して、DOM に触る位置を選べる
  • 後片付けをどこに書くか判断できる

フックはどこで登録できるか​

公式ドキュメントは「フックは setup の段階で同期に呼ぶこと」を求めています。ただし script setup を使っている場合、トップレベルの await をまたいだ後でも登録されます。

components/Panel.vue
<script setup lang="ts">
import { onMounted } from 'vue'

onMounted(() => console.log('前で登録'))

const res = await fetch('/api/data')

// これも登録される
onMounted(() => console.log('後で登録'))
</script>

このコンポーネントはトップレベルに await を持つので、親側で Suspense に包まないと描画されません。

登録が成り立つのは、コンパイラが await の前後でコンポーネントの文脈を保存し、復元しているためです。script setup は素の JavaScript として実行されるのではなく、コンパイル時に書き換えられています (6 章)。この書き換えが await をまたいだ登録を可能にしています。

注意が要るのは、script setup ではなく手書きの async setup() を使う場合です。こちらには復元の仕組みがないので、最初の await より前に登録する必要があります。開発ビルドの警告文もこの場合を指しています。

マウントまでの流れ​

親と子を並べて、実際に走る順序を測るとこうなります。

親: setup
親: onBeforeMount
子: setup
子: onBeforeMount
子: onMounted
親: onMounted

ここから読み取れることを挙げます。

setup が最初に走ります。 Options API を併用した場合でも setup は beforeCreate より前です (公式ドキュメントに明記があります)。「初期化の途中で走る」のではなく「最初に走る」と覚えます。

onMounted は子から親へ完了します。 親の onMounted の時点では子のマウントが済んでいるので、親から子の DOM に触れます。逆に親の onBeforeMount の時点では子はまだ存在しません。

初回のマウントでは順序と DOM の有無が一致します。更新のときは一致しないので、後の節で分けて扱います。

子がトップレベルの await を持つ場合は、Suspense をどこに置いたかで変わります。この形のコンポーネントは Suspense で包む必要があります (13 章で扱います)。

Suspense が親と子の間にあると、親の onMounted の時点で子の中身はありません。 親は自分のマウントを待たずに進み、子は解決してから現れます。

<!-- 親の onMounted では .leaf が無い -->
<div ref="box">
<Suspense><AsyncLeaf /></Suspense>
</div>

Suspense が親子の外側にあるなら、親の onMounted の時点で子の DOM は在ります。 親自身も解決を待つ側に入るので、onMounted は解決後に走ります。

<!-- Wrapper の onMounted では .leaf が在る -->
<Suspense>
<Wrapper><AsyncLeaf /></Wrapper>
</Suspense>
components/Wrapper.vue
<template>
<div ref="box"><slot /></div>
</template>

子を slot で受け取る形と、Wrapper が自分のテンプレートに AsyncLeaf を書く形の両方を実測しましたが、結果は同じでした。親から子の DOM に触る処理を書くなら、Suspense の位置を確かめます。

components/Chart.vue
<script setup lang="ts">
import { onBeforeMount, onMounted, useTemplateRef } from 'vue'

const canvasRef = useTemplateRef('canvas')

onBeforeMount(() => {
// まだ DOM が無いので null
console.log(canvasRef.value)
})

onMounted(() => {
// ここでは触れる
const ctx = canvasRef.value?.getContext('2d')
})
</script>

<template>
<canvas ref="canvas" />
</template>

DOM の寸法を測る、外部ライブラリを要素に紐づける、フォーカスを当てるといった処理は onMounted に置きます。

更新の流れ​

状態が変わって再描画されるとき、onBeforeUpdate と onUpdated が走ります。

子に変化が届かない場合 (親の状態だけを変え、子の props は変えない場合) は親のフックだけが走ります。

親: onBeforeUpdate
親: onUpdated

子が更新されるかどうかは、子の描画がその値を読んでいるかで決まります。 親が持つ状態を変えても、子がそれを読んでいなければ子の更新フックは走りません。props で変化が届く場合はこうなります。

親: onBeforeUpdate
子: onBeforeUpdate
子: onUpdated
親: onUpdated

このとき onUpdated は子から親へ完了します。

この順序は、子が親から新しい props を受け取って更新される場合のものです。 子の再描画が親の再描画の内側で起きるためです。

子が自分で同じ状態を読んでいる場合は逆になります。 親と子がモジュールに置いた ref や store をそれぞれ読んでいると、子の再描画は親とは別に予約されるので、onUpdated は親が先に走ります。

親: onBeforeUpdate
子: onBeforeUpdate
親: onUpdated
子: onUpdated

判定の基準は「子の描画が何を読んでいるか」です。親から渡された props を読んでいるなら子の更新は親の内側、子自身が外の状態を読んでいるなら別枠、と分かれます。この構成では props で変化を渡していない子の更新フックも走ります。

slot の内容もこちら側です。 親のテンプレートに書いた {{ msg }} を子の slot へ渡すと、その式は子の描画の中で評価されるので、msg を読んでいるのは子です。実測すると子の更新フックだけが走り、親の更新フックは走りません。

子: onBeforeUpdate
子: onUpdated

親のテンプレートに書いてあるからといって、親が読んでいるとは限りません。

どちらの順序でも、親の onUpdated の時点で子の DOM は更新済みです。 onUpdated は再描画をすべて流し終えた後にまとめて呼ばれる種類のフックなので、フックの呼び出し順が親から先になっても、DOM の書き換えはその前に終わっています。実測すると、親の onBeforeUpdate では子の表示は古い値、onUpdated では新しい値になります。

onUpdated の中で状態を変えると、また更新が走って無限に繰り返します。ここに書くのは DOM を読む処理だけにします。値の変化に応じて何かをしたいなら、5 章の watch を使います。監視対象が明示されるので、意図しない再実行を避けられます。

破棄の流れ​

親: onBeforeUnmount
子: onBeforeUnmount
子: onUnmounted
親: onUnmounted

onBeforeUnmount は親から子へ、onUnmounted は子から親へ進みます。まだ DOM が残っている状態で処理したいなら onBeforeUnmount、完全に外れた後でよいなら onUnmounted です。

後片付けの典型は、自分で登録したものを外す処理です。

components/ResizeWatcher.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

const width = ref(0)

const handleResize = () => {
width.value = window.innerWidth
}

onMounted(() => {
window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>

<template>
<p>{{ width }}px</p>
</template>

登録と解除を対にして書きます。この形が増えてくるなら、8 章で扱う Composable に切り出すと、対を 1 か所にまとめられます。

KeepAlive がキャッシュを保つ間は破棄されない​

KeepAlive で包んだコンポーネントは、画面から消えてもキャッシュに残る限り破棄されません。状態も保たれます。

キャッシュに残る限り、表示と非表示の切り替えで走るフックが変わります。キャッシュから外れる条件は次の節で扱います。

操作走るフック
初回の表示onMounted → onActivated
非表示にするonDeactivated のみ (onUnmounted は走らない)
再表示するonActivated のみ (onMounted は走らない)

後片付けを onUnmounted だけに書くと、キャッシュされている間もリスナーが残ります。 上の ResizeWatcher を KeepAlive の中に置くと、非表示のあいだも resize に反応して width が更新され続けます。

KeepAlive の下で使う可能性があるなら、onDeactivated でも解除するか、そもそもキャッシュ対象から外します。

キャッシュ対象から外れるときの挙動​

上の表が当てはまるのは、キャッシュに残り続ける間です。外れるときの挙動は経路ごとに違うので、実測した 3 通りを並べます。

max の容量を超えて押し出された場合。 押し出される側は onDeactivated を飛ばして直接 onUnmounted に進みます。

<KeepAlive :max="1">
<component :is="current" />
</KeepAlive>
A: onMounted
A: onActivated
A: onUnmounted
B: onMounted
B: onActivated

押し出される側はキャッシュ対象の印を外されるので、非表示になる時点で「キャッシュへ退避する」経路に入らず、そのまま破棄されます。

include から外した場合は、そのときの表示状態で変わります。 非表示でキャッシュに載っていたものは、その場で破棄されて onUnmounted が走ります。表示中のものは印が外れるだけで表示が続き、次に非表示になった時点で破棄されます。

KeepAlive 自体を破棄した場合は、onDeactivated の後に onUnmounted が走ります。

A: onDeactivated
A: onUnmounted

後片付けを確実にしたいなら、onDeactivated と onUnmounted の両方に置きます。max で押し出される経路では onDeactivated が走らないので、onDeactivated だけでは漏れます。逆にキャッシュされたまま画面から消えている間もリソースを手放したいなら、onUnmounted だけでは間に合いません。

どこに何を書くか​

やりたいこと置き場所
初期値の計算、watch の登録script setup の直下
DOM の寸法測定、外部ライブラリの初期化、フォーカスonMounted
更新後の DOM を読むonUpdated
DOM が残っているうちの後片付けonBeforeUnmount
イベントリスナーやタイマーの解除onUnmounted
値の変化への反応watch / watchEffect (5 章)

データの取得は script setup の直下でも onMounted でも書けます。違いはサーバー側で描画する場合に出ます。script setup の直下なら描画前に走り、onMounted はブラウザでしか走りません (19 章で扱います)。

デバッグ用のフック​

再描画の原因を追いたいときは onRenderTracked と onRenderTriggered が使えます。どの値が依存として記録され、どの変更が再描画を引き起こしたかを受け取れます。

<script setup lang="ts">
import { onRenderTriggered } from 'vue'

onRenderTriggered((event) => {
// 何が再描画を起こしたか
console.log(event.key, event.type)
})
</script>

どちらも開発モード専用で、本番ビルドには含まれません。サーバー側の描画でも呼ばれません。

まとめ​

  • script setup ならトップレベルの await をまたいでもフックは登録されます。 コンパイラが文脈を保存して復元しているためです。手書きの async setup() では復元されないので、最初の await より前に登録します
  • setup は最初に走ります。 Options API を併用しても beforeCreate より前です
  • onMounted は子から親へ完了し、親の時点では子の DOM が済んでいます。ただし子がトップレベルの await を持つ場合は Suspense の位置で変わります。Suspense が親と子の間にあれば親の onMounted に子の中身はなく、親子の外側にあれば在ります
  • onUpdated が子から親へ完了するのは、子が親から新しい props を受け取って更新される場合です。 子が自分で同じ状態を読んでいると親が先に走ります。判定の基準は「子が更新される理由」で、props か子自身の依存かを見ます
  • どちらの順序でも、親の onUpdated の時点で子の DOM は更新済みです。 onUpdated は再描画をすべて流し終えた後に呼ばれるので、フックの順序と DOM の鮮度は別の話です
  • 更新フックが走るかは、その描画が変わった値を読んでいるかで決まります。子が外部の状態を読んでいる場合や、slot の内容として受け取っている場合は、props で変化を渡していなくても子だけが走ります
  • onUpdated の中で状態を変えると更新が繰り返します。値の変化に反応したいなら watch を使います
  • onBeforeUnmount は親から子へ、onUnmounted は子から親へ進みます。DOM が要るなら前者を使います
  • 自分で登録したリスナーやタイマーは、登録と解除を対にして書きます
  • KeepAlive がキャッシュを保つ間は破棄されません。 onUnmounted の代わりに onDeactivated が走るので、後片付けを onUnmounted だけに書くとキャッシュ中もリスナーが残ります
  • キャッシュ対象から外れるときの挙動は経路ごとに違います。 max で押し出されると onDeactivated を飛ばして onUnmounted に進みます。include から外した場合は、非表示なら即 onUnmounted、表示中なら次に消えるまで持ち越します。KeepAlive 自体の破棄では onDeactivated → onUnmounted です
  • 後片付けを片方のフックだけに置くと、上のどこかの経路で漏れます
  • onRenderTracked / onRenderTriggered は再描画の原因を追う道具で、開発モード専用です
関連リファレンス

次に読む​