JavaScript | 非同期処理:パフォーマンス最適化 - 長時間処理の分割

JavaScript JavaScript
スポンサーリンク

なぜ「長時間処理の分割」が必要になるのか

JavaScript は基本的に「シングルスレッド」で動きます。
つまり、1本の道に、処理が一列に並んで流れているイメージです。

その道の上で、
重い処理(ループ・計算・巨大配列の処理など)をドカッと一気にやってしまうと、

画面が固まる(クリックしても反応しない)
スクロールがカクつく
アニメーションが止まる

といった「フリーズしたような状態」が起きます。

これを避けるためにやるのが、
「長時間かかる処理を、小さなかたまりに分割して、少しずつ進める」
というテクニックです。


まずは「悪い例」:重いループを一気に回す

大量データを一気に処理してしまうパターン

例えば、10万件の配列を処理するコードを考えます。

function heavyTask() {
  const arr = Array.from({ length: 100000 }, (_, i) => i);

  console.log("処理開始");

  let sum = 0;
  for (const n of arr) {
    // ちょっと重い計算だと思ってください
    for (let i = 0; i < 1000; i++) {
      sum += n * i;
    }
  }

  console.log("処理終了", sum);
}

heavyTask();
JavaScript

この heavyTask() をブラウザで実行すると、
数秒間、画面が固まるような感覚になるはずです。

なぜかというと、

JavaScript のスレッドがこのループで占有されている
→ その間、クリック・スクロール・描画などのイベントを処理できない

からです。

ここが重要です。
「長時間処理を“同期的に一気にやる”と、UI が全部巻き添えになる」
これが、分割を考える出発点です。


発想の転換:「一気にやる」のではなく「少しずつ進める」

かたまりに分けて、間に“休憩”を挟む

やりたいことはシンプルで、

配列を全部一気に処理するのではなく
例えば 100 件ずつに分けて
1チャンク処理したら、イベントループに戻してあげる

という形に変えることです。

「イベントループに戻す」というのは、
「他の処理(描画やユーザー操作)に一旦バトンを渡す」
くらいのイメージで大丈夫です。


setTimeout を使ったシンプルな分割

チャンク処理の基本パターン

先ほどの heavyTask を「分割版」に書き換えてみます。

function heavyTaskChunked() {
  const arr = Array.from({ length: 100000 }, (_, i) => i);
  const chunkSize = 500; // 1回で処理する件数
  let index = 0;
  let sum = 0;

  console.log("処理開始");

  function processChunk() {
    const end = Math.min(index + chunkSize, arr.length);

    for (; index < end; index++) {
      for (let i = 0; i < 1000; i++) {
        sum += arr[index] * i;
      }
    }

    if (index < arr.length) {
      setTimeout(processChunk, 0); // 次のチャンクを後で実行
    } else {
      console.log("処理終了", sum);
    }
  }

  processChunk();
}

heavyTaskChunked();
JavaScript

ここでやっていることは、

配列を chunkSize 件ずつ処理する
1チャンク終わるごとに setTimeout(processChunk, 0) で「次のチャンク」を後回しにする

というだけです。

setTimeout(..., 0) は、
「今の処理が一段落したら、次のタイミングでこれを実行してね」
という意味になります。

その「一段落」の間に、
ブラウザは描画やユーザー操作の処理を挟めるので、
「重い処理をしているのに、画面が完全には固まらない」
という状態を作れます。

ここが重要です。
「長時間処理を分割する」とは、“処理量を減らす”のではなく、
“処理の連続時間を短く区切る”こと。
その間に UI に呼吸させる。


requestAnimationFrame を使って「描画タイミングに合わせて進める」

アニメーションや描画と相性がいい分割

requestAnimationFrame は、
「次の画面描画の直前に呼ばれるコールバック」を登録する関数です。

これを使うと、
「1フレームごとに少しずつ処理を進める」
という書き方ができます。

function heavyTaskWithRAF() {
  const arr = Array.from({ length: 100000 }, (_, i) => i);
  const chunkSize = 1000;
  let index = 0;
  let sum = 0;

  console.log("処理開始");

  function processChunk() {
    const end = Math.min(index + chunkSize, arr.length);

    for (; index < end; index++) {
      for (let i = 0; i < 500; i++) {
        sum += arr[index] * i;
      }
    }

    if (index < arr.length) {
      requestAnimationFrame(processChunk); // 次のフレームで続き
    } else {
      console.log("処理終了", sum);
    }
  }

  requestAnimationFrame(processChunk);
}

heavyTaskWithRAF();
JavaScript

requestAnimationFrame を使うと、

ブラウザの描画タイミングに合わせて処理が進む
→ アニメーションやスクロールとの相性が良い

というメリットがあります。

「重い処理をしながら、プログレスバーを滑らかに動かしたい」
といったケースでは、setTimeout より requestAnimationFrame の方が気持ちよく動きます。


分割するときに考えるべき「チャンクサイズ」

小さすぎても、大きすぎてもダメ

チャンクサイズ(1回で処理する件数)は、
小さければ小さいほど UI は滑らかになりますが、
その分「全体が終わるまでの時間」は長くなります。

逆に、大きすぎると、
「一回のチャンクが重すぎて、結局カクつく」
ということになります。

例えば、

chunkSize = 10 → すごく滑らかだが、全体が終わるまで時間がかかる
chunkSize = 5000 → 全体は速いが、1チャンクごとに一瞬固まる

みたいなトレードオフです。

ここが重要です。
「長時間処理の分割」は、“速さ”と“滑らかさ”のバランス調整。
チャンクサイズは、実際に動かしてみて決めるパラメータ。


非同期処理との組み合わせ:async/await で書くとどう見えるか

setTimeout を Promise 化して、async 関数で書く

setTimeout を Promise に包んでおくと、
async/await で読みやすく書けます。

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function heavyTaskAsync() {
  const arr = Array.from({ length: 100000 }, (_, i) => i);
  const chunkSize = 500;
  let index = 0;
  let sum = 0;

  console.log("処理開始");

  while (index < arr.length) {
    const end = Math.min(index + chunkSize, arr.length);

    for (; index < end; index++) {
      for (let i = 0; i < 1000; i++) {
        sum += arr[index] * i;
      }
    }

    await delay(0); // イベントループに一旦戻す
  }

  console.log("処理終了", sum);
}

heavyTaskAsync();
JavaScript

やっていることはさっきと同じで、

一定量処理する
await delay(0) で「一旦休憩」
また一定量処理する

という流れです。

async/await を使うと、
「同期っぽい見た目」で
「非同期に分割された処理」を書けるので、
ロジックが追いやすくなります。


もっと重い処理なら Web Worker も選択肢になる(概念だけ)

メインスレッドから完全に分離する

ここまでの話は、
「メインスレッドの中で、長時間処理を細切れにする」
というテクニックでした。

それでも足りないくらい重い処理(画像処理・圧縮・巨大データ解析など)の場合は、
Web Worker という「別スレッド」に処理を逃がす、という選択肢もあります。

Worker に重い処理を任せて、
メインスレッドは UI のことだけ考える、という分業です。

初心者の段階では、
「本当に重い処理は、そもそもメインスレッドでやらない」という考え方がある
くらいを知っておけば十分です。

まずは、
「メインスレッドでやるにしても、長時間処理は分割する」
という感覚をしっかり身につけるのが先です。


初心者として「長時間処理の分割」で本当に押さえてほしいこと

最後に、エッセンスだけをまとめます。

長時間処理を「同期で一気にやる」と、UI が固まる
→ JavaScript はシングルスレッドだから、重い処理が全部を止めてしまう。

分割の基本は「チャンクに分けて、間に休憩を挟む」
setTimeout(..., 0)requestAnimationFrameawait delay(0) でイベントループに戻す。

チャンクサイズは「速さ」と「滑らかさ」のバランス
→ 小さすぎると遅い、大きすぎるとカクつく。実際に動かして調整する。

おすすめは、
さっきの「heavyTask」と「heavyTaskChunked」を両方ブラウザで実行して、
マウスを動かしたりスクロールしたりしながら、
「UI の固まり方の違い」 を自分の目で確かめることです。

その体感が一度でも入ると、
「長時間処理を見つけたら、とりあえず分割を考える」
という反射が身につきます。

それはもう、
「動くコードを書く人」から
「ユーザーの体感まで設計できるエンジニア」 への、かなり大きな一歩です。

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