hitode909の日記

趣味はマリンスポーツですの日記です

JavaScript 長いループ 分割

ブラウザで長いループや、重い処理をともなうループを回したいとき、同期的にJavaScriptを実行するとメインスレッドがブロックしてしまうので、ちょっとずつ細切れに分割して実行したい、ということがある。
昨日久しぶりに書いたら新たなパターンと出会ったので、これまでにどう書いてて今回どうなったかメモ。

setTimeoutする

以前(10年前とか)はこんなのをよく書いていた。
itemsがでかいArrayで、console.logがすごく重い処理だとして読んでください。

function iterateHeavyTask(items) {
  const startAt = new Date();
  while (items.length > 0 && new Date().getTime() - startAt < 10) {
    console.log(items.shift());
  }
  if (items.length > 0) {
    setTimeout(() => {
      iterateHeavyTask(items);
    }, 0);
  }
}


こんなにDateを作りまくって大丈夫なのか気になってたけど、試してみたら手元では秒間に700万回くらいループを回せてたので問題なさそうだった。

i=new Date();j=0; while(new Date().getTime() - i.getTime() < 1000) { j++; }; console.log(j);
7386752

requestIdleCallbackする

最近は、requestIdleCallbackがあるので、ブラウザに対して、落ち着いたときにやっといて、というのができる。
Safari, IE対応するときにはpolyfillを入れておくと良い。

function iterateHeavyTask(items) {
  const startAt = new Date();
  while (items.length > 0 && new Date().getTime() - startAt < 10) {
    console.log(items.shift());
  }
  if (items.length > 0) {
    requestIdleCallback(() => {
      iterateHeavyTask(items);
    });
  }
}

generatorを使う

ループ部分と処理部分が密結合してるのでgeneratorを使って書くとこうなり、ループと処理が分離できる。

function waitForIdle() {
  return new Promise((resolve) => {
    requestIdleCallback(resolve);
  });
}

async function* iterate(items) {
  let startTime = new Date();
  for (const item of items) {
    yield item;
    if (new Date().getTime() - startTime > 10) {
      await waitForIdle();
      startTime = new Date();
    }
  }
}

async function iterateHeavyTask(items) {
  const iterator = iterate(items);
  for await (const item of iterator) {
    console.log(item);
  }
}

isInputPending

generatorを使った版を書いた時点で、ループと処理が分離できて、これはよく書けた、と見せびらかしていたら、最近のChromiumにはisInputPendingというAPIがあり、ユーザー入力がpendingされているか判定できる、とid:mizdraに教えてもらえた。
Chromium 87以降なら実装されているので、実装されていたら使い、実装されてなかったらデッドラインによる判定に戻る形にする。

実装して、使用感を触ってみると、マウスを動かしたりスクロールしたりするとループが中断されて、JSがなにも仕事してないかのような使用感を得られた。
includeContinuousをfalseにするとマウス移動とかは拾ってくれないので、なにかの要素にホバーするとCSSで色が変わる、とかが動かなくなる。そのあたりは用途によって調整すればよい。

function waitForIdle() {
  return new Promise((resolve) => {
    requestIdleCallback(resolve);
  });
}

async function* iterate(items) {
  let startTime = new Date();
  const deadlineHasCome = () => (new Date().getTime() - startTime.getTime()) > 10;
  const hasInputPending = navigator.scheduling && navigator.scheduling.isInputPending;

  for (const item of items) {
    yield item;
    if (
      hasInputPending
        ? navigator.scheduling.isInputPending({ includeContinuous: true })
        : deadlineHasCome()
    ) {
      await waitForIdle();
      startTime = new Date();
    }
  }
}

async function iterateHeavyTask(items) {
  const iterator = iterate(items);
  for await (const item of iterator) {
    console.log(item);
  }
}


ループを細切れにするのはたまに書くのだけど、書くたびに、ブラウザの良いAPIがだんだん増えていてありがたい。
仕事で重い処理を書いてしまって困るというときには、DOMのAPIをがしがし触っていて重いということが多かったのだけど、永久にフィボナッチ数列を計算するとか、純粋に重い処理を実行したいときはWeb Workers APIを使うという手もあるので、そのうち試したい。
この調子でループの分割を一生書き続けていたい。

追記

new Date().getTime()ではなくDate.now()を使うとDateのオブジェクトを作らず済むので軽いはず、と指摘をもらえた。
試したところ手元では1.5倍くらい速いようだった。

i=Date.now();j=0; while(Date.now() - i < 1000) { j++; };
k=new Date();l=0; while(new Date().getTime() - k.getTime() < 1000) { l++; }
console.log(j, l, j/l);
11400689 7299605 1.5618227287640907