本文へ

開発記録

非表示タブ、reduced-motion、resize、pagehide はまとめて扱うべきか

現在マウントされている Canvas のエンジニアリンググリッドを例に、一時停止、静的な再描画、ページ離脱時のクリーンアップ、BFCache からの復元を区別し、ローカルでのタブ切り替え、メディア設定、履歴移動の検証範囲を記録します。

林予晴
公開
  • Canvas
  • アニメーションのライフサイクル
  • reduced-motion
  • BFCache

ページを開いたときに背景アニメーションが動いていても、別のタブへの切り替え、リサイズ、履歴からの復帰時にも正しく動くとは限りません。

タブが非表示なら継続的な更新を止め、動きを減らす設定なら静止画を表示し、ウィンドウのサイズが変われば Canvas を再設定し、ページを離れるときにはそのライフサイクルで作成した制御を解放する必要があります。どのイベントも同じアニメーションループに関わりますが、すべてを「停止」の一言では扱えません。

今回は、実際にマウントされているエンジニアリンググリッド背景の初期化とクリーンアップの経路を読み、ローカルのブラウザーで各状態を確認しました。以前の流線のノートは、当時の実装と計測をそのまま記録しています。このノートでは、プロジェクト内に残っている古い背景コンポーネントを、現在のページで動いているエフェクトとは見なしません。

まず一時停止、静止状態、解放を区別します

現在のグリッドコントローラーは、isPaused、prefersReducedMotion、disposed、そして次の animationFrameId をそれぞれ保持しています。それぞれが答える問いは異なります。

状態 保持する必要があるもの 主な処理
表示中で、動きが許可されている 同じアニメーションライフサイクルのデータと描画制御 rAF ごとに更新と描画を行い、次を予約します
タブが非表示 既存のコントローラーとデータ 以降のアニメーションの予約をキャンセルし、表示状態の変化を待ちます
動きを減らす設定 読みやすい静的な背景 継続的なアニメーションを止め、必要に応じて再描画します
ウィンドウのサイズ変更 動きの設定と現在のコントローラー キャンバスのサイズとグリッドを再構築し、状態に応じて描画します
ページからの離脱 復帰させるなら、外側の処理で制御を再作成する必要があります このライフサイクルのリスナーを削除し、rAF をキャンセルし、context への参照を解放します

非表示になったタブをそのまま恒久的な解放として扱うと、タブに戻ったときに処理を再開するコントローラーが残っていないかもしれません。逆に、ページを離れるときにアニメーションを一時停止するだけでは、リスナーや参照のクリーンアップは完了しません。

そのため、私はまず各状態の意味を決めてからイベントを割り当てます。すべてのイベントを先に同じ stop 関数へ押し込むことはしません。

非表示時は一時停止し、戻ったら時間の基準をリセットします

MDN の Page Visibility API の説明では、タブの表示状態の変化を visibilitychange とドキュメントの状態で判断できます。また、多くのブラウザーはバックグラウンドのタブに rAF callback を提供するのを停止します。[2]

ブラウザー独自のスケジューリング方針は、アプリケーション側の状態管理の代わりにはなりません。現在のコントローラーは、非表示になると予約済みの rAF をキャンセルします。再び表示されたら、動きの設定に応じて静的な背景を一度描くか、アニメーションを再開します。

再開時には previousRenderTime もリセットし、画面を離れていた時間全体を、次のフレームで追いつくべき移動距離として扱わないようにしています。通常の render では、1 回に使う時間差にも別途上限を設けています。これは見た目の更新に関する判断であり、バックグラウンドで経過したすべての時間を正確に追跡するシミュレーションではありません。

ローカルのテストでは、別のタブを作成してそちらへ切り替えました。元のページの document.hidden が実際に true になったことを先に確認してから、カウントを比較しました。その観察期間中、callback と draw はどちらも増加せず、元のタブに戻ると描画が再開しました。

ドキュメントの実際の状態を確認せず、visibilitychange イベントを手動で送るだけでは、このようなタブ切り替えの検証にはなりません。また、今回は非表示の間にほかの設定を同時に変更していません。そのため、カウントが変わらなかったことを、あらゆるイベントの組み合わせでも絶対に再描画されないという意味には広げられません。

reduced-motion は「同じアニメーションを少し遅く動かすこと」ではありません

prefers-reduced-motion は、必須ではない動きを減らしたいというユーザーの設定を表します。それ自体が Canvas の render loop を書き換えるわけではありません。[3]

現在のグリッドは、動きを減らす設定では継続的な rAF をキャンセルし、静的なグリッドを描画します。ノードを横切る動的なハイライトも進めません。これは通常のアニメーションを低い FPS に制限するのとは別の判断です。

コントローラーは初期化時にメディアクエリーを一度読むだけでなく、その後の change も監視します。そのため、ページを開いた後に設定が変わっても切り替える経路があります。再び動きが許可されたときは、ページが表示中で、予約済みのフレームがない場合に再開します。

今回はブラウザーのメディア設定のエミュレーションで reduce に切り替えました。変更イベントの処理が終わってからカウントを 2 回読み取り、継続的な更新が止まったことを確認しました。no-preference に戻すと、draw は再び増加しました。これで確認できるのは、そのブラウザーのエミュレーションでの挙動であり、すべての OS の設定画面を実際に操作して確かめた結果ではありません。

同じ理由で、動きを減らすモードでの callback と draw の累計が一致するかどうかを、通常モードでフレーム落ちがあるかの判断には使いません。静的な再描画の一部はイベントから直接実行されるため、もともと新たな rAF callback を必要としません。カウントは、同じ状態の中での増分を比較する必要があります。

静的な背景でも resize への対応は必要です

継続的なアニメーションを止めても、キャンバスのサイズを最初のままにしてよいわけではありません。

現在の実装は、ウィンドウのサイズとデバイスピクセル比に応じて Canvas を設定し、同時にグリッドのデータと静的レイヤーを再構築します。動きのあるモードでは次の render で描画します。動きを減らす設定でページが表示中なら、resize から直接一度再描画し、古いサイズの画面を残さないようにします。

今回は reduce の状態で、エミュレーション中のビューポートを 1280 × 900 から 900 × 700 に変更し、デバイスピクセル比を 1 に設定しました。キャンバスのサイズも更新され、draw が一度増加し、その後の観察期間中は増加しませんでした。

ここで確認したのはサイズとスケジューリングの関係であり、モバイルでの見た目、すべてのピクセル比、性能ではありません。画面が動いていないだけで reduced-motion への対応が完了したと考えると、ウィンドウの変更後にも正しい静的な出力を保つ責任を見落とします。

pagehide のクリーンアップを BFCache からの復元につなげます

現在の背景コンポーネントは、コントローラーが返す cleanup を保持しています。pagehide でそれを呼び出し、pageshow に persisted が付いている場合は、古い制御を整理してから背景を再初期化します。

この cleanup は、そのライフサイクルの resize、visibility、メディア設定のリスナーを削除し、実行待ちの rAF をキャンセルして、Canvas context への参照を解放します。disposed によって、クリーンアップを繰り返し呼び出しても同じ作業をやり直さずに済みます。離脱と復帰を受け取る外側のリスナーは、次のコントローラーのライフサイクルへつなぐ役割を引き続き担います。

MDN は、ブラウザーの進む・戻る操作や BFCache からのページ復元も pageshow の対象として説明しています。[5] ただし、戻る操作で必ず BFCache にヒットするとは限りません。戻るボタンを押しただけで、キャッシュからの復元を検証できたとは言えません。

今回は実際にプロフィールページを離れ、ブラウザーの履歴から戻ったときに、pageshow.persisted === true を記録しました。背景の描画も再び増加しました。これは実際に BFCache にヒットした観察であり、同名のイベントを自分で送出したものではありません。

また、Canvas の制御とページ内の既存ボタンに、同じクリーンアップ方針を採用する必要はありません。プロフィールページの主要な操作リスナーは、離脱イベントの persisted 分岐で保持されます。一方、背景は自身の描画制御を再作成します。その復帰後もアバターを開くことができ、Escape で閉じてフォーカスを戻せました。背景を復元するためにページ全体を強制 reload することはしていません。

それでも、これらの結果は、あらゆる復帰回数やすべてのブラウザーでリスナーが重複しないことの証明にはなりません。MDN も、モバイルでページを離れるすべての状況で pagehide が確実に発火するわけではないと注意しています。[4] この装飾的な背景にとって、これはライフサイクル処理の一部であり、データの保存やリモートへの送信を確実に保証するものではありません。

各状態の観察条件も一緒に残します

今回のテストは、ローカルのヘッドレス Edge ブラウザーで実行しました。主な結果は次のとおりです。メディア設定とビューポートのサイズはブラウザーでエミュレーションしました。タブの表示状態と BFCache については、ネイティブの状態を読み取ったうえで結果を判断しました。

確認項目 今回の観察
表示中で、動きが許可されている 約 600 ミリ秒のサンプリング中に、callback と draw がそれぞれ 37 回増加しました
reduce に切り替え、イベント処理の完了を待つ その後の約 500 ミリ秒間、どちらのカウントも増加しませんでした
reduce の状態でビューポートを変更する キャンバスが更新され、一度再描画した後は静止状態を保ちました
再び動きを許可する draw が再び増加しました
別のタブへ切り替え、元のページが非表示であることを確認する その観察期間中、callback と draw は変化しませんでした
元のタブへ戻る 描画が続きました
ブラウザーのネイティブな履歴から戻り、BFCache にヒットする 永続化されたページの復元イベントを受け取り、背景が再開し、アバターの操作も引き続き使えました

この一連のテストでは、消費電力、GPU コスト、すべてのデバイスのリフレッシュレート、長時間の安定性を測定していません。状態遷移のすべての順列も網羅していません。静止モードで更新が止まったという観察だけでは、どれだけのリソースを節約できたかを証明したことにはなりません。

以前の背景の流線に関するノートでは、通常モードの更新頻度を扱っています。このノートが補うのは別の問題です。アニメーションをいつ継続するか、いつ一度だけ再描画するか、そして離脱後に誰が制御を引き継ぐかです。動き続けさせるために、すべての状態を通常の表示中ページと同じように扱うことはできません。

出典

[2] https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API [3] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion [4] https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event [5] https://developer.mozilla.org/en-US/docs/Web/API/Window/pageshow_event