スポンサーリンク

JavaScriptの動かないコード(中級編) forループ内でイベントリスナを定義したら,動作がおかしい。(クロージャや関数オブジェクトの性質を理解していないために発生するエラー)


以下のJavaScriptコードが,意図した動作をしないのは,なぜですか。(制限時間1分)


やりたい事:
・ボタンごとに,0, 1, 2 という異なる数を表示したい。


<h3>ボタンごとに異なる数字をアラート表示するサンプル</h3>

<input type="button" id="id0" value="0">
<input type="button" id="id1" value="1">
<input type="button" id="id2" value="2">


<script>

for (var i = 0; i < 3; i ++ ){

  // クリック時のイベントを定義
  document.getElementById( 'id' + i ).onclick = function(){

    // それぞれ異なった数値を表示する。
    alert( i );

  };

}

</script>

解答は下記の通り。


発生する不具合

どのボタンをクリックしても「3」と表示されてしまう。

ボタンごとに異なる数字をアラートできていない。


クリック時のイベントリスナを,for文でまとめて効率的に定義したつもりなのに

うまく定義できていない。


なお,冒頭のコードはピュアJavaScriptで記述したものだが,

jQueryで下記のようなコードを書いた場合にも,同じエラーが発生する。

for ( var i = 0; i < 3; i ++ ){

  $('#id' + i).click( function(){
    alert(i);
  });

}

不具合が起きる原因

このエラーは,非常に有名だ。

誰もが一度,かならず経験する。


うまく動作しない理由は,JavaScriptにおける

  • 「クロージャ」と
  • 「関数オブジェクト」

の性質を理解していないためだ。


あまりにも有名な間違いなので,Mozillaのページにも,

同じサンプルコードがそのまま載っている。


クロージャ - JavaScript | MDN
https://developer.mozilla.org/ja/docs/Web/JavaScript/Closures#Creating_closures_in_loops_A_common_mistake

よくある間違い: ループ内でクロージャを作成する。

・・・このコードを実行してみると、期待したとおりには動かないのが判ります。
どのフィールドにフォーカスしても、表示されるのは年齢についてのメッセージです。

こうなる理由は、onfocus に代入された関数がクロージャだからです。


そこに掲載されているサンプルコードを抜粋すると,

やはり,for文の中でイベントリスナを定義している。

これがバグのもとになる。

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];

    // 複数の要素について,それぞれ異なるイベントを発生させるために
    // for文の内部でイベントをセットしようとしている。
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }

  }


さて,JavaScriptにおける「クロージャ」の性質を

理解していないために発生するエラーなわけだが,つまりどういう事だろうか?


これは,「functionは,自分が定義された環境を記憶している」ということなのだ。


クロージャとはつまりfunctionのことであり,

functionは,自分がfor文の内部で定義されたことを知っている。(自覚している)


そして,「 i という変数は, 関数の外部で,for文で使っていた変数だったよな・・・」というのを覚えている。

関数は,自分が定義された環境,外部環境を記憶しているのだ。


だから,functionの外部でforループが回っていき,さいごに i = 3 でループが停止されると・・・

functionは,その i = 3 という値を使ってしまうのだ。


functionの内部で保持している変数ではなく,

functionの「外部環境」で定義された変数を使っていることが問題のきっかけ,というわけ。


このことは,JavaScriptで「function」というキーワードを使うたびに思い出したほうがよい。

解決策

ではどう改善したらよいのか?


解決策として,「もうひとつfunctionを増やす」という手が一般的だ。


functionは外部を記憶する働きを持つので,

ひとつ余分にfunctionで囲んでやればよい。


そうすれば,あいだに挟まれた余分なfunctionが

個別の変数の値を記憶してくれる役目を果たす,ということ。


具体的に言うと,下記のようなコードにすればよい。

  document.getElementById('id'+i).onclick = function(){
    alert(i);
  };

 ↓書き換え

  document.getElementById('id'+i).onclick = (function(num){

    // functionの外側にもう一つfunctionがあるので
    // 個別の数値を記憶してくれる。
    return function(){
      alert(num);
    }

  })( i );


jQueryの場合も同じことだが,下記のように書けばよい。

<html>
<head>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
</head>
<body>


<h3>ボタンごとに異なる数字をアラート表示するサンプル (jQuery版)</h3>

<input type="button" id="id0" value="0">
<input type="button" id="id1" value="1">
<input type="button" id="id2" value="2">


<script>


// イベントリスナを生みだす。
function get_func( num ){

  // 「特定の数値をアラート」という,関数オブジェクトを返す。
  return function(){
    alert(num);
  };

}

for ( var i = 0; i < 3; i ++ ){

  // 下記のように書けば,ボタンごとに正しい数字が表示される。
  var f = get_func( i );
  $('#id'+i).click( f );

}

</script>


</body>
</html>

もし,上記のコードの意味がわからない場合は,

クロージャ以前に「関数オブジェクト」を復習するとよい。


※上記のget_funcは,関数オブジェクト(つまりfunction)を返却している。

JavaScriptでのクロージャの使い方
http://blogs.wankuma.com/kacchan6/archive/2008/01/28/119508.aspx

ループの中のクロージャは, ループカウンタの変数 i に対する参照を持ちます。
が、最後に評価された結果の4が, 全てのクロージャから参照されてしまうのです。

解決策として,もう一つ関数で囲む

匿名関数にiを都度渡していますが、これによってiを参照しなくなります。
関数に渡された仮引数を都度参照するので、この場合正しく動きます。


今回紹介したクロージャの性質は,下記のエントリでも昔,同じことを述べている。

なので,詳しくはそちらを参照のこと。

JavaScriptの動かないコード (中級編) クロージャを使わない場合に起きるエラー
http://language-and-engineering.hatenablog.jp/entry/20080917/1221652591

  • 理由は,クロージャの性質にある。
  • クロージャとは,「『関数が定義された環境』への参照を持っている関数」のこと。
    • 一般には,「関数内で定義された内部関数」のことを指す場合もある