【JS応用】setTimeout は何を表示するでしょう?

setTimeout は何を表示するでしょう? JavaScriptの非同期実行モデルを完全攻略する

フロントエンド開発の現場において、`setTimeout` は最も基本的なAPIの一つです。しかし、「指定したミリ秒後に実行される」という表面的な理解だけでは、複雑なアプリケーションのバグを特定することは困難です。特に、イベントループ、タスクキュー、そしてコールスタックの挙動を深く理解していないと、期待通りのタイミングで処理が実行されないという事態に直面します。本稿では、`setTimeout` の挙動を軸に、JavaScriptの非同期処理モデルの本質を徹底的に解説します。

イベントループとタスクキューの仕組み

JavaScriptはシングルスレッドで動作する言語です。これは、一度に一つの処理しか実行できないことを意味します。しかし、サーバーとの通信やタイマー処理といった「時間がかかる処理」をメインスレッドで実行すると、画面のレンダリングやユーザー入力がブロックされてしまいます。そこで登場するのが「イベントループ」です。

`setTimeout` が呼び出されると、ブラウザのWeb APIsがタイマーを管理します。指定した時間が経過すると、コールバック関数は「タスクキュー(マクロタスクキュー)」に送られます。メインスレッドのコールスタックが空になったタイミングで、イベントループがタスクキューからタスクを取り出し、コールスタックに積んで実行します。

ここで重要なのは、`setTimeout` に渡した時間が「実行開始時間」ではなく「キューへの投入待ち時間」であるという点です。もしスタック上で重い処理が実行されている場合、タイマー時間が経過していても、コールバックはすぐには実行されません。

setTimeout(fn, 0) の真実

多くのエンジニアが「setTimeout(fn, 0) は即時実行される」と誤解しています。しかし、これは正確ではありません。0ミリ秒を指定しても、コールバック関数は必ず一度タスクキューを経由します。つまり、現在のコールスタックにある同期的な処理がすべて完了し、ブラウザが再描画の準備を整えた後に初めて実行されるのです。

この性質を利用して、重い処理を分割して実行したり、特定の処理を現在の実行サイクルの最後に遅延させたりするテクニックが使われます。

console.log('1: スタート');

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

Promise.resolve().then(() => {
  console.log('3: Promise (マイクロタスク)');
});

console.log('4: エンド');

// 実行結果:
// 1: スタート
// 4: エンド
// 3: Promise (マイクロタスク)
// 2: setTimeout (0ms)

このコードを見てください。なぜPromiseが先に実行されるのでしょうか。それは、JavaScriptには「マクロタスク(setTimeout等)」と「マイクロタスク(Promise等)」という優先順位の異なるキューが存在するからです。イベントループは、一つのマクロタスクを実行するたびにマイクロタスクキューを空になるまで処理します。そのため、0ミリ秒のsetTimeoutよりも、Promiseの解決の方が先に実行されるのです。

クロージャとsetTimeoutの罠

ループ処理の中で `setTimeout` を使用する際、多くの初心者が陥る罠があります。変数のスコープとクロージャの関係を正しく理解していないと、すべてのループで同じ値が表示されてしまうという問題です。

for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// 実行結果:
// 4
// 4
// 4

なぜ「1, 2, 3」ではなく「4, 4, 4」になるのでしょうか。それは、`var` が関数スコープを持ち、ループ完了後には `i` が「4」という値で固定されているからです。`setTimeout` のコールバックが実行される頃には、すでにループは終了しており、すべてのコールバックが同じ変数 `i` を参照しています。

これを解決する現代的な方法は、`let` を使用することです。`let` はブロックスコープを持つため、ループの反復ごとに新しいバインディングが作成されます。

for (let i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// 実行結果:
// 1
// 2
// 3

実務におけるsetTimeoutの注意点

実務において `setTimeout` を使用する際は、以下の3点に注意してください。

1. タイマーの精度:`setTimeout` の時間は保証されません。特にブラウザがバックグラウンドにある場合や、メインスレッドがビジー状態の場合、大幅に遅延する可能性があります。正確な計測が必要な場合は `performance.now()` を使用すべきです。
2. メモリリークの防止:コンポーネントが破棄される前に `setTimeout` が実行されると、存在しないDOMを操作しようとしてエラーが発生したり、メモリリークの原因になったりします。`clearTimeout` を必ず利用し、クリーンアップ処理を徹底してください。
3. 可読性と保守性:非同期処理が連続する場合、`setTimeout` をネストすると「コールバック地獄」に陥ります。現代のJavaScriptでは、`async/await` と `Promise` を組み合わせることで、より直感的で読みやすいコードを書くことが推奨されます。

// 悪い例: ネストされたタイマー
setTimeout(() => {
  console.log('Step 1');
  setTimeout(() => {
    console.log('Step 2');
  }, 1000);
}, 1000);

// 良い例: Promiseによるラップ
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

async function run() {
  await delay(1000);
  console.log('Step 1');
  await delay(1000);
  console.log('Step 2');
}

まとめ

`setTimeout` は単なる「時間待ち」のためのツールではありません。それはJavaScriptの非同期処理モデル、すなわちタスクキューとイベントループの動きを理解するための登竜門です。

– `setTimeout` は指定時間後にタスクキューへコールバックを投入する。
– 実行の優先順位は「同期処理 > マイクロタスク(Promise等) > マクロタスク(setTimeout等)」である。
– ループ処理における変数の扱いはスコープを意識する必要がある。
– 実務では `clearTimeout` による制御と、可能な限り `async/await` を活用したクリーンなコード設計を行う。

これらの知識を備えることで、非同期処理に起因する予期せぬ挙動を論理的にデバッグできるようになります。ブラウザのレンダリングサイクルとJavaScriptの実行モデルを深く理解し、より堅牢でパフォーマンスの高いフロントエンドアプリケーションを構築してください。

コメント

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