ブラウザで長いループや、重い処理をともなうループを回したいとき、同期的に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
Date\.now() だとDateオブジェクトをつくらないぶん軽いと思います。
— FUJI Goro (@__gfx__) 2020年11月26日