requestIdleCallback - アイドル時間を有効活用する
多くのWebページではさまざまな script を実行する必要があり、当然それらには優先度が存在する。例えばUIのレンダリング、インタラクションイベントの登録、APIを呼び出してデータを取得することなどは優先度の高いタスクだ。一方で、アナリティクスのスクリプト、遅延読み込み(lazy loading)、それほど重要でないイベントの初期化などは優先度の低いタスクとなる。
何をもってIdleとするのか?
ブラウザがIdle状態にあることをどうやって知るのだろうか?これはかなり複雑な問題だ。ブラウザは一連のタスクをスケジューリングしている。HTML、CSS、JavaScriptのパース、UIのレンダリング、APIコール、画像の取得とデコード、GPUアクセラレーションなどがあり、いつアイドルになるかを知るにはブラウザのスケジューリング動作を理解する必要がある。しかし幸運なことに、requestIdleCallback がこの問題を解決してくれた。
requestIdleCallback の概要
requestIdleCallback はフレームの最後に実行されるが、すべてのフレームで必ず requestIdleCallback が実行されるわけではない。理由は単純で、すべてのフレーム終了時に空き時間があるとは限らないため、requestIdleCallback の実行タイミングは保証されないからだ。
よく見てみると、requestIdleCallback は少しコンテキストスイッチ(context switch)のような感覚がある。フレームとフレームの間でいくつかの作業を完了させ、一旦中断し、その後に再び実行を継続できる。
requestIdleCallback(fn, {timeout})
const myWork = deadline => {
console.log("not important job.")
while (deadline.timeRemaining() > 0) {
// sending logging
// fetch non-essential data.
// use your imaginary
}
abortMyJob()
}
また、2番目の引数 options には timeout オプションがある。もし timeout 期間内にブラウザがまだコールバックを呼び出していなければ、この timeout を使ってブラウザに手元の作業を中断させ、強制的に呼び出させることができる。
これはあまり適切な使い方ではない。なぜなら僕たちが idle を使う理由は、そのタスクが重要ではないからであり、そのために現在の手元の作業を中断させる必要はないからだ。とはいえ、ブラウザはこの柔軟性を提供してくれており、特定の時間内にどうしてもイベントを発火させたい場合もあるだろう。
cancelIdleCallback(id)
requestIdleCallback() は id を返すため、cancelIdleCallback(id) を呼び出すことで不要になった idle callback をキャンセルすることもできる。
requestIdleCallback は中断されるのか?
deadline 引数は、このフレーム内でタスクを完了するためにどれだけの時間が残されているかを示している。渡されたコールバックはこの引数を取得できる。仕様によると、たとえこの時間を超過したとしても、ブラウザが強制的にタスクを中断することはない。ただ、ユーザー体験を最良に保つために、deadline 内に処理を完了させることが求められているだけだ。
deadline.timeRemaining() は、現在利用可能な残り時間を返す。
requestIdleCallback 内でDOM操作を行うとどうなるか?
少し考えてみてほしい。前述の通り、requestIdleCallback はフレームの最後に実行される。つまりブラウザはすでに再計算(recalculate)、レイアウト(layout)、描画(paint)の処理を終えているということだ。このタイミングでDOMを変更すると、ブラウザに対してスタイルの再計算、レイアウト、描画のスケジューリングをもう一度やり直すよう強いることになってしまう。
requestIdleCallback の中で requestIdleCallback を呼び出すとどうなるか?
requestIdleCallback の中で requestIdleCallback を呼び出すことは問題ない。ただし、そのコールバックは次のフレームにスケジュールされる。(実際には必ずしも次のフレームとは限らず、ブラウザのスケジューリング状況による)
例を見てみよう!
何人かのユーザーがいて、アバターの上にマウスをホバーしたときにプロフィールが表示されるケースを想定してみよう。アイドル期間を有効活用するために、requestIdleCallback を使ってあらかじめ必要なAPIを裏でこっそり取得しておくことができる。まだデータを fetch していなければ、APIを呼び出してデータを取得する。
function fetchUser(name) {
const users = {
kalan: "food, coffee, life",
jack: "woman, coffee, life",
}
return Promise.resolve(users[name])
}
const userIntro = {}
const queue = [
{ name: "kalan", fetched: false },
{ name: "jack", fetched: false },
]
requestIdleCallback(deadline => {
while (deadline.timeRemaining() > 0) {
let q = queue.pop()
fetchUser(q.name).then(user => {
if (deadline.timeRemaining() > 0) {
userIntro[user.name] = user
q.fetched = true
}
})
}
}, 500)
avatar.addEventListener("mouseover", e => {
const name = e.target.getAttribute("data-name")
if (userIntro[name]) {
// show intro
} else {
fetchUser(name).then(user => showInfo(user))
}
})
この例では、requestIdleCallback を利用してあらかじめ user のデータを取得して保存しておき、後でユーザーがアバターをホバーしたときにすぐに表示できるようにしている。まだ取得できていなければ、改めて fetchUser を呼び出す。
requestIdleCallback の効果を実演するためのコードなので、少し面倒に見えるかもしれない。すでに fetch したかどうかを判断するためのキューを管理する必要があるだけでなく、mouseover が発火した際にも userIntro に値があるかどうかを再度確認しなければならないため、開発の手間はむしろ少し増えてしまう。一度に複数人のユーザーデータを取得するほうが手っ取り早い場合もあるが、これは完全に要件とユースケース次第だ。
アナリティクス
もうひとつの典型的なユースケースはトラッキングだ。例えば、ユーザーがボタンをクリックした、再生ボタンをクリックした、視聴時間などをトラッキングする場合である。イベントをまず溜めておき、requestIdleCallback を使ってまとめて送信することができる。以下のような形だ:
const btns = btns.forEach(btn => // buttons you want to track.
btn.addEventListener('click', e => {
// do other interactions...
//...
putIntoQueue({
type: 'click'
// collect your data
}));
schedule();
});
function schedule() {
requestIdleCallback(
deadline => {
while (deadline > 0) {
const event = queues.pop();
send(event);
}
},
{ timeout: 1000 }
);
}
ここでは、ブラウザが確実に schedule 関数を呼び出すようにするために timeout を設定している。
Reactではどうするか?
不要な処理によってメインスレッドがブロックされるのを防ぐために、React Hooks と統合して、不要なタスクをすべて useIdleCallback の中に放り込むことができる。
import { useEffect, useRef } from "react"
function useIdleCallback(callback, timeout) {
useEffect(() => {
let id = requestIdleCallback(deadline => {
callback(deadline)
}, timeout)
return () => cancelIdleCallback(id)
}, [callback, timeout])
}
function UserIntro({ data }) {
useIdleCallback(() => {
sendLog(data)
})
return // your awesome UI
}
いくつかの課題
上記の例はあくまで実演用のものであり、実際にはキューの管理方法、timeout の管理方法、さらにはタスクに優先度をつけて実行順序を保証するなど、最適化できる余地や対処すべき課題がたくさんある。
結論
React の Fiber においても、実はこれと似たような仕組みが使われている。以前の処理方法では、コールスタックが深すぎたり、updateQueue が多すぎたりすることでメインスレッドがブロックされ、更新に時間がかかりすぎてしまうことがあった。
Fiber の仕組みでは作業を小さな塊に分割し、優先度の高い作業があれば先にそちらを実行し、その後に戻ってきて残りの作業を処理するようになっている。
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。