JavaScript イベントループと非同期プログラミング徹底解説:マクロタスクからマイクロタスクまでの完全な流れ

前端工程

あるバグから始まった話

昨年、同僚がポーリング関数を書きました。30 秒後、ブラウザが完全にフリーズしました:

function pollServer() {
  while (true) {
    const response = fetch('/api/status');  // 毎秒ポーリングするつもり
    // ...
  }
}

彼はすぐに問題に気づけませんでした——fetch は非同期なのに、なぜ while(true) がブロックするのか?これが「非同期をマルチスレッドと勘違いする」典型的な罠です。while(true) はメインスレッドで同期的に実行されるため、イベントループが fetch のコールバックを実行する機会を得られません。イベントループを理解することは、JavaScript のシングルスレッド並行モデルを理解することです。


コールスタック(Call Stack)

JavaScript エンジンにはコールスタックが 1 つだけあります。関数呼び出しはプッシュされ、戻りはポップされます:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  const result = multiply(n, n);
  return result;
}

function main() {
  const value = square(5);
  console.log(value);
}

main();

スタックオーバーフローRangeError: Maximum call stack size exceeded——通常は無限再帰です。Chrome のコールスタック上限は約 10,000 フレームです。


タスクキュー(Task Queue)

コールスタックが空になると、イベントループはタスクキューから 1 つのタスクを取得して実行します。タスクキュー内の各項目は**マクロタスク(MacroTask)**と呼ばれます。

マクロタスクを生成するもの:

発生源
setTimeout / setInterval setTimeout(() => {}, 0)
I/O 操作 ファイル読み書き、ネットワークリクエストコールバック
UI レンダリング requestAnimationFrame、ブラウザレンダーフレーム
ユーザー操作 clickkeydown イベントコールバック
setImmediate Node.js 専用
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');

// 出力:1 → 3 → 2
// setTimeout のコールバックはマクロタスクキューに入り、
// 現在のコールスタックが空になるまで待つ必要がある

マイクロタスク(MicroTask)

マイクロタスクは ECMAScript 仕様の概念です。各マクロタスクの実行後、イベントループはマイクロタスクキュー全体を空にしてから次のマクロタスクを取得します。

マイクロタスクを生成するもの:

発生源
Promise.then/catch/finally Promise.resolve().then(fn)
async/await(await 以降のコード) await something
queueMicrotask queueMicrotask(fn)
MutationObserver DOM 変更コールバック

これが面接問題の核心です:マイクロタスクは次のマクロタスクより先に実行されます。

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => console.log('3'));
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => console.log('5'), 0);
});

console.log('6');

// 出力:1 → 6 → 4 → 2 → 3 → 5
//
// 解説:
// 1. 同期コード:1, 6
// 2. マイクロタスクキュー:[then(()=>4)] → 4 を出力、setTimeout(5) をマクロタスクに
// 3. マクロタスクキュー:[setTimeout(2), setTimeout(5)]
// 4. 最初のマクロタスク:2 を出力、then(()=>3) がマイクロタスクに
// 5. マイクロタスクを空にする:3 を出力
// 6. 2 番目のマクロタスク:5 を出力

完全なイベントループモデル

┌──────────────────────────────────────┐
│         コールスタック(同期)          │
│          ↓ スタックが空               │
│    マイクロタスクキュー(全消去)        │
│          ↓ マイクロタスクが空          │
│    マクロタスクキュー(1 つ実行)        │
│          ↓ コールスタックに戻る         │
│          ループ...                    │
└──────────────────────────────────────┘

1 つの完全な tick:

  1. マクロタスクを 1 つ取得して実行
  2. 実行中に生成されたマイクロタスクをすべて同じ tick 内で実行
  3. マイクロタスクから生成されたマイクロタスクも同じ tick で実行(再帰的に空にする)
  4. レンダリング(必要な場合)
  5. 次のマクロタスクを取得

async/await の内部動作

async/await は Generator + Promise の糖衣構文です。

// 私たちが書くコード:
async function fetchUser() {
  const response = await fetch('/api/user');
  const user = await response.json();
  return user;
}

// 等価なコード:
function fetchUser() {
  return Promise.resolve().then(() => {
    return fetch('/api/user');
  }).then((response) => {
    return response.json();
  });
}

重要な詳細:await の後の式は Promise.resolve() でラップされます。値がすでに Promise でない場合は、自動的に解決済み Promise に変換されます。つまり await 42await Promise.resolve(42) と等価です。


Node.js のイベントループ

Node.js のイベントループはブラウザより複雑で、6 つのフェーズがあり、それぞれに独自の FIFO キューがあります:

   ┌───────────────────────┐
┌─>│        timers         │  setTimeout, setInterval コールバック
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │   pending callbacks   │  遅延 I/O コールバック
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │  内部使用
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │        poll           │  新しい I/O イベントの取得(コアフェーズ)
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │        check          │  setImmediate コールバック
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │    close callbacks    │  socket.on('close', ...)
│  └──────────┬────────────┘
└──────────────┘
      各フェーズ終了後 → マイクロタスクキューを空にする

process.nextTick の罠

console.log('1');

process.nextTick(() => {
  console.log('2');
  process.nextTick(() => console.log('3'));
});

Promise.resolve().then(() => console.log('4'));

console.log('5');

// 出力:1 → 5 → 2 → 3 → 4
// nextTick は Promise マイクロタスクより優先される!

実践的チューニング:長いタスクの回避

問題:10,000 件のデータを一度にレンダリング

// ❌ メインスレッドが数秒間フリーズ
function renderAll(items) {
  items.forEach(item => {
    const div = document.createElement('div');
    div.textContent = item.name;
    container.appendChild(div);
  });
}

解決策 1:タイムスライシング

function renderChunked(items, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      const div = document.createElement('div');
      div.textContent = items[index].name;
      container.appendChild(div);
    }

    if (index < items.length) {
      setTimeout(processChunk, 0);
    }
  }

  processChunk();
}

解決策 2:Web Worker(真の並列処理)

// main.js
const worker = new Worker('/workers/data-processor.js');
worker.postMessage({ data: rawData, action: 'transform' });
worker.onmessage = (event) => { renderData(event.data); };

関連ツール

ブラウザローカルツールを無料で試す →

#JavaScript#事件循环#异步编程#Promise#教程