ループ内でコールバック関数に変数を渡すとき、変数のスコープ次第でんぁぁぁぁってなる


突然ですが

以下のスクリプトの実行結果は

  (function(){
    let i = 0;
    for (i = 0; i < 3; i++) {
      console.log(i);
      setTimeout(function(){ console.log(`hello ${i}`)}, 1000);
    }  
  })();

このようになります。

0
1
2
hello 3
hello 3
hello 3

Oh……全部3になってやがるぜ……。

変数iのスコープがforのブロック外にあるので、setTimeoutに渡しているコールバック関数は最新のiの値を参照します。
また、letで宣言せずにfor (var i = 0; i < 3; i++)と記述しても同じ結果になります。varは関数以外ではブロックスコープを作りません。

この手のやつ、むかーし(letがないころ)すごいはまりました。イベントリスナーをループ内で設定しようとして「んぁぁぁぁぁぁぁ」ってなってました。ボタン複数作って、全部最後のボタンと同じ挙動になる術式。

変数のスコープを適切にする

シンプルにこのように記述すれば望み通りの結果になります。

(function(){
  for (let i = 0; i < 3; i++) {
    console.log(i);
    setTimeout(function(){ console.log(`hello ${i}`)}, 1000);
  }  
})();

0
1
2
hello 0
hello 1
hello 2

JavaScriptは歴史的な経緯もあり、変数のスコープが少し特殊ですね。

varしか使えないとき

ES6より前であれば以下のようにクロージャを使うといいかもしれません。

(function(){
  for (var i = 0; i < 3; i++) {
    console.log(i);
    var a = function(n) {
      return function() {
        console.log('hello ' + n); // テンプレートリテラルも使えなかった
      }
    };
    setTimeout(a(i), 1000);
  }  
})();

varしか使えないとき②

@esk312 さんにご指摘いただきました。
setTimeoutのコールバックで外部の値を渡すのであれば、第3引数に指定できるそうです!ありがとうございました。

(function(){
  for (var i = 0; i < 3; i++) {
    console.log(i);
    setTimeout(console.log, 1000, 'hello' + i);
  }  
})();

補遺

自分が管理していない日報ツールのブックマークレット作っていたら、敵()のスクリプト内でグローバルスコープの変数をコールバック数に渡していましてね・・・・・・。