Vuetifyのv-tooltipでのメモリリークの対策方法を考える
以前 Vue と Vuetify で制作した 自宅環境観測ダッシュボード を数日間表示していたところ、Out of Memory でページごとクラッシュすることが複数回ありました。巨大なデータの扱いはなく、意図せず何らかの原因でメモリリークが発生しているのではないかと考えました。
発生箇所と原因の調査、回避方法を考えるまでの過程を記録しておきます。
前提
- Vue: 3.5.11
- Vuetify: 3.7.2
結論
- v-tooltip でメモリリークが発生することは掴めたが、根本原因はわからない
- 回避方法として v-tooltip には動的に変化する要素を極力組み込まない
- 長時間表示させると想定されるページではヒープが不必要に増加していないか確認する
発生した事象
ダッシュボードの Vue コンポーネント を表示させ続けると5日から10日ほどののち、Out of Memory でページがクラッシュしてしまいます。
このページではおよそ 10秒 ごとに外部サーバの API から温度や湿度などの情報を JSON で取得しています。また、1秒ごとに最終取得の時刻情報を更新し、画面に反映しています。JSON のサイズは 1.8kB ほどです。
Chrome の DevTools のメモリタブを開くと、ページで実行されている JavaScript VM インスタンスで使用されているヒープのサイズが表示されます。これを確認すると、実行時に 10MB 程度だったヒープサイズが1日経つと 50~60MB ほどに増大していることが確認できます。
ページのロード直後
ページのロードから1日後
DevTools の左側にあるモップのボタンをクリックするとガーベージコレクションが行われますが、GCが実行されてもほとんどサイズが変わりません。
仮説
発生した事象から、以下の仮説を立てました。
-
API から取得した JSON がメモリリークしている可能性は低い と考えました。
理由として、仮に JSON が漏れていたとすると 10秒ごとに 1.8kB (あるいはそれ以上)のヒープが漏れるはずなので、24時間では 15.2MB ほどのリークになります。テキストデータの JSON はすぐにparse
関数によってオブジェクトに変換されるので、テキストデータは使っていません。つまりすぐにテキストデータはGCされるはずです。
さらに JSON データにはシーケンス番号が振ってあり、シーケンス番号が過去と同じ場合はデータの採用をせずに破棄しています。つまり、仮に JSON データをシリアライズしてできたオブジェクトが漏れていたとしても、24時間で 15.2MB 以上の漏れは原理的に発生しません。
これらの観点から、JSON データが漏れている可能性は低いと判断しました。 -
最終取得の時刻情報に関係する何らかのデータがメモリリークしている可能性は否定できない と考えました。
今回の場合、JSON データが何秒前に取得されたかを表示する機能があり、場合によってはそれが同一画面上に何箇所も配置されています。画面更新は1秒単位で行っているので、何らかの原因で時刻情報の文字列が漏れていると24時間で 85400 文字列 が漏れることになります。もちろん、これが何個も配置されていればこれの数倍になりますし、DOMノードごと漏れていれば数十倍のオブジェクトが漏れる可能性も否定できません。
ヒープの差分をとる
DevTools のプロファイリングにはスナップショットの比較機能があります。ページを読み込んだ直後としばらく表示したときのスナップショットを比較すれば、GCされずに意図せず増加している(= 漏れている)オブジェクトが探せるはずです。
2つのスナップショットを用意して、「比較」モードにしたところ
増加を比較したところ、特に以下の項目が大幅に増加していることがわかります。
- Object
- system / context
- Array
- (concatenated string)
- (string)
- (compiled code)
- (sliced string)
上位3つについては Vue 内部で生成されたと思しきオブジェクトが多く、一目みただけでは発生箇所が判然としません。しかし string の量も増えていることに注目してください。string であれば内容がすぐ判明しますし、私が意図して生成した文字列であればコード位置もすぐに特定できます。
時刻情報の文字列がGCされずに大量に漏れている
そこで (string) の項目を開くと、上図のように時刻情報の文字列が大量に表示されます。Fetched At ****
やシーケンス番号を表す Sequence #****
といった文字列が大量に存在することがわかりました。
この文字列が書かれているコード
機能的な都合で2箇所(ダッシュボード上とカード上)ありますが、要約するとどちらも以下のような構造になっています。
<v-tooltip location="bottom">
<template v-slot:activator="{ props }">
<UpdateCircle v-bind="props" :time="fetchedAt" />
</template>
<p>Sequence #{{ observator.sequence }}</p>
<p>Fetched At {{ fetchedAt.format('YYYY-MM-DD h:mm:ss') }}</p>
</v-tooltip>
UpdateCircle とはデータの取得時間からの経過秒数を視覚的に表示するもので、Vuetify の Progress Circular を応用したコンポーネントです。この円弧をマウスオーバーすると、シーケンス番号とデータの取得時刻が表示されるUIになっています。
シーケンス番号とデータの取得時刻が表示されている
これ以外に Fetched At ****
と Sequence #****
の文字列を扱っている箇所はありません。
仮説
- v-tooltip の更新処理に何らかのマズい部分があり、デフォルトスロットの内容が保持され続けているのではないか?
- HTMLへの埋め込み処理そのものが漏れているとは考えづらい。何故ならば、もし埋め込み処理自体に問題があるならば、Vue を使っている他のページでももっと大規模なメモリリークが発生しているはず。しかしそのようなことは起きていない(聞こえてこない)
- UpdateCircle の内部では文字列は取り扱っていないので、可能性からは排除できる
仮説検証
該当部分のコードを抽出し、仮説を検証することにしました。
検証1: そのままのコードを使う
時刻表示部分で string が大量に生成されていると考えました。取得時刻とシーケンス番号の生成部分はテストにダミーデータを作り、更新間隔を 100 ミリ秒の高頻度にしてみました。この状態であれば確実にメモリリークが発生しているはずです。
<script setup lang="ts">
import { ref } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import dayjs, { type Dayjs } from 'dayjs';
const counter = ref<number>(0);
const now = ref<Dayjs>(dayjs());
useIntervalFn(() => {
counter.value++;
now.value = dayjs();
}, 100);
</script>
<template>
<v-tooltip location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props">hover me</v-btn>
</template>
<p>Sequence #{{ counter }}</p>
<p>Fetched At {{ now.format('YYYY-MM-DD h:mm:ss') }}</p>
</v-tooltip>
</template>
検証2: ツールチップの内容を v-tooltip の外に書く
時刻表示そのものがメモリリークの原因になっていないという検証です。v-tooltip の内部には更新される要素は存在しません。これでメモリリークが発生していれば、v-tooltip に原因がないことになります。
<script setup lang="ts">
// (検証1 と同一のため省略)
</script>
<template>
<v-tooltip location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props">hover me</v-btn>
</template>
</v-tooltip>
<p>Sequence #{{ counter }}</p>
<p>Fetched At {{ now.format('YYYY-MM-DD h:mm:ss') }}</p>
</template>
検証3: ツールチップの内容を別コンポーネントにする
もしも検証2でメモリリークが発生していないならば v-tooltip に原因があると推定できます。v-tooltip がデフォルトスロットの内容の変更を検知して結果的にメモリリークを発生させてしまっているならば、スロットの内容が直接的に変化しないように、別コンポーネントにして見かけ上はDOMの要素に変化がないようにすればどうなるだろうか、という検証です。
<script setup lang="ts">
import { ref } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import dayjs, { type Dayjs } from 'dayjs';
import UpdateTimeToolTip from './UpdateTimeToolTip.vue'; // 追加
const counter = ref<number>(0);
const now = ref<Dayjs>(dayjs());
useIntervalFn(() => {
counter.value++;
now.value = dayjs();
}, 100);
</script>
<template>
<v-tooltip location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props">hover me</v-btn>
</template>
<UpdateTimeToolTip :counter :now />
</v-tooltip>
</template>
<script setup lang="ts">
import type { Dayjs } from 'dayjs';
const { counter, now } = defineProps<{ counter: number; now: Dayjs }>();
</script>
<template>
<p>Sequence #{{ counter }}</p>
<p>Fetched At {{ now.format('YYYY-MM-DD h:mm:ss') }}</p>
</template>
検証結果
各コードを使ってページを読み込み、直後に1つ目のスナップショットを取得し、10秒ほど経過したら2つ目のスナップショットを取得して比較します。なお、検証中はツールチップを表示させるボタンにはマウスカーソルを触れさせないようにしました。
検証結果1: 漏れている
埋め込んだ文字列が大量に差分に出てきています。つまり、メモリリークが発生しています。
検証結果2: 漏れていない
ボタンの下に文字列が表示され、正常に表示も更新されていますが、メモリリークは発生していません。つまり文字列の生成と埋め込み自体に原因はなく、v-tooltip との組み合わせによりメモリリークが発生していることがわかりました。
検証結果3: 漏れていないが増える
Fetched At ****
と Sequence #****
の文字列は差分に大量には現れていません。しかし v-overlay__content
という文字列が大量に増えています。
原因の推定
検証結果2ではメモリリークが発生していないことから、v-tooltip の表示内容に「変化」がない限りはメモリリークも発生しないと推測できます。また、検証結果3から、その「変化」とはDOMノード的なものではなく、レンダリング結果によるものではないかと考えられます。
v-tooltip には表示の親となるコンポーネント(検証では v-btn)との位置を調整する機能があり、ツールチップ内部のレンダリング結果に変更があると大きさや表示位置を再計算する必要が生じるはずです。v-overlay__content
はまさにツールチップで使われているDOMノードのクラス名です。
これはつまり、v-tooltip 以外でも同様の処理を行っているコンポーネントについては今回と同じ問題が発生しうるということになります。最終的なレンダリング結果に関わる事象のため、仮に Vuex → Pinia などを使ってツールチップ内の子コンポーネントの props を省略できたとしても意味がないことになります。
考えられる対策
埋め込んだ文字列そのもののメモリリークは回避できそうです。
しかし v-tooltip 内部のレンダリング結果の変化で生じる文字列に関しては、Vuetify内部の問題(あるいは仕様)であり、ユーザレベルでの解決は難しそうです。そのため、表示内容が時々刻々と変化する用途でツールチップを使っている場合は v-tooltip の使用是非を再検討すべきかもしれません。
現状のまま v-tooltip を使いつつ、良い解決方法があれば今後追記します。
Discussion