scheduler.yield()とpostTask()入門|重いJavaScriptでUIを固めない

UIを固めないJavaScript。scheduler.yieldとpostTaskの入門 Web標準API
カテゴリー
Web標準API
公開日
2026.09.26

はじめに

大量データをメインスレッドで一度に処理すると、クリックや描画も同じスレッドで待たされます。Prioritized Task Scheduling APIは、scheduler.yield()で処理を一時中断したり、scheduler.postTask()で優先度付きタスクを登録したりできます。

この記事では1件ずつJSONを整形する例を使います。CPU処理そのものを高速化するAPIではありません。

なぜ画面が固まるのか

多くのJavaScript、クリック処理、画面描画はメインスレッドで順番に実行されます。長いループがメインスレッドを使い続けると、利用者がボタンを押しても処理の順番が来ません。yield()は長い処理の途中でブラウザへいったん順番を返します。

  • メインスレッド:画面描画や多くのJavaScriptを実行する中心の処理場所
  • Long Task:メインスレッドを長時間占有し、操作遅延の原因になる処理
  • AbortSignal:処理の中止要求を関数へ伝える仕組み
  • フォールバック:新しいAPIが使えない環境で代わりに使う処理

必要環境と起動

Node.js 22.12以上とnpm、ブラウザを用意します。新しいフォルダーに次のindex.htmlを保存します。後述の3つのJavaScriptブロックは順番に1つのapp.jsへ保存してください。

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scheduler API検証</title>
<h1>Scheduler API検証</h1>
<button id="start">処理開始</button>
<button id="stop" disabled>中断</button>
<button id="click">クリック確認</button>
<p id="clicks">クリック: 0回</p>
<p id="result" role="status">未実行</p>
<script type="module" src="./app.js"></script>
</html>

同じフォルダーでnpx --yes vite@7.3.6 --host 127.0.0.1を実行し、表示されたURLを開きます。初回はnpmからViteを取得します。終了はCtrl+Cです。APIのないブラウザではタイマーへフォールバックします。ただしタイマーは優先度を再現しません。

yieldで長いループを分割する

async function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    await scheduler.yield();
  } else {
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

async function processRows(rows, { signal, chunkSize = 200 } = {}) {
  if (!Array.isArray(rows)) throw new TypeError("rowsは配列にしてください");
  if (!Number.isSafeInteger(chunkSize) || chunkSize < 1) {
    throw new RangeError("chunkSizeは1以上の安全な整数にしてください");
  }
  signal?.throwIfAborted();
  const results = [];

  for (let i = 0; i < rows.length; i++) {
    signal?.throwIfAborted();
    results.push(JSON.stringify(rows[i]));

    if ((i + 1) % chunkSize === 0) {
      await yieldToMain();
    }
  }
  signal?.throwIfAborted();
  return results;
}

chunkSizeは端末と処理内容で調整します。件数では処理時間が安定しない場合、performance.now()を使い、およそ8~16msごとにyieldする方法もあります。この値は万能な推奨値ではなく測定の出発点です。

postTaskで優先度を付ける

async function scheduleAnalytics(send, signal) {
  if (globalThis.scheduler?.postTask) {
    return scheduler.postTask(send, {
      priority: "background",
      signal,
    });
  }
  signal?.throwIfAborted();
  return send();
}

const controller = new AbortController();
await scheduleAnalytics(() => console.log("低優先度の処理を実行"), controller.signal);

優先度はuser-blocking、user-visible、backgroundです。重要な作業をすべてuser-blockingにすると優先順位の意味がなくなります。

動的に優先度を変更する場合はTaskControllerを利用できますが、対応状況を確認します。単純なキャンセルだけならAbortControllerを渡す設計が理解しやすいでしょう。

ボタンから実行する

次も同じapp.jsの末尾へ追加します。処理中に「クリック確認」「中断」を操作できます。入力データの作成自体は同期処理なので、この小さなデモでも無制限に件数を増やさないでください。

const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const result = document.querySelector("#result");
let active;
let clicks = 0;
document.querySelector("#click").addEventListener("click", () => {
  document.querySelector("#clicks").textContent = `クリック: ${++clicks}回`;
});
stop.addEventListener("click", () => active?.abort());
start.addEventListener("click", async () => {
  if (active) return;
  active = new AbortController();
  start.disabled = true;
  stop.disabled = false;
  result.textContent = "処理中";
  try {
    const rows = Array.from({ length: 500000 }, (_, i) => ({ id: i }));
    const output = await processRows(rows, { signal: active.signal });
    result.textContent = `完了: ${output.length}件`;
  } catch (error) {
    result.textContent = error.name === "AbortError" ? "中断しました" : error.message;
  } finally {
    active = undefined;
    start.disabled = false;
    stop.disabled = true;
  }
});

yieldとpostTaskの使い分け

目的API
実行中のasync関数を途中で分割scheduler.yield()
後で行う処理へ優先度を付けるscheduler.postTask()
重い処理を別スレッドへ移すWeb Worker
GPUで並列計算WebGPUなど別のAPI

yieldを入れても合計CPU時間は減りません。数百ms以上の純粋計算や画像処理は、Web Workerへ移せないか先に検討します。

動作確認

Performanceパネルで分割前後を記録し、Long Taskの長さと入力応答を比較します。処理時間だけでなく、ボタンを連打したときに入力が反映されるかも確認します。

  • 0件でも正常終了する
  • 処理中にabortすると中断する
  • 非対応環境でフォールバックが動く
  • 途中で例外が起きたときPromiseがrejectされる
  • 分割しすぎて総時間が大幅に悪化していない

簡単に違いを見るには、数万件の配列を用意し、処理中にも押せる「クリック確認」ボタンを画面へ置きます。分割前はボタン表示の更新が処理終了まで遅れ、分割後は途中で反映されれば改善を体感できます。端末性能で結果が変わるため、件数やchunkSizeを変えて測定してください。

よくあるトラブル

  • scheduler is not defined:機能検出をせず直接呼び出している。記事のyieldToMain()を経由する
  • yield()を入れても総時間が短くならない:目的は高速化ではなく入力・描画を割り込ませることである
  • 画面がまだ固まる:1件の処理自体が長い、またはchunkSizeが大きすぎる
  • 分割後に極端に遅くなる:細かく分けすぎて切り替え回数が増えている
  • 中止できない:ループ内でsignal.throwIfAborted()を定期的に実行する

画像変換や巨大な計算など、1処理そのものが重い場合はWeb Workerへの移動を優先します。

関連記事

まとめ

scheduler.yield()は長いasync処理の分割、postTask()は今後実行する処理の優先度とキャンセルに向きます。機能検出を行い、Performanceパネルと実機入力で効果を確認してください。

参考リンク

コメント

タイトルとURLをコピーしました