ブラウザ内部で行われる処理は、基本的にシングルスレッドで行われる。
そのため、処理に時間がかかるタスクがひとつあると、後続のタスクの処理がその分だけ遅れることになる。
この「タスク」には、JavaScript の実行も含まれるし、画面の描画、ユーザーの操作への対応といった UI に関する処理も含まれる。
そのため、JavaScript の処理に時間が掛かってしまい UI の更新が遅れてしまう、ということが起こり得る。
もしその JavaScript の処理の優先度が高くないのなら、まずは UI の更新を行い、ブラウザに余裕が生まれたタイミングで JavaScript を処理することが望ましい。
そういったことを可能にするのが、requestIdleCallback
という API である。
この API を使って優先度の低い処理を後回しにすることで、画面の描画などユーザー体験に直結する処理を優先させることができる。
この記事の内容は Google Chrome の86.0.4240.111
で動作確認している。
基本的な使い方
後回しにしたい処理をrequestIdleCallback
のコールバック関数として渡すと、そのコールバック関数は即座には実行されず、ブラウザがアイドル状態になったときに実行される。
以下のコードを実行すると、3
秒後にdone!
がログに流れ、その直後にfirst
がログに流れる。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } heavy(3000); console.log('first');
heavy(3000)
の処理に時間が掛かっているため、その分だけconsole.log('first)
が遅れている。
これを、requestIdleCallback
を使った形に書き換える。
以下のようにheavy(3000)
の実行をrequestIdleCallback
のコールバック関数に設定すると、その処理は後回しにされ、まずconsole.log('first)
が実行される。
そしてその後heavy(3000)
が実行される。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } requestIdleCallback(() => heavy(3000)); console.log('first');
requestIdleCallback
は ID を返し、それをcancelIdleCallback
に渡すことで、コールバック関数の呼び出しがキャンセルされる。
以下のコードを実行してもdone!
は表示されない。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } const id = requestIdleCallback(() => heavy(3000)); console.log(id); // 1 cancelIdleCallback(id);
もう少し実践的な例を示す。
以下のコードでは、ボタンを押下するとまずrender
を実行し、その後heavy(3000)
を実行する。
heavy(3000)
は時間のかかる処理であるため、これが終わるまで画面の更新が行われず、表示が遅れてしまう。
function render() { const paragraph = document.createElement('p'); paragraph.textContent = 'fizz'; document.body.appendChild(paragraph); } function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } function onClick() { render(); heavy(3000); } const start = document.querySelector('#start'); start.addEventListener('click', () => { onClick(); });
もしheavy(3000)
が優先度の低い処理なら、requestIdleCallback
に渡すことができる。
そうすると画面の更新が優先的に行われるため、遅延なく表示される。
function render() { const paragraph = document.createElement('p'); paragraph.textContent = 'fizz'; document.body.appendChild(paragraph); } function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } function onClick() { render(); requestIdleCallback(() => heavy(3000)); } const start = document.querySelector('#start'); start.addEventListener('click', () => { onClick(); });
IdleDeadline インスタンス
requestIdleCallback
に渡されたコールバック関数は、引数としてIdleDeadline
インスタンスを受け取る。
このインスタンスは、didTimeout
プロパティとtimeRemaining
メソッドを持つ。
requestIdleCallback((deadline) => { console.log(deadline.constructor.name); // IdleDeadline console.log(Object.keys(Object.getPrototypeOf(deadline))); // ["didTimeout", "timeRemaining"] });
didTimeout
requestIdleCallback
には第二引数としてオプションを渡すことが可能で、そこでタイムアウトを設定できる。
requestIdleCallback(() => {}, {timeout: 2000});
だがこのタイムアウトは、その時間を経過したら実行する、あるいはキャンセルする、といった類のものではない。
requestIdleCallback
に渡されたコールバック関数は、ブラウザがアイドル状態になったときに呼び出されるのであり、タイムアウトは関係ない。
以下の例ではコールバック関数のタイムアウトは2000
ミリ秒になっているが、その経過を待たず、heavy(100)
の処理が終わった時点でコールバック関数が呼び出される。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } requestIdleCallback( () => { console.log('callback'); }, {timeout: 2000} ); heavy(100);
タイムアウトしたかどうかで処理の内容を変えたいときに使うのが、didTimeout
プロパティである。
以下の例では、まずコールバック関数が登録され、その後heavy(2000)
による2000
ミリ秒の待機時間のあと、コールバック関数が呼び出される。
timeout
として設定した1000
ミリ秒よりもあとに呼び出されているため、didTimeout
はtrue
になる。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } requestIdleCallback( (deadline) => { console.log(deadline.didTimeout); // true }, {timeout: 1000} ); heavy(2000);
didTimeout
を正しく機能させるためには記述する順番が重要で、この例だとheavy
をrequestIdleCallback
より先に記述すると、didTimeout
は必ずfalse
になってしまう。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } heavy(2000); requestIdleCallback( (deadline) => { console.log(deadline.didTimeout); // false }, {timeout: 1000} );
また、timeout
オプションを設定しなかった場合も、didTimeout
は常にfalse
になる。
タイムアウトは、複数のrequestIdleCallback
が実行されているときの処理の順番に影響を与える。
ブラウザがアイドル状態になったとき、既にタイムアウトになっているコールバック関数があった場合、それを優先的に処理する。
そして、タイムアウトしているものが複数あった場合、より大きくタイムアウトしているコールバック関数の処理を優先する。
そのため以下の場合、1
よりも2
と3
が優先され、2
よりも3
が優先される。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } requestIdleCallback((deadline) => { console.log(1, deadline.didTimeout); }); requestIdleCallback( (deadline) => { console.log(2, deadline.didTimeout); }, {timeout: 3000} ); requestIdleCallback( (deadline) => { console.log(3, deadline.didTimeout); }, {timeout: 2000} ); heavy(5000); // done! // 3 true // 2 true // 1 false
タイムアウトしていないコールバック関数が複数ある場合は、requestIdleCallback
が実行された順番に呼び出される。
function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } requestIdleCallback( (deadline) => { console.log(1, deadline.didTimeout); }, {timeout: 3000} ); requestIdleCallback((deadline) => { console.log(2, deadline.didTimeout); }); requestIdleCallback( (deadline) => { console.log(3, deadline.didTimeout); }, {timeout: 2000} ); heavy(1000); // done! // 1 false // 2 false // 3 false
timeRemaining
timeRemaining
メソッドは、アイドル状態の残り時間を返す。
例えば30
が返ってきた場合、あと30
ミリ秒はアイドル状態であることが期待できる。そのため、その時間内に収まる処理であるなら、後続のブラウザの処理に影響を与えずに実行できる。
逆に、30
ミリ秒を超過する処理を行う場合、その間にアイドル状態が終了し、後続の処理を遅延させてしまう可能性がある。
そのため、timeRemaining
で残り時間をチェックし、もう時間がない場合は残りの作業を先送りにすることが望ましい。
なお、didTimeout
がtrue
の場合、timeRemaining
は必ず0
を返す。
マイクロタスクに分割して利用すべき
先程述べたように、requestIdleCallback
に渡す処理が時間のかかるものである場合、結局、後続の処理を遅延させてしまう可能性がある。
そのため、可能な限りタスクを分割してからrequestIdleCallback
を利用したほうがよい。
これも、例を出して見ていく。
以下のコードで、start
ボタンを 2 回押下したときの挙動を見てみる。
function render() { const paragraph = document.createElement('p'); paragraph.textContent = 'fizz'; document.body.appendChild(paragraph); } function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } let count = 0; function onClick() { count += 1; render(); requestIdleCallback(() => heavy(3000, count)); } const start = document.querySelector('#start'); start.addEventListener('click', () => { onClick(); });
requestIdleCallback
を使っているので初回のfizz
の表示はスムーズである。だが 2 つめのfizz
の表示に時間が掛かってしまっている。
これは、2 回目の押下の前に() => heavy(3000, 1)
が呼び出されており、これが終わるまでは画面の更新が行われないためである。
そしてこの処理は3
秒かかるため、画面の更新もその分だけ遅延してしまう。
これを(少しだけ)改善したのが、以下のコード。
heavy(3000)
を、6 つのheavy(500)
に分割している。そしてtimeRemaining
メソッドを使い、残り時間がなくなった場合は残りのheavy(500)
の処理を先送りしている。
function render() { const paragraph = document.createElement('p'); paragraph.textContent = 'fizz'; document.body.appendChild(paragraph); } function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } function runTasks(taskList) { requestIdleCallback((deadline) => { // 残り時間があるときにのみ、タスクを処理する while (taskList.length > 0 && deadline.timeRemaining() > 0) { const task = taskList.shift(); task(); } // まだタスクが残っている場合は、再び runTasks を実行する if (taskList.length > 0) { runTasks(taskList); } }); } let count = 0; function onClick() { render(); const currentCount = count + 1; count += 1; const taskList = [ () => heavy(500, `${currentCount}-1`), () => heavy(500, `${currentCount}-2`), () => heavy(500, `${currentCount}-3`), () => heavy(500, `${currentCount}-4`), () => heavy(500, `${currentCount}-5`), () => heavy(500, `${currentCount}-6`), ]; runTasks(taskList); } const start = document.querySelector('#start'); start.addEventListener('click', () => { onClick(); });
500
ミリ秒という単位で処理を行っているため、画面の更新の遅延も、約500
ミリ秒で済むようになっている。
Google Chrome のデベロッパーツールでスクリプト処理を確認してみると、Fire Idle Callback
の合間にフレームの更新が行われている。
そして、Fire Idle Callback
が細かく分割されていることで、2 つめのfizz
の反映が早くなっていることが分かる。
改善前。
改善後。
とはいえ、これでも目に見えて画面の更新が遅延している。
今回はサンプルとしての分かりやすさのためにこのようにしたが、実際にrequestIdleCallback
を使うときは、もっと細かくタスクを分割することが望ましい。
DOM の更新には requestAnimationFrame を使う
DOM の更新はrequestIdleCallback
ではなく、requestAnimationFrame
を使う。
requestAnimationFrame
は、次回のレンダリングのタイミングに合わせて、ブラウザが最適なタイミングで呼び出してくれる。
参考までに、両者の挙動の違いを見てみる。
まず、requestIdleCallback
のなかで DOM を更新してみる。
以下のコードでボタンを 2 回押下すると、fizz buzz fizz buzz
の順で表示される。
function renderFizz() { const paragraph = document.createElement('p'); paragraph.textContent = 'fizz'; document.body.appendChild(paragraph); } function renderBuzz() { const paragraph = document.createElement('p'); paragraph.textContent = 'buzz'; document.body.appendChild(paragraph); } function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); console.log(message || 'done!'); } function onClick() { renderFizz(); requestIdleCallback(() => { renderBuzz(); heavy(1000); }); } const start = document.querySelector('#start'); start.addEventListener('click', () => { onClick(); });
次に、requestAnimationFrame
のなかで DOM の更新を行う。
@@ -19,7 +19,7 @@ function onClick() { renderFizz(); requestIdleCallback(() => { - renderBuzz(); + requestAnimationFrame(() => renderBuzz()); heavy(1000); }); }
すると、fizz fizz buzz buzz
という順番になる。
requestAnimationFrame
を使わなかった場合、2 回目の押下の前にrenderBuzz()
が実行されるため、最初のfizz
の下にbuzz
が追加される。
requestAnimationFrame
を使った場合、次回のレンダリング更新の前にrenderBuzz()
が実行される。そして、次回レンダリングがheavy(1000)
によって遅延している間に 2 回目の押下があったため、renderBuzz()
よりも先に 2回目のrenderFizz()
実行される。そのため、最初のfizz
の下にfizz
が追加される。