同じコードを書いてもらって解説(クロージャ問題) Re: Web/JS part.6

Web/JS 講習会参加者に同じ課題を出してコードを書いてもらう@第二弾です。
そろそろ個人差が生まれ、またこちらを悩ませる面白い間違いも出るようになりました。

Element.onclick を onClick と書いてしまう、イベントハンドラに関数でなく戻り値を渡してしまう(実行してしまう)、といった細々した間違いはありますが大体の人は「読み書きの量をこなせば大丈夫かな〜」というレベルで付いてきてくれているようです。

其の中で面白い間違いがあったので取り上げてみます。
まず、次のようなHTMLがあります。

    <ul>
        <li>John Doe</li>
        <li>Foo Bar</li>
    </ul>

li をクリックすると Firebug コンソールにテキストノードを表示するスクリプトを組んでみます。
書き慣れた人なら this を使うか

    function fn3() {
    	var items = document.getElementsByTagName('li');
    	for (var i = 0; i < items.length; i++) {
    		var item = items[i];
    		item.onclick = function() {
    			console.log(this.firstChild.nodeValue);
    		};
    	}
    }
    window.onlod = fn3;

クロージャを使ってこんな感じでしょうか。

    function fn4() {
    	var items = document.getElementsByTagName('li');
    	for (var i = 0; i < items.length; i++) {
    		var item = items[i];
    		item.onclick = function(text) {
    			return function() {
    				console.log(text);
    			};
    		}(item.firstChild.nodeValue);
    	}
    }
    window.onload = fn4;

ここで問題のコードです。

    function fn1() {
    	var items = document.getElementsByTagName('li');
    	for (var i = 0; i < items.length; i++) {
    		var item = items[i];
    		var fn = getFn(item.firstChild.nodeValue);
    		item.onclick = function() {
    			fn();
    		};
    	}
    }
    function getFn(text) {
    	return function() {
    		console.log(text);
    	};
    }

これを実行すると John Doue をクリックしてもコンソールに出力される値は Foo Bar になってしまいます。
この手の現象は、経験的にクロージャが上手く作れなくて発生している事が多いようです。

仕様を振り返ると、関数の評価に入るとスコープ(環境、execution context)が生成され、通常は評価を終えるとスコープは消滅しますが関数からの参照が残る場合にこのスコープはガーベージコレクトを逃れ残る事になります。これが ECMAScript でのクロージャです。
この原則が正しいなら、どこかで参照関係の形成に抜け落ちがあるという事になります。
さて、このコードをどう直しますか?
修正例は以下に示します。ちょっと考えてみてください。

    function fn2() {
    	var items = document.getElementsByTagName('li');
    	for (var i = 0; i < items.length; i++) {
    		var item = items[i];
    		var fn = getFn2(item.firstChild.nodeValue);
    		item.onclick = function(fn) {
    			return function(evt) {
    				fn();
    			};
    		}(fn);
    	}
    }
    function getFn2(text) {
    	return function() {
    		console.log(text);
    	};
    }

問題のような書き方をした場合、参照関係は2つ必要になります。
li に含まれる表示対象となる文字列、これを var fn で示される関数のスコープにアサインします。
次に、var fn もループの都度スコープが生成される(参照が無ければ消滅する)ため、ここもクロージャが必要で var fn を onclick にアサインされる無名関数のスコープにアサインする必要があります。
という事で上記のような修正例となりました。

ECMASCriptの挙動解釈や説明、コード等に間違いやツッコミがある場合はご指摘頂ければ嬉しく思います。
私もまだまだ勉強中です。

this とクロージャについては説明不足と実例不足を感じているので次回からの講習会でも続けて行きたいと思います。参加者の皆さんはお楽しみに。

コメント / トラックバックはありません

コメントする