コーポレートサイトがメモリを無限に食べていたので修正した話

## まえがき
この記事は **VR法人HIKKY Advent Calendar 2024** の 3 日目の記事です。

https://qiita.com/advent-calendar/2024/hikky

前日は [シングルトンアンチのためのUnityにおけるオブジェクト共有方法](https://qiita.com/naninunenoy/items/89b153a780386a1a3a24) 、明日は @kazu0617 さんが何か書きます。

Webチームの飛び道具担当を自称している called_D という者です。つい一ヶ月ほど前に公開されたコーポレートサイト（リニューアル）についての記事を書きたいと思います。この記事で取り扱うのはメモリリークについて。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/883c7456-5fa5-4b95-568e-4ab28b7e05e5.png" alt="本体.png" title="本体.png" width="30%" />
(作者近影)

## 要約
* SPA のページ遷移の都合で、要素が作られて消えてを繰り返すことで状態が悪化する
* きちんとリソースを開放しよう。JavaScriptだからといって全部がGCの対象ではない
  - ライブラリが提供している開放操作がある。使い勝手が悪くても泣かない
    + Three.js なら dispose()
    + lottie なら destroy() 
* メモリリークのデバッグの仕方

## 初回: ライブラリの提供する関数とキャプチャされた変数について
Vket 2024 Summer が終わってすぐの 8 月初旬、コーポレートサイトリニューアル作業中のチームメンバーから「メモリ消費量が下がらない」と相談を受けて対処しました。

NOTE: タブごと[^1]の消費メモリはメニューから「その他のツール」→「タスク マネージャ」で見れます。
![ブラウザのメニューからタスクマネージャーを開く.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/b1bce4c1-3464-84bf-5969-d02ee3098470.png)

症状は「サイトのヘッダーから各ページを行き来するとサイトのメモリ消費量が増え続ける」というもの。夏の Vket が終わって精神力が尽きていたので、思考停止でも進められる " 二分\**コメントアウト**探索 " でざっくり問題の箇所を特定します。

このときの開放漏れは共通レイアウトに書かれたサイトフッターのコンポーネント内で、[Three.js](https://threejs.org/) で読み込んでいた 3D モデルでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/518f5278-1ba4-4ee3-30a8-04e3fb72778c.png)

### タブのメモリと SPA
ところで HIKKY ではフロントエンドのフレームワークに Nuxt を採用しており、このサイトは SSR（サーバーサイドレンダリング）モードで動作しています。Nuxt や Next の SSR は最初のレンダリング[^2]こそサーバーで行いますが、その後ブラウザ側で「ページ遷移」が起きるときの動作は SPA（シングルページアプリケーション）と同等なので同じように考えてよいです。

すなわち、通常の Web ページと異なり「ページ遷移」で js の世界はリセットされません。GC から漏れたリソースは同一アプリケーションのページ内を回遊するかぎり開放されずそのまま積み重なっていきます。

（Vue / Nuxt ではコンポーネントの onBeforeUnmount() フックでリソースの開放処理を行うのがよいと思われます。大体はそれで事足りるはず）

### js でしょ？　GC されるんじゃないの？
ページ遷移のたびにフッターのコンポーネントは unmount され消えているので、直感的にはそこに紐づく変数も参照されなくなりガベージコレクションで消えるように思われます。ところが、内部で自前でリソースを管理[^3]しているような、ある種のライブラリはライブラリ側が提供する操作を用いないとメモリが開放されないパターンがあります。

Three.js はマニュアルにリソースの開放についてのページがあります。~~ところでこれ Getting Started じゃなくて Next Steps の中なので、普通に 3D モデルが描画できてそこで満足してたら読み飛ばすのでは……？~~
![TVモニター3Dモデル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/91df93d8-752c-bf31-2187-a33f98d801c3.png)

フッターのコンポーネントを書いた同僚氏は Three.js の dispose() を書いていなかったので、さくっと書いて解決！[^5]

### GC されない（変数への参照が生き残っている？）
……解決しませんでした。そんなー。

> **当時のメモ**:
最終的にこの行コメントアウトすればリークしないのは分かったけどそっから先が一番重要なんだわ……。
![NextStep_Disposing_resources.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/65bb9a50-18ea-1df8-f8a7-498557181af9.png)

イベントリスナーの宣言の階層がずれていて undefined を代入しそびれていたのを直したり、変数をクロージャの外に移したり、この関数のスタック無限に伸び続けていない？　というのを色々直してどうにか修正しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/41b77bf6-aae5-8d2a-03dd-1045ed446b16.png" alt="fix_1_commits.png" title="fix_1_commits.png" width="60%" />

キャプチャで 3D モデルの変数への参照が残っていて云々だとは思うのですが、きちんと原理を説明できるまでの検証をできていません。腑に落ちないまま他のタスクに移りました。

そして二ヶ月半が経ち……。

## 第二: リリース数日前のメモリリーク
![メモリリークでSafariが壊れた図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/4ba939ba-4da0-959a-5efb-73d24b0ca8d0.png)

> すみません。
品管チームから下記連絡をいただいて、共有です！
> 
> サイトのtopからMISSION~RECRUITのいずれかに移動→hikkyのロゴを押しTOPページに戻るを繰り返すとサイトのメモリ使用率がどんどん上がる事を確認しました。
こちらサイト側の処理で何か起きている物とおもわれますので念のためご確認をお願いします。

> 品管チームってメモリ使用量とかも見てくださるんだ…すごい…

> **called_D** メモリリーク！　殺したはずでは！？

メモリリークが再発していることが、リリース前の QA 期間に発覚しました。ローカルで試したところ、サイトトップと子ページを行き来[^6]することできちんと再現することが確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/44f43fae-1be6-1cb0-b7dd-26ded6dd0184.png)
（前よりひどくなってる……）

[Firefox Developer Edition](https://www.mozilla.org/ja/firefox/developer/)[^4] でサイトを開き、[メモリツール](https://firefox-source-docs.mozilla.org/devtools-user/memory/) でメモリのスナップショットを撮って中身を見ます。
![2gb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/b1f97a77-c451-6ab6-b29a-b29b186b4f94.png)

### Three.js の dispose() は全部一括で開放してくれるインターフェイスを持たない
[ドミネータービュー](
https://firefox-source-docs.mozilla.org/devtools-user/memory/dominators/index.html) で確認すると、 `scene` から参照されている何かが。
![表示:ドミネーター.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/1d90061e-9afe-6b62-a557-6cae6299fd93.png)
![参照のグラフ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/1f9bbef5-5809-8afa-7534-99786d32cedc.png)

前回から変更されていて、scene が直接持っているものはあまり多くないためいくつかコメントアウトして挙動を確認し、結果、新しく追加された環境マッピングの dispose() 呼び出しが漏れているため対処しました。
![envMap.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/2da023dd-4474-511e-7990-7d6c8861f60f.png)
（環境マップ。3Dモデルの表面に写っている光沢、ライトの形状）

![diff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/778be2b4-05de-812b-321e-ac5e9449651b.png)
（Three.js の dispose() は「ある種のリソースには dispose() 関数があるから都度呼んでね」という方式なので、存在を認識していないと容易に呼び漏れるなぁ、と）

### もう一人の実行犯[^7]、Lottie
上記修正を加えてもまだ軽減されるだけで解決しなかったため、調査を進めます。
![804mb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/1bc52a9d-e9b4-10ca-ff34-b5801457cea1.png)

参照ツリーに scene が含まれるものは増殖しなくなったのですが、\*\*UNKNOWN SLOT 1** を含む（つまり、参照関係が上記とは別の）オブジェクトが開放されずに残っているようです。
![mCallback.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/43119167-0b4f-b59a-68fa-38a1b63f6254.png)
（mCallback って変数 Three.js には居ないぞ……？）

情報が足りないので「コールスタックを記録」のチェックを入れて再度計測します（これはスナップショットを取得する直前に入れてもだめで、メモリが確保される実際の操作の前に ON にしておきます）
![recordcallstack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/29692e7a-7c51-909b-bf41-64c8fb5bf508.png)
![caution.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/45694660-2d86-abe6-b123-32e429ffa41b.png)

ちなみにコールスタックの記録はだいぶ計測自体が重たいです。再現するために必要な操作が複雑だったりするときは、これなしで当たりが付けられるならOFFのほうがストレスが少ないと思います。

表示: ドミネーター、ラベル: Call Stack で表示します
![memory-tool-says-lottie.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/9c1f2bfc-bd9b-229a-c2ec-434f7b97b42c.png)

アニメーションライブラリの Lottie が原因のようだ、と分かったので開放操作を調べます[^8]

https://airbnb.io/lottie/#/web?id=usage-1 
> **lottie.destroy()** -- To destroy and release resources. The DOM element will be emptied.

`lottie.destroy()` を onBeforeUnmount で呼んで解消です。

> **called_D** lottie まで直してトップページと、ヘッダーメニューに乗ってる各ページ往復で計14回ページ遷移すると、消費メモリが470MB近辺[^9]に落ち着く認識
（Firefox）

> 400MB台まで下がっておりました!!!!!!!!!!!(下層->TOPを20回検証)
動かなくなっているLottieなども見られませんでした！
&nbsp;<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/54f7f970-c0f0-95b8-c9e8-9ff7a17b60a6.png" alt="405mb.png" title="405mb.png" width="40%" />


## まとめ
こうして再度書いてみると
* コメントアウトして調べる
* メモリツールで調べる
* 参照を掴んでいそうな部分を片っ端から書き換える
* メモリツールでコールスタックを出して調べる

と複数の手法を使ったこと、開発者ツールのメモリツールが問題の特定に役に立ったことがよく見て取れます。

ライブラリによっては独自のリソースの開放の作法があり、呼び忘れると参照が失われた後も GC の対象とならず生きていることが直接的な原因でしたが、本来ならページ遷移のタイミングでリセットされるものが SPA の性質により開放されないことで、結果的にギガバイト単位でタブの消費メモリが膨れることになりました。

PC からの閲覧ならともかく、スマートフォンからの閲覧の場合タブごとクラッシュするはずなので、サイトリリース前にこれを修正できなかったらと考えると怖いですね。

## こぼれ話

* コーポレートサイト公開と前後して lottie-player に暗号資産を窃取しようとする悪意あるコードが追加されていました。これは肝が冷えた
> **called_D** コーポレートサイトで使用しているのは lottie-web パッケージであり、今回の @lottiefiles/lottie-player ではないため対象外です
https://x.com/LottieFiles/status/1851848602093777273
* コメントアウトで部分部分で切り分けていくアプローチとは異なり、git bisect と Puppeteer を使って時間方向で切り分けていくアプローチも考えられます
* Webデザインの参考サイト [SANKOU!](https://sankoudesign.com/) に掲載されました。自薦・他薦を受け付けていないらしいのでリニューアル当日・翌日とかでどうやって把握してるんでしょうか……？

[^1]: 実際にはプロセス単位での使用量を見ることになる。Spectre脆弱性への対策によりサイトごとにプロセスが分離されるので、各サイトのリソース消費がまとまる挙動となる
![isolated.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208731/2aaab478-f08b-0400-9cca-3befbd57f2f2.png)

[^2]: 3D 用語の、画面を描画する「レンダリング」と異なり、単に HTML を組み立てて返すことをいう

[^3]: Three.js の場合、おそらく WebGL とデータをやり取りする都合でそのような構造になっているのだと思います

[^4]: こっちのほうが Chrome よりメモリツールが見やすかったので

[^5]: 実際には全部のリソースを把握して dispose() を呼ぶのは書くプログラマ側の負担が大きいので、シーンで利用しているものを再帰的に辿って dispose() 関数があれば呼び出すようなヘルパー関数を使います https://discourse.threejs.org/t/when-to-dispose-how-to-completely-clean-up-a-three-js-scene/1549/11

[^6]: サイトロゴとサイトヘッダーの各ページをクリックし往復、計14回のページ遷移

[^7]: 「指示役」は開放操作を呼んでないプログラマなのでここでは実行犯と表現しています

[^8]: https://stackoverflow.com/questions/69567046/memory-leak-in-lottie-web とか

[^9]: ブラウザ上でメタバース動かすならともかく、コーポレートサイトで消費メモリ 0.5GB ってどうなの、という思いはあります