こんにちは!フロントエンドからNode.jsの深層まで、JavaScriptのランタイムの挙動を愛してやまないチーフアーキテクトです。
今回は、JavaScriptを学ぶ誰もが一度はハマる、そして中級者へのステップアップにおいて必ず超えなければならない「forループと非同期処理、そしてクロージャが引き起こす変数共有の罠」について、V8エンジンの裏側の動きまで踏み込んで徹底的に解説していきます。
「プログラミングを始めて少し経ち、ループと非同期処理(`setTimeout`など)を組み合わせたら、なぜか変数の値がおかしくなった……」
そんな経験はありませんか?
ここをクリアすれば、JavaScriptのスコープと非同期処理の挙動は完全にあなたのものになります。温かく丁寧に解説していきますので、ぜひ一緒に本質をマスターしていきましょう!
—
1. 誰もが通る「変な挙動」:何が起きているのか?
まずは、よくある失敗例のコードを見てみましょう。0秒後(実際には最小遅延後)に、ループのインデックスを順番に出力したいだけのコードです。
// 【アンチパターン】varを使ったループと非同期処理
function countWithVar() {
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(`現在のカウント: ${i}`);
}, i 1000);
}
}
countWithVar();
直感的には、1秒後に「1」、2秒後に「2」、3秒後に「3」と出力されてほしいですよね。しかし、上記のコードを実行すると、なんと1秒後、2秒後、3秒後のすべてで「現在のカウント: 4」と出力されてしまうのです。
「えっ、どうして? `i` は1ずつ増えていたはずじゃ……?」と思いますよね。
この不可解な現象を引き起こしている犯人が、JavaScriptの `var` のスコープ特性 と 非同期処理のタイミング です。
—
2. なぜバグるのか?ランタイムの裏側を覗く
この現象の謎を解く鍵は、JavaScriptエンジン(V8など)がメモリ上で変数をどう扱い、非同期タスクをどうスケジュールしているかという「ランタイムの仕組み」にあります。
ポイント①:`var` は「関数スコープ」であり、ループごとに新しい箱を作らない
`var` で宣言された変数 `i` は、ブロックスコープ(`{}` の中)ではなく、最も近い関数スコープ(またはグローバルスコープ)に属します。
つまり、`for` ループの中で `var i` と書いても、メモリ上にはただ1つの変数 `i` が存在し、ループが回るたびにその値が「4」に上書きされていくことになります。
ポイント②:ループの高速実行 vs 非同期処理の遅延
JavaScriptのシングルスレッドな世界では、`for` ループの処理は一瞬で駆け抜けます。
1. ループが猛スピードで回り切り、変数 `i` の値は最終的に `4` になります。
2. その間、`setTimeout` に渡された無名関数(コールバック)たちは、「実行待ちの行列(タスクキュー)」に並んでじっと待機しています。
3. 1秒、2秒、3秒と時間が経ち、コールバックがようやく実行される番がやってきました。この時、コールバック関数が参照している変数 `i` を見に行くと……そこにはすでに上書きされた最終値の `4` しかないのです。
これが、すべてのタイマーが「4」を出力してしまったメカニズムです。
—
3. 救世主 `let`:ブロック単位の「記憶の隔離」
この問題を一発で、そして美しく解決してくれるのが、モダンJavaScript(ES2015以降)で導入された `let` です。
`var` を `let` に書き換えてみましょう。
// 【解決策】letを使ったループ
function countWithLet() {
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(`現在のカウント: ${i}`);
}, i 1000);
}
}
countWithLet();
// 期待通りの出力:
// 1秒後 -> 現在のカウント: 1
// 2秒後 -> 現在のカウント: 2
// 3秒後 -> 現在のカウント: 3
見事に期待通りの動作をしました!なぜ `let` に変えただけで、このような魔法のようなことが起きるのでしょうか?
`let` がもたらす「ループごとの新しい変数(環境)」
モダンなJSエンジンは、`for` ループの頭で `let i` が使われている場合、ループの反復(イテレーション)が1回行われるたびに、そのブロック専用の新しい変数 `i` のメモリ領域を裏側で新しく生成し直します。
イメージとしては、以下のような状態が毎回のループで作られています。
- 1回目のループ:変数 `i`(値は1)が作られ、1回目の `setTimeout` にその「1」がしっかり記憶(クロージャとして保存)される。
- 2回目のループ:別の変数 `i`(値は2)が新しく作られ、2回目の `setTimeout` に「2」が記憶される。
- 3回目のループ:さらに別の変数 `i`(値は3)が作られ、3回目の `setTimeout` に「3」が記憶される。
それぞれの非同期処理(コールバック)が、自分専用の「生まれたときの値」を閉じ込めた変数(クロージャ)を持っているため、後から一括で値が変わってしまうような悲劇が起きなくなるのです。
—
4. 現場で役立つ実践知見:古いコード(`var`)をどう扱うか?
「じゃあ、すべての `var` を `let` にすれば完璧だね!」
その通り、現代のモダンな開発においては、基本原則として `var` は使わず `let` と `const` を使用します。
しかし、もしあなたがレガシーなコードベースや、どうしても `var` を使わざるを得ない環境でこの問題に直面した場合はどうすればよいでしょうか?
そんなときは、「即時実行関数(IIFE: Immediately Invoked Function Expression)」 を使って、スコープを強制的に切り、その瞬間の `i` の値を閉じ込めるというテクニックが使われます。
// 【レガシー環境での回避策】IIFEで変数をその場にバインドする
function countWithVarAndIIFE() {
for (var i = 1; i <= 3; i++) {
// 即時実行関数でスコープを作り、現在の i のコピーを引数として渡す
(function(currentI) {
setTimeout(function() {
console.log(`現在のカウント: ${currentI}`);
}, currentI 1000);
})(i); // ここで今の i の値を渡す
}
}
countWithVarAndIIFE();
このように、関数を一つ挟むことで、引数 `currentI` としてその瞬間の値がローカル変数にコピーされ、非同期処理の中でも正しく値を保持できるようになります。モダンな開発では `let` があるため書く機会は減りましたが、JavaScriptのスコープチェーンの本質を理解する上で非常に重要なパターンです。
---
まとめ
今回は、forループと非同期処理、そしてクロージャが織りなす変数共有の罠について深く掘り下げてみました。
- `var` は関数スコープであり、ループ内で値が上書きされていくため、非同期処理のタイミングでは最終値しか見えない。
- `let` はループの反復ごとに新しい変数の領域(ブロックスコープ)を生成するため、それぞれの非同期処理が「その瞬間の値」を安全に保持できる。
ここをクリアできれば、非同期処理のタイミングやスコープの概念で頭を悩ませることはグッと減るはずです。JavaScriptのランタイムが裏側でどう動いているのかをイメージしながらコードを書く癖をつけていくと、エラーに遭遇したときも怖くなくなりますよ。
ここをマスターしたあなたなら、もうJavaScriptの基本はバッチリです!
ぜひ、日々のコーディングやデバッグにこの知見を活かしてみてくださいね。