30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

requestIdleCallback を使って優先度の低い処理を後回しにする

ブラウザ内部で行われる処理は、基本的にシングルスレッドで行われる。
そのため、処理に時間がかかるタスクがひとつあると、後続のタスクの処理がその分だけ遅れることになる。
この「タスク」には、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();
});

f:id:numb_86:20201029230937g:plain

もし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();
});

f:id:numb_86:20201029231017g:plain

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ミリ秒よりもあとに呼び出されているため、didTimeouttrueになる。

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を正しく機能させるためには記述する順番が重要で、この例だとheavyrequestIdleCallbackより先に記述すると、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よりも23が優先され、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で残り時間をチェックし、もう時間がない場合は残りの作業を先送りにすることが望ましい。

なお、didTimeouttrueの場合、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();
});

f:id:numb_86:20201029232240g:plain

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();
});

f:id:numb_86:20201029232505g:plain

500ミリ秒という単位で処理を行っているため、画面の更新の遅延も、約500ミリ秒で済むようになっている。

Google Chrome のデベロッパーツールでスクリプト処理を確認してみると、Fire Idle Callbackの合間にフレームの更新が行われている。
そして、Fire Idle Callbackが細かく分割されていることで、2 つめのfizzの反映が早くなっていることが分かる。

改善前。

f:id:numb_86:20201029232553p:plain

改善後。

f:id:numb_86:20201029232607p:plain

とはいえ、これでも目に見えて画面の更新が遅延している。
今回はサンプルとしての分かりやすさのためにこのようにしたが、実際に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();
});

f:id:numb_86:20201029233008g:plain

次に、requestAnimationFrameのなかで DOM の更新を行う。

@@ -19,7 +19,7 @@
 function onClick() {
   renderFizz();
   requestIdleCallback(() => {
-      renderBuzz();
+     requestAnimationFrame(() => renderBuzz());
     heavy(1000);
   });
 }

すると、fizz fizz buzz buzzという順番になる。

f:id:numb_86:20201029233032g:plain

requestAnimationFrameを使わなかった場合、2 回目の押下の前にrenderBuzz()が実行されるため、最初のfizzの下にbuzzが追加される。
requestAnimationFrameを使った場合、次回のレンダリング更新の前にrenderBuzz()が実行される。そして、次回レンダリングがheavy(1000)によって遅延している間に 2 回目の押下があったため、renderBuzz()よりも先に 2回目のrenderFizz()実行される。そのため、最初のfizzの下にfizzが追加される。

参考資料