1. 今回記事の内容
知っている人は知っている話ですが、JavaScript の for文の中でクロージャを使う際、ちょっとした書き方の違いで意外な動きの違いを生むという話です。
2. コード例
以下の2つのコードを見て下さい。
console.log("1つ目:iがfor()の中にある場合");
const a = [];
for (let i = 0; i < 3; i++) {
a[i] = () => {
console.log(i);
};
}
a.forEach((e) => e());
let i = 4;
console.log('逆順にも回してみる');
a.reverse().forEach((e) => e());
console.log("2つ目:iがfor()の外にある場合");
const a = [];
let i = 0; // ここが違う
for (; i < 3; i++) {
a[i] = () => {
console.log(i);
};
}
a.forEach((e) => e());
i = 4;
console.log('逆順にも回してみる');
a.reverse().forEach((e) => e());
ほんのちょっとした違いがあるだけのこの2つのソースの実行結果がどうなるか、それぞれ想像してみると面白いと思います。「え、同じじゃないの?」と思った人(お察しの通り、私のことなのですが)もいるのではないかと思います。
2-1. 実行結果
では、実際に動かしてみます。
1つ目:iがfor()の中にある場合
0
1
2
逆順にも回してみる
2
1
0
2つ目:iがfor()の外にある場合
3
3
3
逆順にも回してみる
4
4
4
実行結果が予想通りであった方は、もうこの後の記事を読む必要はないと思います。
以下、不思議に思った方向けの説明となります。
3. なぜそうなるのかについての考察
2つのソースの違いは、for文のカウント用変数 i を定義する位置が違うだけです。
for (let i = 0; i < 3; i++) { // for() の中でiを定義
a[i] = () => {
console.log(i);
};
}
let i = 0; // for() の外でiを定義
for (; i < 3; i++) {
a[i] = () => {
console.log(i);
};
}
※2023.3.24 付記 コメントでクロージャについての認識誤りをご指摘いただき説明を修正しました。PECMM様ご教示ありがとうございます。
この2つの違いは、for文の中でa[]という配列に定義しているアロー関数 ()=>{console.log(i)
から見て、変数 i の扱いが違うということだと思われます。ソース1では let i は for() の中にあり、そのことによってループするたびに別コンテキストの i がアロー関数にバインドされたクロージャを形成しています。ソース2では i はfor文の外に定義され関数とそれに付随して作られたクロージャから同じ変数が参照されているだけです。
ソース1でもソース2でもそれぞれアロー関数がfor文内で3回定義され、a[0], a[1], a[2] へセットされています。そこまでは同じですが、ソース1ではその時点での変数 i(のコピー) が各アロー関数と一緒に各アロー関数内のローカルな変数 i としてバインドされ a[0]~a[2] へ保持されます。a[0]~a[2] に設定された関数オブジェクトの中にはそれぞれ別のコンテキストの i(のコピー)が存在することになります。ソース2では変数 i はアロー関数(とそのクロージャ)の外にあるので a[0]~a[2] に設定される3つのアロー関数から1つの同じ変数 i が参照されています。その結果、アロー関数が呼ばれた時点のiの値(この場合は3と4) が3回ずつ表示されるという実行結果になっています。ソース1の実行では、各クロージャにバインドされた時点のi(のコピー)の値が表示されています。
かなり不思議な動きですが、コメントでご紹介いただいた下記の記事に説明があります。
こちらの記事を見ると、i の定義位置を変えなくてもソース1の let を var に変えるだけで違いが出そうですね…やってみましょう。
console.log("3つ目:iがfor()の中にあるがvarで定義されている場合");
const a = [];
for (var i = 0; i < 3; i++) { // let を var にした
a[i] = () => {
console.log(i);
};
}
a.forEach((e) => e());
i = 4;
console.log('逆順にも回してみる');
a.reverse().forEach((e) => e());
3つ目:iがfor()の中にあるがvarで定義されている場合
3
3
3
逆順にも回してみる
4
4
4
予想通りでした。
【2024.4 追記】var と const, let のスコープ扱いの違いに関しては、下記の記事でも似たような現象を扱っています。
4. まとめ
JavaScriptのfor文では、以下のようなちょっとした違いで、クロージャにバインドされる変数の扱いが変わってしまうという点に注意する必要があります。
- for() の中で i を letで定義 → iはループごとに別コンテキストでバインドされる
- for() の外で i を letで定義 → iは各関数から同じものが参照される
- for() の中で i を varで定義 → iは各関数から同じものが参照される
その前にまずクロージャを理解しておく必要がありますが、これについては検索すると色々出て来ますので、そちらをご参照いただければと思います。
例えばこちらの記事など。
ということで、JavaScript の文法はなかなかトリッキーな一面があるので注意が必要です。
今回は以上です。
ここまで読んでいただき、ありがとうございました。