ユーザーが別のタブに切り替えたり、ブラウザーを最小化したり、スマートフォンでホーム画面に戻ったりしても、ページの JavaScript は動き続けます。30秒ごとの自動更新、動きっぱなしのアニメーション、鳴り続けるオーディオ。見えていない時間に動かす必要がない処理は、バッテリーも通信量も無駄に消費します。Page Visibility API を使うと「いまページが見えているかどうか」を検知でき、見えていない間だけ処理を止められます。この記事では document.visibilityState と visibilitychange イベントの基本から、ポーリングの停止・動画の一時停止・離脱時のデータ送信といった実践例までを解説します。
目次
Page Visibility API で何が分かるのか
Page Visibility API は、その名のとおり「ページが利用者から見えている状態かどうか」を教えてくれる API です。ブラウザーは、そのページが今どう扱われているかを知っています。前面のタブで表示されているのか、裏に回されているのか、ウィンドウごと最小化されているのか。その情報を JavaScript から読めるようにしたものだと考えてください。
これが分かると、次のような処理を「見えている間だけ」に絞れます。サーバーへ定期的に問い合わせるポーリング、setInterval や requestAnimationFrame で回しているアニメーション、動画や音声の再生、カウントダウンタイマー、WebSocket 経由の頻繁な更新。どれも裏で動き続けても誰も見ていないのに、端末の電力とサーバーの負荷だけは消費します。
逆方向にも使えます。「ユーザーが戻ってきた」タイミングが分かるので、離れている間に古くなったデータを、戻った瞬間に更新できます。タブに戻ったら最新の通知件数が入っている、という体験はこの仕組みで作られています。
基本の使い方
使うものは3つだけです。現在の状態を表す document.visibilityState、その簡易版である document.hidden、そして状態が変わったときに発生する visibilitychange イベントです。
// 今この瞬間の状態を調べる
console.log( document.visibilityState ); // 'visible' または 'hidden'
console.log( document.hidden ); // false または true
// 状態が変わったときに呼ばれる
document.addEventListener( 'visibilitychange', () => {
if ( document.visibilityState === 'hidden' ) {
console.log( 'ページが見えなくなりました' );
} else {
console.log( 'ページが見えるようになりました' );
}
} );
visibilitychange は「変わった」ことだけを伝えるイベントで、イベントオブジェクトに「どちらに変わったか」は入っていません。そのためハンドラーの中で document.visibilityState(または document.hidden)を読んで、今どちらなのかを判定します。この形が基本になるので覚えてしまってください。
イベントの登録先は document
最初につまずきやすいのがここです。visibilitychange は Document に対して発生するイベントで、window には発生しません。resize や scroll のつもりで window.addEventListener と書いてしまうと、エラーも警告も出ないまま、ハンドラーが一度も呼ばれません。
// 呼ばれない(window では発生しない)
window.addEventListener( 'visibilitychange', handler );
// 正しい
document.addEventListener( 'visibilitychange', handler );
なお、後半で触れる pagehide / pageshow は逆に window のイベントです。混同しやすいので、セットで覚えておくと安全です。
visibilityState が取る値
document.visibilityState が返すのは次の2つの文字列です。document.hidden は、この状態を真偽値にしたものだと考えてください。
| visibilityState | hidden | どんな状態か |
|---|---|---|
'visible' | false | ページの少なくとも一部が画面に見えている |
'hidden' | true | まったく見えていない。別のタブに切り替えた、ウィンドウを最小化した、スマートフォンでホーム画面に戻ったり別アプリに切り替えたりした、端末の画面がロックされた、など |
2値しかないので、document.hidden だけで済む場面も多くあります。実際、次のコードはどちらも同じ判定です。
if ( document.visibilityState === 'hidden' ) { /* ... */ }
if ( document.hidden ) { /* ... */ }
どちらを使ってもかまいませんが、visibilityState のほうが「何の状態を見ているのか」がコードから読み取りやすく、条件を書き間違えにくいという利点があります。この記事では読みやすさを優先して visibilityState を中心に使います。
非表示のあいだ自動更新(ポーリング)を止める
もっとも効果が分かりやすいのが、定期的にサーバーへ問い合わせるポーリングの制御です。通知件数を30秒ごとに取りに行くような処理は、裏に回っている間も律儀に動き続け、ユーザーの通信量とサーバーのリクエスト数を消費します。
そこで「隠れたら止める、戻ったら再開する」を実装します。ポイントは、戻ってきたときに次の30秒を待たず、即座に1回取得してからタイマーを動かし直すことです。こうしないと、タブに戻ってから最大30秒間、古い内容が表示されたままになります。
const INTERVAL = 30000; // 30秒ごと
let timerId = null;
async function fetchNotifications() {
const res = await fetch( '/api/notifications' );
const data = await res.json();
render( data );
}
function startPolling() {
// すでに動いているなら二重に起動しない
if ( timerId !== null ) {
return;
}
timerId = setInterval( fetchNotifications, INTERVAL );
}
function stopPolling() {
clearInterval( timerId );
timerId = null;
}
document.addEventListener( 'visibilitychange', () => {
if ( document.visibilityState === 'hidden' ) {
stopPolling();
} else {
fetchNotifications(); // 戻ってきた直後は待たずに1回取得
startPolling();
}
} );
// 初回の読み込み時
fetchNotifications();
startPolling();
startPolling() の先頭にある if ( timerId !== null ) は地味ですが重要です。これが無いと、visible のイベントが続けて来たときにタイマーが増殖し、止めたつもりでも古いタイマーが残り続けます。setInterval の ID を必ず1つだけ保持し、停止時に null に戻す、という形を守っておくと事故が起きません。
ちなみに、非表示のタブでは多くのブラウザーがタイマーの発火間隔を1秒以上に間引きます。「放っておいてもある程度は抑えられる」わけですが、間引かれるだけで止まるわけではありません。30秒間隔のポーリングは1秒に間引かれたところで30秒のままなので、この節約は効きません。明示的に止める意味は十分にあります。
動画・音声を一時停止して、戻ったら再開する
自前のプレイヤーを作っている場合、タブを離れたら再生を止めたいことがあります。ここで注意したいのは「戻ってきたら無条件に再生する」と書いてしまうと、ユーザーが自分で一時停止していた動画まで勝手に再生されてしまう点です。止める前の状態を覚えておき、もともと再生中だったときだけ再開します。
const video = document.querySelector( '#player' );
let wasPlaying = false;
document.addEventListener( 'visibilitychange', () => {
if ( document.visibilityState === 'hidden' ) {
// 再生中だったかどうかを覚えてから止める
wasPlaying = ! video.paused;
video.pause();
} else if ( wasPlaying ) {
// 隠れる前に再生していたときだけ再開する
video.play().catch( () => {
// 自動再生が拒否された場合は何もしない
} );
wasPlaying = false;
}
} );
video.play() は Promise を返し、ブラウザーの自動再生ポリシーによって拒否されることがあります。拒否された Promise を放置すると「Uncaught (in promise)」のエラーがコンソールに出るので、catch() を付けておきます。
同じ考え方は requestAnimationFrame のループにも使えます。もっとも requestAnimationFrame は非表示のタブでは呼び出しが止まるため、アニメーションの描画自体は自動的に停止します。問題になるのは、そのループの中で経過時間を計算しているようなケースです。止まっていた分の時間がまとめて戻ってくるため、復帰した瞬間にキャラクターが大きく飛ぶ、といった不具合が起きます。visibilitychange で復帰を検知して、基準となる時刻を取り直すと防げます。
非表示になっていた時間を計測する
「ユーザーがこのページを実際に見ていた時間」を測りたいときにも Page Visibility API が使えます。隠れた瞬間の時刻を記録しておき、戻ってきた瞬間との差を取るだけです。
let hiddenAt = null; // 隠れた瞬間の時刻
let hiddenTotal = 0; // 隠れていた時間の合計(ミリ秒)
document.addEventListener( 'visibilitychange', () => {
if ( document.visibilityState === 'hidden' ) {
hiddenAt = performance.now();
return;
}
// ここに来るのは visible に戻ったとき
if ( hiddenAt !== null ) {
const elapsed = performance.now() - hiddenAt;
hiddenTotal += elapsed;
hiddenAt = null;
console.log( `${ Math.round( elapsed ) } ms 隠れていました` );
}
} );
// 実際に見えていた時間 = ページを開いてからの時間 − 隠れていた時間
function getVisibleTime() {
return performance.now() - hiddenTotal;
}
時刻の取得に Date.now() ではなく performance.now() を使っているのは、こちらがページを開いた時点からの経過時間を返す単調増加の値だからです。Date.now() はシステム時計の値なので、計測中に時刻の自動補正が入ると差がずれる可能性があります。経過時間を測る用途では performance.now() のほうが適しています。
下のデモは、この仕組みを使った「表示中だけ進むカウンター」です。プレビュー部分が動き始めたら、ブラウザーの別のタブに切り替えて数秒待ち、戻ってきてください。離れていた分だけカウンターが止まっていて、非表示だった回数と合計時間が記録されているはずです。
<div class="panel">
<p class="label">表示中だけ進むカウンター</p>
<p class="count"><span id="seconds">0.0</span> 秒</p>
<p class="state">現在の状態: <strong id="state">visible</strong></p>
<p class="log">非表示だった回数: <span id="hiddenCount">0</span> 回 / 合計 <span id="hiddenTime">0.0</span> 秒</p>
<p class="hint">別のタブに切り替えて、戻ってきてください。</p>
</div>
body {
margin: 0;
padding: 16px;
font-family: system-ui, sans-serif;
background: #f7f7f9;
}
.panel {
max-width: 360px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
.label {
margin: 0 0 8px;
font-size: 13px;
color: #666;
}
.count {
margin: 0 0 12px;
font-size: 32px;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.state,
.log {
margin: 0 0 6px;
font-size: 14px;
}
.state strong {
color: #007bff;
}
.hint {
margin: 12px 0 0;
font-size: 12px;
color: #888;
}
const secondsEl = document.getElementById('seconds');
const stateEl = document.getElementById('state');
const hiddenCountEl = document.getElementById('hiddenCount');
const hiddenTimeEl = document.getElementById('hiddenTime');
let timerId = null;
let seconds = 0;
let hiddenCount = 0;
let hiddenTotal = 0;
let hiddenAt = 0;
// 表示中だけタイマーを動かす
function start() {
if (timerId !== null) return;
timerId = setInterval(() => {
seconds += 0.1;
secondsEl.textContent = seconds.toFixed(1);
}, 100);
}
function stop() {
clearInterval(timerId);
timerId = null;
}
document.addEventListener('visibilitychange', () => {
stateEl.textContent = document.visibilityState;
if (document.visibilityState === 'hidden') {
// 非表示になった瞬間の時刻を記録してタイマーを止める
hiddenAt = Date.now();
stop();
} else {
// 戻ってきたら、隠れていた時間を集計して再開する
hiddenCount += 1;
hiddenTotal += (Date.now() - hiddenAt) / 1000;
hiddenCountEl.textContent = String(hiddenCount);
hiddenTimeEl.textContent = hiddenTotal.toFixed(1);
start();
}
});
stateEl.textContent = document.visibilityState;
start();
JavaScript タブのコードを見ると分かるとおり、やっていることは visibilitychange で clearInterval と setInterval を切り替えているだけです。凝った仕掛けは要りません。
離脱時のデータ送信は visibilitychange と sendBeacon で
「ユーザーがページを離れる直前にアクセス解析のログを送りたい」という要件は昔からあります。古い定番は beforeunload や unload でしたが、現在はこの用途に visibilitychange を使うことが推奨されています。理由はモバイル環境にあります。
スマートフォンでは、ユーザーがホームボタンを押したりアプリを切り替えたりしたあと、ブラウザーがそのままバックグラウンドで終了させられることがあります。このとき unload や beforeunload は発生しないことがあり、送信処理が丸ごと実行されません。一方、ホーム画面に戻った時点で visibilitychange(hidden)は発生します。つまり hidden になった瞬間が、そのページが確実にコードを実行できる最後のタイミングだと考えられるわけです。
ただし hidden の時点でページが終了するとは限らないため、通常の fetch() では送信が完了する前にページが止められる可能性があります。そこで使うのが navigator.sendBeacon() です。このメソッドはデータをブラウザーに預けて即座に戻り、ページが閉じられたあとも送信を続けてくれます。
let sent = false;
function sendLog() {
if ( sent ) {
return; // 二重送信を防ぐ
}
sent = true;
const payload = JSON.stringify( {
path: location.pathname,
stayed: Math.round( performance.now() ), // 滞在時間(ミリ秒)
} );
// Content-Type を指定したいときは Blob で包む
const blob = new Blob( [ payload ], { type: 'application/json' } );
navigator.sendBeacon( '/api/log', blob );
}
document.addEventListener( 'visibilitychange', () => {
if ( document.visibilityState === 'hidden' ) {
sendLog();
}
} );
sendBeacon() に文字列をそのまま渡すと text/plain として送られます。サーバー側で JSON として受け取りたいときは、上のように Blob に type を指定して渡してください。戻り値は真偽値で、ブラウザーが送信キューに登録できたかどうかを表します。送信そのものが成功したかどうかは分かりませんし、レスポンスも受け取れません。「投げっぱなしで良いデータ」に使うメソッドだと割り切るのが正解です。
コードの先頭にある sent フラグにも意味があります。hidden は「タブを離れて戻る」たびに何度でも発生するので、対策なしでは行き来のたびにログが送られます。1回だけ送りたいのか、行き来ごとに送りたいのかを決めて、必要なら上のようにガードを入れてください。
pagehide・pageshow との使い分け
ページの離脱まわりには pagehide と pageshow という window のイベントもあります。pagehide はページから移動するときに、pageshow はページが表示されるときに発生します。visibilitychange との大きな違いは、これらのイベントが bfcache(戻る/進むのキャッシュ)の情報を持っている点です。
// これらは window のイベント(document ではない)
window.addEventListener( 'pagehide', ( event ) => {
if ( event.persisted ) {
// bfcache に保存された。戻るボタンで復帰する可能性がある
} else {
// ページは破棄される
}
} );
window.addEventListener( 'pageshow', ( event ) => {
if ( event.persisted ) {
// bfcache から復元された(読み込み直しは発生していない)
// 古くなった表示をここで更新する
}
} );
bfcache から復元されたページは、DOMContentLoaded や load が発生しません。ページの状態はそっくり保存されていたものが戻るだけなので、初期化処理をそこに書いていると実行されず、時刻表示などが古いまま残ります。pageshow で event.persisted を確認する処理は、そのための受け皿です。
使い分けの目安としては、「見えているかどうか」で処理を絞りたいなら visibilitychange、「ページが破棄されるのか、キャッシュに入るのか」を知りたいなら pagehide / pageshow、と考えると整理しやすくなります。両方を登録しておいて役割を分けるのも一般的です。
ハンドラーが呼ばれない・思ったタイミングで動かないとき
Page Visibility API は仕組み自体が単純なので、うまくいかないときの原因もいくつかに絞られます。多いものから順に見ていきます。
window にリスナーを登録している
前述のとおり visibilitychange は document のイベントです。window.addEventListener( 'visibilitychange', ... ) と書いても、エラーは一切出ないままハンドラーが呼ばれません。「登録はできているのに動かない」ように見えるため、原因に気付きにくいパターンです。まずここを疑ってください。
同じ理由で、pagehide を document に登録してしまう逆のミスもあります。どちらも文字列のイベント名を渡すだけなので、対象を間違えても静かに無視されます。登録先が正しいかは、動かないときに最初に確認する項目です。
別のウィンドウに覆われているだけでは hidden にならない
「他のアプリのウィンドウをブラウザーの上に重ねたのに hidden にならない」という相談はよくあります。確実に hidden になるのは、別のタブに切り替えたとき、ウィンドウを最小化したとき、モバイルでホーム画面に戻ったり別アプリに切り替えたりしたときです。単に他のウィンドウの後ろに回っただけの状態をどう扱うかはブラウザーの実装に委ねられており、visible のままになることがあります。
そのため「ユーザーがページを注視しているか」を厳密に判定する用途には向きません。あくまで「ブラウザーがこのページを画面に出していないので、処理を減らしてよい」というヒントとして扱ってください。要素単位で見えているかを知りたい場合は、Page Visibility API ではなく Intersection Observer の担当です。
開発者ツールを開いたままだと検証しづらい
console.log で確認しようとすると、タブを切り替えた瞬間にコンソールも一緒に見えなくなるため、ログを追いにくいという事情があります。開発者ツールを画面下部にドッキングしたままタブを切り替えても、戻ってきたときにはすでに両方のログが出てしまっていて、順番が分かりづらいことがあります。
解決策は2つあります。ひとつは開発者ツールを別ウィンドウとして切り離し、そちらを見ながら元のページのタブを切り替える方法。もうひとつは、この記事のデモのように状態をページ上の要素に書き出してしまう方法です。document.title を書き換えれば、タブのタイトル自体が変化するので、切り替えた状態のまま確認できます。
// タブのタイトルに状態を出しておくと、切り替えたまま確認できる
let changeCount = 0;
document.addEventListener( 'visibilitychange', () => {
changeCount += 1;
document.title = `[${ changeCount }] ${ document.visibilityState }`;
} );
hidden のハンドラーで非同期処理が最後まで走らない
hidden になった直後にページが終了させられる可能性があるため、ここで fetch() を投げても完了しないことがあります。「ログを送るコードは書いたのにサーバーに届かない」という現象の多くはこれが原因です。前述のとおり、離脱時の送信には navigator.sendBeacon() を使ってください。
同様に、hidden のハンドラーで setTimeout を使って後回しにするのも避けたほうが無難です。実行されるとは限らないうえ、実行されたとしても非表示のタブでは間隔が間引かれます。状態の保存や送信は、ハンドラーの中で同期的に済ませてしまうのが確実です。
タイマーを止めたつもりで止まっていない
clearInterval を呼んでいるのに処理が続いている場合、止めようとしている ID が実際に動いているタイマーの ID と一致していない可能性があります。setInterval を複数回呼んで変数を上書きしてしまうと、古いタイマーの ID が失われ、二度と止められなくなります。前のポーリングの例で timerId !== null のガードを入れていたのは、この状態を作らないためです。
複数の場所で visibilitychange を登録している場合も、それぞれが別々にタイマーを持っていないか確認してください。停止と再開の処理は1か所にまとめ、そこだけがタイマーの ID を持つように設計すると見通しが良くなります。
まとめ
Page Visibility API で使うものは、document.visibilityState('visible' か 'hidden')、その真偽値版の document.hidden、そして document で発生する visibilitychange イベントの3つだけです。イベント自体は「変わった」ことしか伝えないので、ハンドラーの中で現在の状態を読んで分岐する、という形が基本になります。
活用どころは、隠れている間の無駄な処理を止めることです。ポーリングは clearInterval で止め、戻ったら即座に1回取得してから再開する。動画は止める前の再生状態を覚えておき、再生中だったときだけ再開する。経過時間を測るなら、隠れた瞬間の performance.now() を記録して差を取る。どれも数行で書ける割に、バッテリーと通信量の節約という形ではっきり効果が出ます。
離脱時のデータ送信では、モバイルで発生しないことがある beforeunload ではなく、hidden になったタイミングで navigator.sendBeacon() を使うのが現在の定石です。bfcache がからむ処理が必要なら pagehide / pageshow を併用します。うまく動かないときは、リスナーの登録先が window になっていないか、覆われているだけの状態を hidden と期待していないか、この2点をまず確認してみてください。