Katashin .info

Vue.js パフォーマンスチューニングの最終手段 computedEager

「これを computed のかわりに使うとパフォーマンスが良くなる」

先日 computedEager というユーティリティが使われているのを見て、使っている理由を聞いたらこういった答えをいただきました。たしかに computedEager でパフォーマンスを改善できる場合がありますが、使い方を間違えると逆に悪化させてしまう可能性もあります。この記事では computedEager の仕組みを深掘りしていき、どのような使い方が有効なのかを解説します。

computedEager とは #

computedEager とは VueUse から提供されているユーティリティ関数の一つです。computedEager のドキュメントにはデモや computed との使い分けが書かれていますが、正直、初見ですぐにこれを理解できる人はあまりいなさそうです。以降、computedEager でパフォーマンスが改善するケースを見ながら解説していきます。

computedEager でパフォーマンスが改善するケース #

useTimestamp という、同じく VueUse から提供されている関数と computedEager は相性がいいです。useTimestamp は現在の時刻をミリ秒で表した値を持つ ref オブジェクトを返し、その値はデフォルトで毎フレーム更新されます。

以下の例では useTimestamp から得たタイムスタンプを、currentTime という算出プロパティ(computed)で文字列にフォーマットし、<template> 内で参照することで画面に表示しています。現在時刻の表示は1秒ごとに更新されますが、描画はそれ以上に頻繁に発生しています。このような単純な例では実感できませんが、再描画の回数が多くなるとそれだけ処理も増え、テンプレートが大きくなるとフレームレートも落ちていきます。

ソースコード: useTimestamp と computed を一緒に使うケース
<script setup>
import { ref, computed, onUpdated } from 'vue'
import { useTimestamp } from '@vueuse/core'

// 現在のタイムスタンプ
// 毎フレーム更新される
const timestamp = useTimestamp()

// 現在の時刻を時間:分:秒のフォーマットで表示
const currentTime = computed(() => {
  const date = new Date(timestamp.value)
  const hours = ('0' + date.getHours()).slice(-2)
  const minutes = ('0' + date.getMinutes()).slice(-2)
  const seconds = ('0' + date.getSeconds()).slice(-2)
  return `${hours}:${minutes}:${seconds}`
})

// このコンポーネントが描画された回数を計測
const countRef = ref(null)
const count = ref(1)
onUpdated(() => {
  countRef.value.textContent = ++count.value
})
</script>

<template>
  <p>現在時刻: {{ currentTime }}</p>
  <p>描画回数: <span ref="countRef">1</span></p>
</template>

上の例の computedcomputedEager に変えたのが以下のデモです。描画が現在時刻の更新と完全に同期し、1秒ごとに行われます。computedEager を使ったほうが不必要な再描画がなくなり、パフォーマンスが上がると言えます。

ソースコード: useTimestamp と computedEager を一緒に使うケース
<script setup>
import { ref, onUpdated } from 'vue'
import { useTimestamp, computedEager } from '@vueuse/core'

// 現在のタイムスタンプ
// 毎フレーム更新される
const timestamp = useTimestamp()

// 現在の時刻を時間:分:秒のフォーマットで表示
// computedEager に変更したことで、timestamp がどれだけ更新されても
// 現在時刻の文字列が変わらない限り再描画されない
const currentTime = computedEager(() => {
  const date = new Date(timestamp.value)
  const hours = ('0' + date.getHours()).slice(-2)
  const minutes = ('0' + date.getMinutes()).slice(-2)
  const seconds = ('0' + date.getSeconds()).slice(-2)
  return `${hours}:${minutes}:${seconds}`
})

// このコンポーネントが描画された回数を計測
const countRef = ref(null)
const count = ref(1)
onUpdated(() => {
  countRef.value.textContent = ++count.value
})
</script>

<template>
  <p>現在時刻: {{ currentTime }}</p>
  <p>描画回数: <span ref="countRef">1</span></p>
</template>

なぜパフォーマンスが改善するのか #

なぜ上記のようなケースでは computedEager でパフォーマンスが改善するのか、それを理解するためには普段あまり気にしない computed の挙動について理解する必要があります。

computed の特徴は値のキャッシュと遅延計算 #

computed によって計算された値は、計算処理の中で依存している他の値が更新されない限りキャッシュされ続けます。依存している値が更新された時、computed の値も再計算されますが、この計算は即座に行われるのではなく、必要なタイミングになってから行われます。

先ほどの useTimestamp の例では、timestamp の値が更新されると、それに依存する currentTime をたどり、さらにそれに依存するテンプレートの再描画が開始されます。そして、テンプレートの中で currentTime の値を使う時に再計算が行われます。

computed を使った時の処理の流れを表した図

この遅延計算の仕組みは基本的にはパフォーマンス上良いとされています。例えば、currentTimev-if によって非表示になった時、その値は使われていないため、いくら timestamp が更新されても値の再計算は行われません。

v-if で currentTime を使っている部分が描画されない時の処理の流れを表した図

しかし、遅延計算を行うということは、テンプレートの再描画が行われる時にしか currentTime の値の変化がわかりません。先ほどの例では、currentTime の値が変わったときにだけ再描画をしたいですが、computed を使う場合は再描画が始まった後でないと新しい値がわかりません。なので、とにかく依存関係に何らかの更新が行われた時には再描画をするという挙動になります。これが不必要な再描画が行われる原因です。

computedEager は先に計算し値の更新をチェック #

computed が遅延計算なのに対して、computedEager は先に計算を行います。currentTime の再計算がテンプレートの再描画が行われる前になり、その値が実際に変わった場合のみ再描画を行います。

computedEager を使った時の処理の流れを表した図

computedEager の実装はどうなっているのでしょうか?Github 上で見てみるとshallowRef でキャッシュを持っておき、watchEffect で依存している値が更新された時に引数の fn を再計算して値を更新していることがわかります。以下が簡略化したコードです。

import { shallowRef, watchEffect, readonly } from 'vue'

export function computedEager(fn) {
  // computedEager でキャッシュしている実際の値
  const result = shallowRef()

  // fn 内で使っている依存関係が更新された時に実行する
  watchEffect(
    () => {
      // 即座に値の再計算を行い、キャッシュを更新する
      result.value = fn()
    },
    {
      flush: 'sync',
    },
  )

  return readonly(result)
}

Vue.js の ref (shallowRef) は代入した値が前の値と同じ時は更新通知を行わないため、先ほどの例のような挙動となります。ただし、前の値と同じかどうかをチェックするのは文字列や数値などのプリミティブ型のときのみなので、オブジェクトを返している場合はその利点を得られません。

このように、値の計算を先に行うことで更新前後の値の比較を可能にし、再描画が本当に必要かどうかを判断できるようにしたことが computedEager によるパフォーマンス改善の仕組みです。

つまりいつ computedEager を使えばいい? #

基本的には computed を使いましょう。その上で、パフォーマンスの問題が発生した時に、大きなコンポーネントの再描画などの重い処理が多く発生している場合は computedEager が役に立つかもしれません。

問題となる処理が useTimestamp のような頻繁に更新される値に依存している場合、依存する値を computedEager に置き換えることで不必要な処理がなくなり、パフォーマンスが改善されるかもしれません。

すべての computedcomputedEager に変えることはおすすめしません。値の計算が先に走るということは、その値が必要かどうかを確認しないということです。不必要な再描画を減らすつもりで不必要な computedEager の再計算を増やしてしまうだけになってしまうおそれがあります。また、初めのデモでもわかるように、小さなコンポーネントであれば多少再描画が多くても実感できるほどの影響はないことがほとんどです。さらに、Vue.js は値の依存関係を見て自動的にある程度不要な再描画を省いていて、多くの場合はそれで十分です。問題が顕在化した時にピンポイントで使っていきましょう。

おわりに #

computedEagercomputed でパフォーマンスが落ちてしまう一部のケースに対応できるユーティリティです。その挙動やどういう時に使うかを理解するのはすこし難しいですが、使いどころが見極められるようになると、パフォーマンスチューニングの時に役立つでしょう。

参考文献 #

Vue: When a computed property can be the wrong tool
https://dev.to/linusborg/vue-when-a-computed-property-can-be-the-wrong-tool-195j