こんにちは!日々の開発、本当にお疲れ様です。
JavaScriptを書き始めて少し経つと、「あれ、なんだかループと非同期処理の組み合わせで変数の値がおかしくなるぞ……?」という壁にぶつかったことはありませんか?例えば、画面のボタンクリックやタイマー処理(`setTimeout`)をループの中で仕込んだつもりが、なぜか「すべての処理で最後の変数の値になってしまう」という現象です。
ここをクリアすれば、JavaScriptの変数スコープや、V8エンジンが裏側でどうやってメモリを管理しているのかという「本質」が一気に見えてきます。今日は、伝説の(?)`var`とモダンな`let`の挙動の違いを紐解きながら、JavaScriptのコアを一緒にバッチリマスターしていきましょう!
—
1. 誰もが一度はハマる「お化けタイマー」の怪奇現象
まずは、こちらのコードを見てください。画面に「0秒後、1秒後、2秒後……」とメッセージをコンソールに出力させたい、よくあるシチュエーションです。
// varを使ったループと非同期処理の例
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(`現在のカウント: ${i}`);
}, 1000 i);
}
直感的な期待値としては、1秒ごとに `0`、`1`、`2` と順番に出力されてほしいですよね。しかし、実際にこれを実行してみると……。
// 実行結果(1秒後、2秒後、3秒後ではなく、なぜか3秒一斉に経ったあとにこう出力される)
現在のカウント: 3
現在のカウント: 3
現在のカウント: 3
「えっ、なんで全部 `3` なの!?」
初めてこれを見たときは、誰もが頭にクエスチョンマークを浮かべます。エラーが出るわけでもなく、静かに、しかし冷酷にすべてが `3` になってしまう。
この現象の裏側では、JavaScriptの「変数の宣言場所(スコープ)」と「非同期処理が実行されるタイミング」のタイムラグが起きています。ここを解剖していきましょう。
—
2. なぜ `var` だと全部「3」になってしまうのか?
これを理解するには、JavaScriptのエンジンがコードをどう解釈しているか、その「時間軸」を追う必要があります。
ポイント①:`var` は「関数スコープ」であり、ループの外にも漏れ出す
現代のモダンJSではあまり使われない `var` ですが、こいつの最大の特徴は「ブロック(`{}`)の壁を無視する」ということです。`for` ループの `()` の中で宣言された `var i` は、ループの外側(あるいはそれを囲む関数全体)から丸見えの状態になっています。
ポイント②:ループは一瞬で終わり、タイマーは「未来」に予約される
コンピュータの処理速度は人間とは比べ物にならないほど高速です。上記の `for` ループは、コンマ数ミリ秒の間に `i = 0`、`i = 1`、`i = 2`、そして条件を満たさなくなる `i = 3` まで一気に駆け抜けます。
このとき、頭の中で次のようなタイムラインをイメージしてみてください。
[ 0.000ミリ秒 ] ループ開始
i = 0 でタイマー予約 (1秒後に実行してね)
i = 1 でタイマー予約 (2秒後に実行してね)
i = 2 でタイマー予約 (3秒後に実行してね)
i = 3 になり、ループ終了! (※この時、変数 i の値は「3」に固定される)
[ 0.005ミリ秒 ] ループ処理自体はすでに完了している!
—————— ここから未来(数秒後) ——————
[ 1.000秒後 ] 1つ目のタイマー発火 → console.log(i) を実行しようとするが、
メモリ上の変数 i はすでに「3」になっている!
[ 2.000秒後 ] 2つ目のタイマー発火 → やはり i は「3」!
[ 3.000秒後 ] 3つ目のタイマー発火 → やっぱり i は「3」!
つまり、`setTimeout` の中身(無名関数)が実際に実行される頃には、ループは遥か昔に終わっており、共有の金庫番である変数 `i` の中身はすっかり `3` に書き換わっていた、というわけなんです。これが `var` の生み出す悲劇の正体です。
—
3. 救世主 `let` の登場:ループの数だけ「世界」がコピーされる
では、これをモダンな `let` に書き換えてみましょう。コードはたった1文字、`var` を `let` に変えるだけです。
// letを使ったループと非同期処理の例
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(`現在のカウント: ${i}`);
}, 1000 i);
}
これを実行すると、見事に期待通りの結果が得られます。
// 実行結果(1秒ごとに綺麗に出力される)
1秒後 -> 現在のカウント: 0
2秒後 -> 現在のカウント: 1
3秒後 -> 現在のカウント: 2
一体、裏側で何が起きているのでしょうか?
ブロック単位のスコープと「環境のクローン」
`let`(および `const`)は、ブロック(`{}`)スコープを持ちます。さらに、`for` ループの頭で `let i` が宣言された場合、JavaScriptのランタイムは特別な挙動をします。
なんと、ループが1回回るごとに、そのブロック専用の変数 `i` の「インスタンス(別のメモリ空間)」を新しく作り直してくれるのです。
イメージ図で表現してみましょう。
【ループ1周目 (i = 0 の世界)】
- このブロック専用の [ i = 0 ] が誕生する
- この世界に紐づいた setTimeout が予約される
【ループ2周目 (i = 1 の世界)】
- 先ほどとは別の、新しい [ i = 1 ] が誕生する
- この世界に紐づいた setTimeout が予約される
【ループ3周目 (i = 2 の世界)】
- さらに別の、新しい [ i = 2 ] が誕生する
- この世界に紐づいた setTimeout が予約される
このように、`let` を使うことで、未来に実行される非同期処理それぞれが、「自分が生まれた瞬間の `i` の値(クロージャ)」を安全に自分の中に閉じ込めておく(キャプチャする)ことができるようになります。
他の言語(C#やJava、Pythonなど)で当たり前に動くループ処理の感覚に近いため、初心者の方にとっても `let` を使えば直感的なメンタルモデルをそのままコードに落とし込めますよね。
—
4. もし古いコード(`var`)と戦わなくてはならない場合の回避策
実務の中では、歴史的経緯から未だに `var` が使われているレガシーなコードベースに出会うこともあります。「あ、ここ `var` だから動かないじゃん!」となったとき、どうすればいいでしょうか?
もし `let` に書き換えられない事情がある場合(あるいは技術的な面接などで「`var` のままでこれを解決して」と言われた場合)、即時実行関数(IIFE: Immediately Invoked Function Expression)を使って、強制的に新しいスコープを作り出すというテクニックがあります。
// varのままでスコープを閉じ込めるテクニック
for (var i = 0; i < 3; i++) {
// 即時関数に現在の i の値を「値渡し」して、スコープ内に閉じ込める
(function(currentIndex) {
setTimeout(function() {
console.log(`現在のカウント: ${currentIndex}`);
}, 1000 currentIndex);
})(i); // その都度の i を引数として即時実行する
}
関数を挟むことで、その瞬間の `i` の値が引数 `currentIndex` という別の変数として関数の中にコピーされ、タイマーはその安全なコピーを参照できるようになります。
……とはいえ、現代のJavaScript開発において、わざわざこのような書き方をする必要はほとんどありません。特別な理由がない限りは、変数の宣言には常に `let`、再代入しないものには `const` を使うというモダンなルールを徹底すれば、この手のスコープ迷子からは完全に卒業できます。
—
まとめ:今日のポイント
- `var` は関数スコープ:ループを抜けたあとも変数が残り、非同期処理が実行される頃には最終値に書き換わっているためバグの温床になりやすい。
- `let` はブロックスコープ:ループの反復ごとに新しい変数の「世界(メモリ空間)」が生成されるため、非同期処理もそれぞれの正しい値を保持できる。
- ループ内の非同期処理(`setTimeout` やイベントリスナーなど)で変数が意図せず書き換わっているように見えたら、まずは変数宣言が `var` になっていないかを疑う。
ここをしっかり理解しておくと、非同期処理の実行タイミング(イベントループ)と変数スコープの関係がクリアになり、バグを踏んだときも「あ、いまメモリの空間とタイミングでこういうことが起きているな」と脳内で美しくトレースできるようになります。
基本をマスターすれば、JavaScriptはもっと楽しく、もっと自由になりますよ。一緒にフロントエンドの深淵を極めていきましょう!