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>
上の例の computed
を computedEager
に変えたのが以下のデモです。描画が現在時刻の更新と完全に同期し、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
の値を使う時に再計算が行われます。
この遅延計算の仕組みは基本的にはパフォーマンス上良いとされています。例えば、currentTime
が v-if
によって非表示になった時、その値は使われていないため、いくら timestamp
が更新されても値の再計算は行われません。
しかし、遅延計算を行うということは、テンプレートの再描画が行われる時にしか currentTime
の値の変化がわかりません。先ほどの例では、currentTime
の値が変わったときにだけ再描画をしたいですが、computed
を使う場合は再描画が始まった後でないと新しい値がわかりません。なので、とにかく依存関係に何らかの更新が行われた時には再描画をするという挙動になります。これが不必要な再描画が行われる原因です。
computedEager
は先に計算し値の更新をチェック #
computed
が遅延計算なのに対して、computedEager
は先に計算を行います。currentTime
の再計算がテンプレートの再描画が行われる前になり、その値が実際に変わった場合のみ再描画を行います。
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
に置き換えることで不必要な処理がなくなり、パフォーマンスが改善されるかもしれません。
すべての computed
を computedEager
に変えることはおすすめしません。値の計算が先に走るということは、その値が必要かどうかを確認しないということです。不必要な再描画を減らすつもりで不必要な computedEager
の再計算を増やしてしまうだけになってしまうおそれがあります。また、初めのデモでもわかるように、小さなコンポーネントであれば多少再描画が多くても実感できるほどの影響はないことがほとんどです。さらに、Vue.js は値の依存関係を見て自動的にある程度不要な再描画を省いていて、多くの場合はそれで十分です。問題が顕在化した時にピンポイントで使っていきましょう。
おわりに #
computedEager
は computed
でパフォーマンスが落ちてしまう一部のケースに対応できるユーティリティです。その挙動やどういう時に使うかを理解するのはすこし難しいですが、使いどころが見極められるようになると、パフォーマンスチューニングの時に役立つでしょう。
参考文献 #
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