C
TypeScript/非同期/Lesson 02

イベントループ — Microtask vs Macrotask、型でキューを表現する

30分·theory
このチャプター
2/7
TypeScript

イベントループ — Microtask vs Macrotask、型でキューを表現する

💡 なぜ学ぶべきか? — キューが見えれば、デバッグが見える

🎯 PromiseがsetTimeoutより先に実行される理由 — マイクロタスクキューはマクロタスクキューより優先されます。面接の定番問題です。
💼 TypeScriptはキュー自体を変えませんが、キューに入れる関数のシグネチャを強制できます。誤ったコールバックをキューに入れるミスをコンパイル時に防ぎます。
`queueMicrotask`・`setImmediate`・`requestAnimationFrame`のコールバックシグネチャをTypeScriptは正確に把握しており、誤った呼び出しパターンを防ぎます。
🔗 非同期コードの順序バグ(レースコンディション)は型システムでは検出できません — ただし、キューモデルを理解していなければそのようなバグをデバッグすることもできません。
🏢 실무에서는
Reactの`useEffect`、Next.jsの`useTransition`、Vueの`nextTick` — これらはすべてマイクロタスク・マクロタスクキューの上に構築されています。`setState`の直後にDOMを読むと古い値が返る理由、`await`の一行の間にpropsが変わりうる理由 — すべてイベントループモデルで説明できます。

Call Stack → Web API → Queue → Loop

1. イベントループの 4 つの構成要素

構成要素役割
Call Stack現在実行中の関数のスタック
Web API / Node APIsetTimeoutfetchfs.readFile などホストが提供する非同期作業場
Macrotask QueuesetTimeoutsetIntervalsetImmediate・IO コールバックが待機するキュー
Microtask QueuePromise.thenqueueMicrotaskMutationObserver のコールバックが待機するキュー

2. ループの 1 サイクル

1. Call Stack が空になったら、Microtask Queue をすべて空にします。
2. 次に Macrotask Queue から1 件だけ取り出します。
3. 再び Microtask Queue を空にします。
4. 繰り返し。

ts
console.log('A');                                  // 1
setTimeout(() => console.log('B'), 0);             // macrotask
Promise.resolve().then(() => console.log('C'));    // microtask
console.log('D');                                  // 2
// 出力: A → D → C → B  (同期 → microtask → macrotask)

3. TypeScript が守ってくれる部分

ts
// queueMicrotask のコールバックシグネチャは () => void
queueMicrotask(() => console.log('hi'));   // ✅
// queueMicrotask((x: number) => console.log(x)); // ❌ TS がブロック — 引数なしのコールバックのみ

// setTimeout のコールバックは可変引数も受け取れる (TS はこれを把握している)
setTimeout((name: string) => console.log(name), 1000, '山田太郎');
//                                                     ^^^^^^^ コールバックの第 1 引数として渡される

4. async/await がキューと出会う地点

ts
async function f() {
  console.log('1');
  await Promise.resolve(); // ここで関数が一時停止
  console.log('2');        // この行は microtask キューに入る
}
f();
console.log('3');
// 出力: 1 → 3 → 2
// await 以降のコードは常に microtask として後続処理される
💻 🅰️ JavaScript スタイル — キューモデルのシミュレーション(型なし)
// ❌ JS — キューを表現しても型が貧弱

const microtasks = []; // 何が入るか分からない
const macrotasks = [];

function queueMicro(fn) { microtasks.push(fn); } // fnのシグネチャ?
function queueMacro(fn) { macrotasks.push(fn); }

function runLoop() {
  // microtaskをすべて空にする
  while (microtasks.length) microtasks.shift()();
  // macrotask 1つ
  if (macrotasks.length) macrotasks.shift()();
}

queueMacro(() => console.log('B: macrotask'));
queueMicro(() => console.log('A: microtask'));
console.log('start');
runLoop();
runLoop();
// 出力: start → A → B
💻 🅱️ TypeScript スタイル — キューの型を明示する
// ✅ TS — キューを型で表現

type Task = () => void;

const microtasks: Task[] = [];
const macrotasks: Task[] = [];

function queueMicro(fn: Task): void { microtasks.push(fn); }
function queueMacro(fn: Task): void { macrotasks.push(fn); }

function runLoop(): void {
  while (microtasks.length) {
    const task = microtasks.shift();
    task?.(); // shiftの結果はTask | undefined — TSが強制的に絞り込む
  }
  const next = macrotasks.shift();
  next?.();
}

queueMacro(() => console.log('B: macrotask'));
queueMicro(() => console.log('A: microtask'));
// queueMicro('not a function'); // ❌ TSが即座に拒否 — Taskではない
// queueMicro((x: number) => x); // ❌ Taskシグネチャ () => void と異なる

console.log('start');
runLoop();
runLoop();

// 実際のブラウザAPIも同じ原理
queueMicrotask(() => console.log('本当のmicrotask'));
setTimeout((name: string) => console.log('macrotask:', name), 0, '山田太郎');
//          ^^^^^^^^^^^^^ TSがsetTimeoutの可変引数シグネチャを把握している

💡 💡 イベントループ面接の定番 + TypeScript が検出できるミス

1. Promise が setTimeout(0) より先に実行される理由
Microtask キューの優先度: 1 サイクル内で microtask をすべて処理してから、macrotask を 1 件処理します。

2. await 以降のコードは常に microtask

ts
async function f() {
  console.log('A');
  await Promise.resolve(); // ここで関数が一時停止
  console.log('B');        // microtask として後続処理
}

3. queueMicrotask のコールバックは引数なし — TypeScript が強制

ts
queueMicrotask(() => console.log('ok'));        // ✅
// queueMicrotask((x: number) => console.log(x)); // ❌

4. setTimeout の可変引数を TypeScript は把握している

ts
setTimeout((a: number, b: number) => console.log(a + b), 0, 1, 2);
// コールバック引数 (a, b) と setTimeout の第 3・4 引数 (1, 2) のマッピングを TS が検証

5. 無限 microtask はメインスレッドを止める

ts
function infinite() { Promise.resolve().then(infinite); } // 🚨 ブラウザがフリーズ

TypeScript では検出不可 — コードレビューで防ぐこと。

⚡ 実際に試してみよう — イベントループの順序

同期 → microtask → macrotask の順序を直接確認します。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

理解度チェック

次のコードの出力順序は? ```ts console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D'); ```
💡 同期コード(A、D)が最初に実行される → コールスタックが空になったらマイクロタスクキュー(C)を空にする → 次にマクロタスク(B)を1件処理。よって **A → D → C → B**。マイクロタスクがマクロタスクより先に実行されるのがポイントです。