こんにちは!フロントエンドからNode.jsの深層まで、日夜JavaScriptと向き合っているエンジニアのあなたへ。今日は、JavaScriptの「変数とスコープ」に関する、とてもエキサイティングで重要な核心の話をしますね。
「ループの中で `let` を使うと、なぜか変数がイテレーション(反復)ごとに独立して生き残る」――この現象、不思議に思ったことはありませんか?
「古い `var` の世界ならおかしな挙動になっていたのに、なぜ `let` だとうまくいくのか?」
ここをクリアすれば、JavaScriptのスコープチェーンやクロージャの挙動はバッチリマスターできますよ。V8エンジンの裏側の息づかいまで感じられるような、本質的な世界へ一緒に踏み込んでいきましょう!
—
1. タイムトラベルの終焉:`var` が抱えていた「ひとつの変数」の呪縛
まずは、歴史を少しだけ振り返ってみましょう。モダンなJavaScriptになる前、私たちの世界には `var` しかありませんでした。
次のコードを見てください。あなたなら、コンソールに何が表示されると予想しますか?
// 【歴史的遺物】varを使ったループの例
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(`[var] 現在の i の値: ${i}`);
}, 100);
}
直感的には、「0、1、2」と順番に出力されてほしいですよね。でも、実際に実行してみると……驚くなかれ、「3、3、3」と表示されてしまいます。
なぜこんなことが起きるのでしょうか?
それは、`var` のスコープが「関数スコープ(またはグローバルスコープ)」だからです。`for` ループの中で宣言された `i` は、ループの外側(あるいは同じ関数内)から見ても「たったひとつの同じ変数」を指し続けています。
V8エンジンのメモリ空間のイメージを覗いてみましょう。
[メモリ空間:var の場合]
変数 i ──> [ 値: 3 ] (※ループが終わった瞬間に最後の値「3」に固定される)
↑
setTimeoutのコールバックたちが
「みんなで同じ i を見つめている」状態
`setTimeout` のタイマーが実際に発火する頃には、すでに `for` ループは高速で回りきってしまい、変数 `i` の値は `3` に書き換わってしまっています。その結果、すべての非同期処理が「3」を参照してしまうという、JavaScript初学者が必ず一度はハマる罠が生まれていたのです。
—
2. 救世主 `let` の登場:反復ごとに生まれる「ブロックスコープのクローン」
ここで、今回の主役である `let` の登場です。
先ほどのコードの `var` を `let` に書き換えてみましょう。
// 【モダンJS】letを使ったループの例
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(`[let] 現在の i の値: ${i}`);
}, 100);
}
これを実行すると、期待通り 「0」「1」「2」 が順番に出力されます。素晴らしいですね!
「えっ、一体何が起きているの?」と思いますよね。実はこれこそが、ECMAScript仕様(ES2015)がJavaScriptにもたらした、最も美しい魔法の一つです。
`for (let i = 0; i …)` のように、初期化式で `let` を使用した場合、JavaScriptエンジンは「各反復(イテレーション)のブロック内部専用の新しい変数(環境レコード)」を毎回合法的かつ自動的に生成(クローン)します。
V8エンジンの内部、ヒープメモリ上では次のようなドラマが繰り広げられています。
[メモリ空間:let の場合]
反復 00回目: ──> [ ブロックスコープ A ] ──> 変数 i (値: 0) <── [クロージャ A が保持]
反復 01回目: ──> [ ブロックスコープ B ] ──> 変数 i (値: 1) <── [クロージャ B が保持]
反復 02回目: ──> [ ブロックスコープ C ] ──> 変数 i (値: 2) <── [クロージャ C が保持]
つまり、ループが回るたびに、前回の `i` とは完全に別個のメモリ領域に新しい `i` が生み出され、その瞬間の値がコピー(束縛)されるのです。
`setTimeout` の中に作られた無名関数(クロージャ)は、その生成された瞬間に存在していた「その回専用の `i`」をしっかりと掴んで離さないため、時間が経って実行されても「0、1、2」を保持し続けることができるというわけですね。
—
3. 陥りやすい罠:`const` でループを回そうとしたときの悲劇
ここで一つ、実務の現場で開発者がやりがちな「うっかりミス」についても触れておきましょう。
「ループ変数が書き換わらないなら、より安全な `const` でループを回せば最強なのでは?」と考えたことはありませんか?
// 【注意】constでループを回そうとすると…?
for (const i = 0; i < 3; i++) {
console.log(i);
}
これを実行すると、JavaScriptエンジン(V8など)は即座に次のようなエラー(SyntaxError)を吐き出して怒り出します。
> `TypeError: Assignment to constant variable.`
> (定数への代入はできません)
なぜエラーになるのでしょうか?
それは、`i++` という記述が文字通り「変数 `i` に新しい値を再代入(Assignment)している」からです。
`const` は「再代入を一切許さない」という強い制約を持つ変数宣言です。したがって、ループの条件式やインクリメント部分で `i` の値を更新しようとする `for (const i = …; …; i++)` は、文法的に矛盾してしまうためエラーになります。
じゃあ、オブジェクトや配列のプロパティなら `const` でいける?
ちなみに、ループの中でそのイテレーション固有の「定数」として扱いたい値がある場合は、次のようにブロック内部で `const` を宣言するのがモダンなアプローチです。
const items = [‘apple’, ‘banana’, ‘orange’];
// ループ自体のカウンターは let、ループ内で使う不変の値は const にする
for (let i = 0; i < items.length; i++) {
const currentItem = items[i]; // このブロック内だけで生きる定数
setTimeout(() => {
console.log(`インデックス ${i}: ${currentItem}`);
}, 100);
}
この書き方であれば、`i` はループエンジンによって安全に管理され、`currentItem` はそのブロックの中だけの安全なリードオンリーの定数としてクロージャにキャプチャされます。非常に美しく、堅牢なコードですね。
—
まとめ:言語の背後にある「メモリとスコープの仕組み」を愛そう
いかがでしたでしょうか?
ただ「`var` じゃなくて `let` を使えば動く」と表面的に覚えるだけよりも、
- `var` は関数全体でひとつの変数を共有してしまう
- `let` は `for` ループの各反復ごとに独立した変数のクローン(スコープ)を生成する
- この仕組みのおかげで、非同期処理やクロージャが「期待通りの値」を安全にキャプチャできる
というランタイムの挙動までイメージできるようになると、コードを書くときの視座が劇的に変わります。
JavaScriptという言語は、一見するとお気楽に動くように見えて、その下層では非常に洗練されたスコープチェーンのメカニズムが働いています。ここをクリアしたあなたなら、もう変数のスコープやクロージャで迷うことは怖くないはずです。
明日からのコーディングで、ぜひこの「ブロックスコープのクローン」の躍動を脳内でトレースしてみてくださいね。ハッピー・コーディング!