コハム

Webクリエイターのコハムが提供する、Web制作に役立つ情報をまとめたブログです。

JavaScript実践術:タスク分割でUXを改善する7つのテクニック

There are a lot of ways to break up long tasks in JavaScript.

記事は上記記事を意訳したものです。

※当ブログでの翻訳記事は元サイト様に許可を得て掲載しています。


メインスレッドを長い高負荷なタスクに占有させることで、サイトのユーザーエクスペリエンスを台無しにすることは簡単です。アプリケーションがどれほど複雑になっても、イベントループは一度に一つのことしかできません。あなたのコードがメインスレッドを占領していると、他のすべての処理は待機状態になり、ユーザーがそれに気づくまでにそれほど時間はかかりません。

こちらは単純な例です:画面上のカウントを増やすボタンと、何か重い作業をする大きなループがあります。これは単に同期的な一時停止を実行していますが、何らかの理由でメインスレッド上で、しかも順番通りに実行する必要がある意味のある処理だと仮定してください。

<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>

<script>
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  button.addEventListener("click", () => {
    clickCount.innerText = Number(clickCount.innerText) + 1;
  });

  const items = new Array(100).fill(null);

  for (const i of items) {
    loopCount.innerText = Number(loopCount.innerText) + 1;
    waitSync(50);
  }
</script>

これを実行すると、ループカウントを含めて何も視覚的に更新されません。ブラウザが画面に描画する機会を得られないからです。どれだけ必死にクリックしても、これが得られるのはたったこれだけです。ループ処理が完全に終了した時にのみ、フィードバックを得ることができます。

開発ツールのフレームチャートもこれを裏付けています。イベントループ内の単一のタスクが完了するまでに5秒かかっています。恐ろしいですね。

もし以前に同様の状況に直面したことがあれば、解決策は大きなタスクを定期的に分割して、イベントループの複数のティック(実行単位)に分散させることだとわかるでしょう。これにより、ブラウザの他の部分がボタンクリックの処理や再描画など、他の重要なことのためにメインスレッドを使用する機会を得られます。次のような状態から:

このような状態に移行したいです:

実はこれを実現する方法は驚くほど多くあります。最も古典的な方法であるリカーション(再帰)から探っていきましょう。

#1: setTimeout() + 再帰

ネイティブのプロミスが存在する前にJavaScriptを書いていた方なら、間違いなく次のようなコードを見たことがあるでしょう:関数がタイムアウトのコールバックから自分自身を再帰的に呼び出しています。

function processItems(items, index) {
  index = index || 0;
  var currentItem = items[index];

  console.log("processing item:", currentItem);

  if (index + 1 < items.length) {
    setTimeout(function () {
      processItems(items, index + 1);
    }, 0);
  }
}

processItems(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);

今日でもこれには何も問題ありません。結局のところ、目的は達成されています – 各アイテムは異なるティックで処理され、作業が分散されます。フレームチャートの400msセクションを見てください。一つの大きなタスクの代わりに、多くの小さなタスクが得られます:

そしてこれにより、UIはスムーズに応答します。クリックハンドラは機能し、ブラウザは画面更新を描画できます。

しかし、ES6から10年が経過し、ブラウザは同じことを達成するためのいくつかの方法を提供しています。そのすべてが、プロミスによってより人間工学的になっています。

#2: Async/Await & タイムアウト

この組み合わせにより、再帰を捨てて物事を少し合理化できます:

<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>

<script>
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  button.addEventListener("click", () => {
    clickCount.innerText = Number(clickCount.innerText) + 1;
  });

  (async () => {
    const items = new Array(100).fill(null);

    for (const i of items) {
      loopCount.innerText = Number(loopCount.innerText) + 1;

      await new Promise((resolve) => setTimeout(resolve, 0));
          
      waitSync(50);
    }
  })();
</script>

ずっと良くなりました。単純なforループとプロミスの解決を待つだけです。イベントループのリズムはとても似ていますが、一つの重要な変更があります(赤で示されています):

プロミスの.then()メソッドは常にコールスタック上の他のすべてが終了した後に、マイクロタスクキューで実行されます。ほとんどの場合、これは重要な違いではありませんが、それでも注目に値します。

#3: scheduler.postTask()

Schedulerインターフェースは比較的新しいChromiumブラウザの機能で、より多くの制御と効率性を持つタスクのスケジューリングのためのファーストクラスツールとなることを意図しています。基本的に、何十年もの間setTimeout()に頼ってきたことのより良いバージョンです。

const items = new Array(100).fill(null);

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await new Promise((resolve) => scheduler.postTask(resolve));

  waitSync(50);
}

postTask()でループを実行する際に興味深いのは、スケジュールされたタスク間の時間の量です。再び400msにわたるフレームチャートの一部です。前のタスクの後に新しいタスクが実行される様子がいかに緊密かに注目してください。

postTask()のデフォルトの優先度は「user-visible」で、setTimeout(() => {}, 0)の優先度に匹敵するように見えます。出力は常にコード内での実行順序を反映しているようです:

setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask"));

// setTimeout
// postTask
scheduler.postTask(() => console.log("postTask"));
setTimeout(() => console.log("setTimeout"));

// postTask
// setTimeout

しかしsetTimeout()とは異なり、postTask()はスケジューリング用に作られており、タイムアウトと同じ制約を受けません。それによってスケジュールされたすべてのものはタスクキューの先頭に配置され、特に急速にキューに入れられる場合に、他のアイテムが割り込んで実行を遅らせることを防ぎます。

確かなことは言えませんが、postTask()は一つの目的を持つ洗練されたマシンであるため、フレームチャートにそれが反映されていると思います。とはいえ、postTask()でスケジュールされたタスクの優先度をさらに最大化することも可能です:

scheduler.postTask(() => {
  console.log("postTask");
}, { priority: "user-blocking" });

「user-blocking」優先度は、ページ上でのユーザーの体験にとって重要なタスク(ユーザー入力への応答など)のために意図されています。そのため、大きな作業負荷を分割するためだけに使用する価値はおそらくありません。結局のところ、私たちは他の作業が行われるようにイベントループに丁寧に譲るよう努めています。実際、「background」を使用して優先度をさらに下げる価値があるかもしれません:

scheduler.postTask(() => {
  console.log("postTask - background");
}, { priority: "background" });

setTimeout(() => console.log("setTimeout"));

scheduler.postTask(() => console.log("postTask - default"));

// setTimeout
// postTask - default
// postTask - background

残念ながら、Schedulerインターフェース全体には欠点があります:すべてのブラウザでまだそれほどサポートされていません。しかし、既存の非同期APIでポリフィルするのは十分簡単です。したがって、少なくともユーザーの相当部分がそれから恩恵を受けるでしょう。

requestIdleCallback()について

このように優先度を譲るのが良いなら、requestIdleCallback()が思い浮かぶかもしれません。これは「アイドル」期間があるときにコールバックを実行するように設計されています。問題は、それがいつ実行されるか、または実行されるかどうかの技術的な保証がないことです。呼び出される際にタイムアウトを設定することもできますが、それでもSafariがこのAPIをまったくサポートしていないという事実に対処する必要があります。

さらに、MDNは必要な作業にはrequestIdleCallback()よりもタイムアウトを推奨しているので、この目的のためにはそれを避けるのが良いでしょう。

#4: scheduler.yield()

Schedulerインターフェイスのyield()メソッドは、これまで紹介したアプローチよりも少し特別です。なぜなら、まさにこのようなシナリオのために作られたからです。MDNから:

Schedulerインターフェイスのyield()メソッドは、タスク中にメインスレッドに譲り、後で実行を継続するために使用されます。その継続は優先度付けされたタスクとしてスケジュールされます... これにより、長時間実行される作業を分割して、ブラウザの応答性を保つことができます。

これは初めて使うとさらに明確になります。自分自身のプロミスを返して解決する必要はもうありません。提供されたプロミスを待つだけです:

const items = new Array(100).fill(null);

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;
  
  await scheduler.yield();
  
  waitSync(50);
}

フレームチャートも少し整理されます。スタック内で識別される必要のあるアイテムが一つ少なくなっていることに注目してください。

このAPIは非常に使いやすいため、あらゆる場所で使用する機会を見つけ始めずにはいられません。変更時に高負荷なタスクを開始するチェックボックスを考えてみましょう:

document
  .querySelector('input[type="checkbox"]')
  .addEventListener("change", function (e) {
    waitSync(1000);
});

現状では、チェックボックスをクリックするとUIが1秒間フリーズします。

しかし今、クリック後すぐにブラウザに制御を譲り、UIを更新する機会を与えましょう。

document
  .querySelector('input[type="checkbox"]')
  .addEventListener("change", async function (e) {
+    await scheduler.yield();

    waitSync(1000);
});

見てください。素早く応答します。

Schedulerインターフェイスの他の部分と同様に、このAPIもしっかりとしたブラウザサポートが不足していますが、ポリフィルするのは簡単です:

globalThis.scheduler = globalThis.scheduler || {};
globalThis.scheduler.yield = 
  globalThis.scheduler.yield || 
  (() => new Promise((r) => setTimeout(r, 0)));

#5: requestAnimationFrame()

requestAnimationFrame()APIは、ブラウザの再描画サイクルの周りで作業をスケジュールするように設計されています。そのため、コールバックをスケジュールする際に非常に正確です。常に次の描画の直前になり、それがおそらくこのフレームチャートのタスクがこれほど緊密に配置されている理由を説明しています。アニメーションフレームコールバックは実質的にレンダリングフェーズの特定の時間に実行される独自の「キュー」を持っているため、他のタスクが邪魔をして行列の後ろに押しやることは難しいです。

しかし、再描画の周りで高負荷な作業を行うと、レンダリングにも影響を与えるようです。同じ期間中のフレームを見てください。黄色/線のついた部分は「部分的に提示されたフレーム」を示しています:

これは他のタスク分割戦術では発生しませんでした。これとアニメーションフレームコールバックがタブがアクティブでない限り通常実行されないという事実を考慮すると、おそらくこのオプションも避けるべきでしょう。

#6: MessageChannel()

この方法をこのように使われるのをあまり見かけませんが、見かけるとすれば、多くの場合、ゼロ遅延タイムアウトのより軽い代替として選択されます。ブラウザにタイマーをキューに入れてコールバックをスケジュールするよう頼む代わりに、チャネルをインスタンス化して即座にメッセージを投稿します:

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await new Promise((resolve) => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve();
    channel.port2.postMessage(null);
  });

  waitSync(50);
}

フレームチャートの様子から、パフォーマンスについて何か言えるかもしれません。スケジュールされた各タスク間にあまり遅延がありません:

しかし、このアプローチの(主観的な)欠点は、セットアップが複雑なことです。これがそのために設計されたものでないことは明らかです。

#7: Web Workers

これまでは別の方法を述べてきましたが、メインスレッドから離れて作業を実行できるなら、Web Workerは間違いなく最初の選択肢であるべきです。技術的には、ワーカーコードを収容するために別のファイルさえ必要ありません:

const items = new Array(100).fill(null);

const workerScript = `
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  self.onmessage = function(e) {
    waitSync(50);
    self.postMessage('Process complete!');
  }
`;

const blob = new Blob([workerScript], { type: "text/javascipt" });
const worker = new Worker(window.URL.createObjectURL(blob));

for (const i of items) {
  worker.postMessage(items);

  await new Promise((resolve) => {
    worker.onmessage = function (e) {
      loopCount.innerText = Number(loopCount.innerText) + 1;
      resolve();
    };
  });
}

個々のアイテムの作業が他の場所で実行されるとき、メインスレッドがどれほどクリアであるかを見てください。代わりに、それはすべて「Worker」セクションの下に押し下げられ、活動のためのたくさんのスペースを残しています。

私たちが使用しているシナリオでは、進行状況をUIに反映させる必要があるため、個々のアイテムをワーカーに渡して応答を待っています。しかし、アイテムのリスト全体をワーカーに一度に渡すことができるなら、確かにそうすべきです。それによりオーバーヘッドがさらに削減されるでしょう。

どのように選択すればよいか?

ここで紹介したアプローチは網羅的ではありませんが、長いタスクを分割する際に考慮すべき様々なトレードオフをうまく表していると思います。それでも、ニーズによっては、私自身はおそらくこれらの一部しか使わないでしょう。

メインスレッドから作業を実行できるなら、断然Web Workerを選びます。ブラウザ間でとてもよくサポートされており、その目的はまさにメインスレッドから作業を切り離すことです。唯一の欠点は使いにくいAPIですが、これはWorkerizeやViteの組み込みワーカーインポートなどのツールによって軽減されます。

タスクを分割するためのシンプルな方法が必要なら、scheduler.yield()を選びます。非Chromiumユーザー向けにポリフィルする必要があるのはあまり好きではありませんが、大多数の人々がそれから恩恵を受けるので、その追加の負担は引き受ける価値があります。

チャンク化された作業がどのように優先されるかについて非常に細かい制御が必要な場合は、scheduler.postTask()を選びます。そのニーズに合わせてこの機能をカスタマイズする深さは印象的です。優先度制御、遅延、タスクのキャンセルなどはすべてこのAPIに含まれていますが、.yield()と同様に、今のところポリフィルが必要です。

ブラウザのサポートと信頼性が最も重要な場合は、単にsetTimeout()を選びます。華やかな代替手段が登場しても、この伝説は消えません。

©コハム