ページのコンテンツから離れたタイミングのブラウザイベントの選び方

これは はてなエンジニアAdvent Calendar 2020 1日目のエントリーです。

ブラウザに何か表示させるアプリケーションを作っている人は、ブラウザのAPIやイベントの発火タイミングについて調べる機会が年に何度かあると思います。今回はそんな調査の一つを紹介です。

知りたいこと

ユーザーがページから離れたタイミングを取れるイベントが知りたい。

  • ユーザーがページから離れたら際に行いたい処理があったので、契機となるイベントがほしい
  • ここでは「ページから離れた = ページのコンテンツを見ていないと想定される」とする。具体的には以下のようなタイミング
    • ページ遷移
    • タブ移動
    • ページ破棄 (タブ/ブラウザを閉じる)
    • 画面がロックされる
    • 別のアプリケーションに切り替える (PCなら画面の最小化/モバイルならアプリ切り替え)
  • beforeunloadイベントでタブやブラウザ自体を閉じる際に処理を挟めるのは知っていたが、タブの切り替えやPC/モバイルの画面がロックされた場合にも処理を行いたいので、何を使えばいいのか知りたい

結論

  • visibilitychangeイベントで「ページから離れた」のユースケース全てを満せるので、このイベントを契機に処理を行う
  • Safariではバグがあるので、一部pagehideイベントと合せて実現する
  • (未検証)IEではページ遷移時の処理が途中で打ち切られているような挙動をしているので、IEをサポートするのならbeforeunloadも合せて実現する

以下この結論に至った過程を紹介。

調査と検討過程

基本方針

改めて対応したいことは「ページから離れた = ページのコンテンツを見ていないと想定される」タイミングで発火されるイベントが何かということ。

このケースは全てPage Visibility APIで対応できる。

developer.mozilla.org

visibilitychangeイベントが発火し、 visibilityState === 'hidden' であれば「ページから離れた」と判断できるので、このタイミングで処理を行えば良い。 Can I Useを見る限り主要ブラウザではサポートされているので、気にせず使っていけば良さそう。

ただしSafariにバグがある。

Safari向け対応

Can I UseのSafariの注釈[3]を読むと、こう書いてある。

https://caniuse.com/mdn-api_document_visibilitychange_event

Doesn't fire the visibilitychange event when navigating away from a document, so also include code to check for the pagehide event (which does fire for that case in all current browsers)

(意訳) visibilitychangeがページ遷移時に発火しないので、このタイミングではpagehideを利用する必要がある

そんな……。

ページ遷移時だけを考えるならpagehideのみを使えばよいが、今回はページから離れた時全般なので、visibilitychangeとpagehideのそれぞれで処理を行う必要がある。

このときvisibilitychangeとpagehideの両方に処理を登録すると、ページ遷移時に2回処理が実行されてしまうので、Safariのみpagehideイベントに行いたい処理を登録する必要がある*1

このSafariのバグについて、WebKit Bugzilla見てみると 2020/09/25 時点で更新がある。もしかしたら近いリリースでの修正が期待できるかもしれない。

IEの挙動について

IEではページ遷移時にvisibilitychangeやpagehideのイベントが発火はしているものの、処理が終わり切らず途中で終わっているような挙動をしているように見えるため、代替手段が必要になる。挙動は深く追ってはいないものの、beforeunloadなら処理を最後まで終了させているようなので、IEをサポートする必要があるならこれを利用する必要もあるだろう*2

以上をまとめると以下のようなコードになる。

const doSomething = () => { /* ページのコンテンツから離れた際の処理 */ };

document.addEventLisnter('visibilitychange', () => {
  const state = document.visibilityState;
  if (state === 'hidden') {
    doSomething();
  }
});

if (isSafari()) {
  window.addEventListener('pagehide', () => {
    doSomething();
  });
}

if (isIE()) {
  window.addEventListener('beforeunload', () => {
    doSomething();
  });
}

調査過程で得た情報

調査の過程で得た、他にも応用できそうな情報を上げておく。

Page Lifecycle API

developers.google.com

ページが開かれてから閉じるまでの状態やイベントの遷移をまとめたもの。

これを読んでおけばページの状態にまつわる処理を書きたくなった時にサッと参照出来てお得。ただしPage Lifecycle API自体はChromeでしか実装されていなさそうなので、あくまで参考程度に止めた方がよさそう。

ライフサイクルをまとめた図を見ると、モバイルのネイティブアプリのライフサイクルと似たイメージなのかなとも思ったりする。

またbeforeunloadで何か処理を行うのは、ページ離脱時のユーザー体験を損ねる可能性があるので、保存前の変更を警告するため だけ に使うべきなど、Legacy Lifecycle APIの節で言及されており、勉強になる。

デバッグ便利情報

event-logger.glitch.me

WebKit BugzillaのIssueコメントにあった、動作確認用のglitch。visibilitychangeやpageshow/hideの発火毎にログを表示してくれるもので、今回のAPIの挙動理解の助けになった。

また実装がデバッグの手段の一つの参考になる。素朴なイベントの動作確認方法だと、イベント発火時にconsole.debugでコンソールに出力させるのがあるけど、今回は発火のタイミングがタブやブラウザを閉じるまで含まれるのでコンソールから消えてしまう。このglitchではローカルストレージにイベント発火のログを残しており、タブやブラウザを閉じても消えないようにしている。

ローカルストレージに残す発想は考えてみたら当たり前ではあるが、使ったことの無い手法なので参考になる。

まとめ

ページのコンテンツから離れたタイミングのイベントの選び方とその検討過程について紹介した。Page Visibility APIやライフサイクルの図は覚えておくと便利なタイミングがありそう。

ほしいブラウザのAPIを調べるのが最初だったけれど、ページのライフサイクルだったりデバッグテクニックまで知れてお得な題材だった。

明日は id:nabeop さんです!

*1:ブラウザによる条件分岐を避けたいのなら、行いたい処理をべき等にしたり、処理が実行されたらフラグを立て1度しか実行されないようにするなども考えられる。

*2:同僚の id:koudenpa さんが挙動回りを確認してくれた。