Katashin .info

Vue テンプレートが再描画されても onUpdated ライフサイクルフックが実行されないケース

Vue コンポーネントで DOM 要素の更新を検知したい場合、onUpdated ライフサイクルフックが使えます。例えば以下のコンポーネントでは、ボタンをクリックするたびに count の値が更新され、その値が表示されている DOM 要素も更新されます。その後、onUpdated に登録されたコールバックが実行され、コンソールに Updated と出力されます。

<script setup>
import { ref, onUpdated } from 'vue'

const count = ref(0)

onUpdated(() => {
  // DOM が更新されたあとに呼ばれる
  console.log('Updated')
})
</script>

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

似たライフサイクルフックに onBeforeUpdate もあります。こちらは DOM の更新直前に呼ばれるコールバックです。

この onUpdated や onBeforeUpdate が特定のケースでは実行されない場合があります。この記事ではその現象がどういった時に、なぜ起きるのかを解説し、そのようなケースではどういったコードを書けばよいかを提案します。

子コンポーネントの slot 内の更新では実行されない #

先に挙げたコードは以下のように、更新対象の DOM 要素を子コンポーネントの slot の中に配置すると onUpdated フックが実行されなくなります。

<script setup>
import { ref, onUpdated } from 'vue'
import CompWithSlot from './CompWithSlot.vue'

const count = ref(0)

onUpdated(() => {
  // <button> の中の数値が更新されても呼ばれない
  console.log('Updated')
})
</script>

<template>
  <CompWithSlot>
    <!-- slot の中に配置する -->
    <button @click="count++">{{ count }}</button>
  </CompWithSlot>
</template>

<CompWithSlot> は slot で渡された内容を描画するだけのシンプルなコンポーネントです。

<!-- CompWithSlot.vue -->
<template>
  <div>
    <slot></slot>
  </div>
</template>

onUpdated フックが実行されない理由は、更新される部分が <CompWithSlot> の slot 内のみであるため、その更新が <CompWithSlot> の update として扱われるためです。この挙動はパフォーマンスの最適化として Vue 3 から取り入れられています。

onUpdated が実行されないケースの対応策 #

この現象に対応する方法として以下の3つが挙げられます。

  1. かわりに watch で値を監視
  2. 子コンポーネントの updated イベントを監視
  3. テンプレート参照を関数で行わない

かわりに watch で値を監視 #

多くの場合、コンポーネントの更新を監視するよりも、特定の値の更新に応じて処理をするので十分です。以下は onUpdated のかわりに watch を使った例です。

<script setup>
import { ref, watch } from 'vue'
import CompWithSlot from './CompWithSlot.vue'

const count = ref(0)

// count の値が変わったら処理を行う
watch(
  count,
  () => {
    // 更新対象が slot 内でも更新される
    console.log('Updated')
  },
  {
    // DOM 要素の更新後に処理を行う
    flush: 'post',
  },
)
</script>

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

watch のオプション (第三引数に渡す値) には flush という、コールバックが実行されるタイミングの設定があります。デフォルトでは flush: 'pre'、つまり値が更新された後で DOM 要素が更新される前にコールバックが実行されます。DOM 要素が更新された後に処理を行いたい場合は flush: 'post' を指定する必要があります。

ウォッチャー | Vue.js #コールバックが実行されるタイミング

子コンポーネントの updated イベントを監視 #

onUpdated フックが実行されない理由は、その更新処理が子コンポーネントの update として扱われるためです。その子コンポーネントの update を親コンポーネントから監視できれば、slot 内の DOM 要素が更新された時にも処理を行えます。

子コンポーネントのライフサイクルフックを監視するには @vue:updated イベントを使います。Vue ではすべてのコンポーネントは「@vue:ライフサイクル名」という名前のイベントをトリガーします。@vue:updated を監視するように修正すると以下のようになります。

<script setup>
import { ref, onUpdated } from 'vue'
import CompWithSlot from './CompWithSlot.vue'

const count = ref(0)

// このコンポーネントの更新も監視する
onUpdated(updatedListener)

function updatedListener() {
  // 更新対象が <CompWithSlot> の slot 内でも DOM が更新されたあとに呼ばれる
  console.log('Updated')
}
</script>

<template>
  <!-- 子コンポーネントの updated ライフサイクルを監視 -->
  <CompWithSlot @vue:updated="updatedListener">
    <button @click="count++">{{ count }}</button>
  </CompWithSlot>
</template>

テンプレート参照を関数で行わない #

onBeforeUpdate フックが実行されなくて困るケースとして、テンプレート参照を関数を使って行っている場合があります。テンプレート参照とは要素やコンポーネントに ref 属性を付与して、そのインスタンスにアクセスする機能です。Vue 3 ではこの ref 属性に関数を渡して、インスタンスを保持する処理を自分で書くことができます。

<!-- ref 属性に関数を渡す -->
<button
  :ref="
    (instance) => {
      // <button> 要素のインスタンスを保持する
      buttonRef = instance
    }
  "
>
  Hello!
</button>

単一の要素に対するテンプレート参照であればインスタンスを代入する関数を書くだけでよいですが、v-for の繰り返し要素の場合は onBeforeUpdate で保持していた値をリセットする処理が必要になります。この場合、slot 内に v-for の繰り返しがある時に onBeforeUpdate が実行されないことが問題となります。

<script setup>
import { ref, onBeforeUpdate, watch } from 'vue'
import CompWithSlot from './CompWithSlot.vue'

const list = ref([])

// テンプレート参照でインスタンスを保存する先
const refList = ref([])

onBeforeUpdate(() => {
  // DOM 要素が更新される前に前回保持していた値をリセットする
  // しかし、v-for が slot 内にあるので、DOM 要素の更新時に実行されない
  refList.value = []
})

function setRef(instance) {
  if (instance) {
    // v-for の要素の個数分この関数が呼ばれるので、
    // refList にインスタンスを追加して保持する
    refList.value.push(instance)
  }
}

watch(
  () => list.value.length,
  () => {
    // refList の中身をチェック。onBeforeUpdate が実行されないので、
    // refList の項目が増え続けてしまう
    console.log(refList.value)
  },
  {
    flush: 'post',
  },
)
</script>

<template>
  <CompWithSlot>
    <button @click="list.push(Math.random())">Add</button>

    <!-- v-for の対象要素に関数によるテンプレート参照を使う -->
    <p v-for="(item, i) of list" :key="i" :ref="setRef">
      {{ item }}
    </p>
  </CompWithSlot>
</template>

ほとんどのケースでは関数によるテンプレート参照ではなく、useTemplateRef を使うので十分です。useTemplateRef であれば、単一の要素でも v-for で繰り返された要素でも違いを気にすることなく書けますし、slot の中に記述していても問題なく正しい値が保持されます。

<script setup>
import { ref, useTemplateRef, watch } from 'vue'
import CompWithSlot from './CompWithSlot.vue'

const list = ref([])

// useTemplateRef を使うと自動的に要素の参照が保持される
const refList = useTemplateRef('refList')

watch(
  () => list.value.length,
  () => {
    // refList の中身をチェック
    console.log(refList.value)
  },
  {
    flush: 'post',
  },
)
</script>

<template>
  <CompWithSlot>
    <button @click="list.push(Math.random())">Add</button>

    <!-- ref 属性に useTemplateRef で指定した文字列を入れる -->
    <p v-for="(item, i) of list" :key="i" ref="refList">
      {{ item }}
    </p>
  </CompWithSlot>
</template>

結論 #

Vue 3 ではパフォーマンス最適化のため、子コンポーネントの slot 内の更新は親コンポーネントの更新として扱われず、その結果 onUpdated や onBeforeUpdate ライフサイクルフックが実行されない場合があります。この記事では以下の3つの方法による対応策を紹介しました。

  1. かわりに watch を使用して DOM 要素の更新を引き起こす特定の値の更新を監視する
  2. 子コンポーネントの @vue:updated イベントを監視する
  3. テンプレート参照を関数で行わず、useTemplateRef を使用する

onUpdated/onBeforeUpdate は watch で置き換えられることが多いので他のライフサイクルフックよりも使う機会が少ないかもしれません。実際に筆者が書いている Vue のコードには update 系のフックはほとんどありませんでした。もし update 系のフックが意図した通りに動かない場合は、更新対象が子コンポーネントの slot 内にないかを確認し、当てはまる場合はこの記事で紹介した対応策を試してみてください。