これでできる! クロスブラウザJavaScript入門

第5回JavaScriptの基礎知識#2:クロージャ編

こんにちは、太田です。前回はJavaScriptの基礎的な部分を解説しました。今回はJavaScriptのクロージャについて解説します。クロージャはJavaScriptでは使用頻度が高く(意識して使用していなくとも、ほとんどの場合クロージャが使われています⁠⁠、今後の連載の中でも積極的に使っていきますのでここで確実に理解してしまいましょう。

クロージャとは

クロージャはその定義を説明されてもなかなか理解できないため、難しいものだと思われがちです。しかし、ソースコードを中心に見方を少し工夫すればすんなりと理解できると思います。

さて、クロージャの前に確認しておくべき基本事項があります。それは、JavaScriptでは関数を入れ子にできる、という点です。ある関数の中に別の関数を定義することができます。基本中の基本ですが、これがクロージャにおいてもっとも重要です。

では、それを踏まえて次のコードを見てください(変数名・関数名に日本語を使っていますが、文字コードにさえ気をつければこのままでも動作します⁠⁠。

関数の中の関数
function 関数A(){ var 変数a = 'a';
function 関数B(){ var 変数b = 変数a + 'b';
var 関数C = function(){ var 変数c = 変数a + 変数b + 'c'; alert(変数c); // aabc }
関数C(); }
関数B(); }
関数A();

関数Aの中に関数Bを、さらにその中に関数Cを、と関数の定義を入れ子にしてみました。ここで、関数Aのローカル変数である変数aを関数Bと関数Cの内部でも使用しています。

関数Bは関数Aの中に定義された関数(内部関数)なので、同じく関数Aの中で定義された変数aを関数Bの中からも参照することができます。ここまでは「見た目の通り」なので何も複雑なことはないと思います。

さてさて、実はこれが既にクロージャの実例となっています。このように関数の中に定義した内部関数が外側の関数(エンクロージャ)のローカル変数を参照できる仕組みをクロージャと呼んでいます(正確にはもっと小難しい説明になるのですが、JavaScriptではそれだけのことです⁠⁠。なお内部関数Bから見た変数aはローカル変数でもグローバル変数でもないので、これをレキシカル変数と呼びます。

このように、JavaScriptでは関数を定義した段階でその関数から見える変数が決まっています。それゆえに、関数の中に関数があるとき、内部の関数は外側にある変数を参照できるという見た目の通りの動作をします。

クロージャの生成と変数
function 関数A(){ var 変数a = 0;
function 関数B(){ 変数a++; }
関数B(); 関数B(); alert(変数a); // 2 }
関数A(); 関数A();

こちらは関数Aを2度呼び出しています。一度目の呼び出しでは2がalertされます。また、2度目の呼び出しでもやはり2がalertされます。関数Bは何度呼び出しても同じ変数aをインクリメントするので、変数aはどんどん値が増えていきますが、一方で関数Aが呼び出されると変数aは初期化されています。当たり前だと思われるかもしれませんが、このことを踏まえて次のソースコードを見てください。

クロージャと高階関数
function 関数A(){ var 変数a = 0;
function 関数B(){ return ++変数a; }
return 関数B; }
var 変数β = 関数A(); alert(変数β()); // 1 alert(変数β()); // 2 var 変数γ = 関数A(); alert(変数γ()); // 1 alert(変数β()); // 3

こちらはクロージャと前回取り上げた高階関数との組み合わせです。関数Aは関数Bを返すようにしたので、それを変数βに入れました。変数βはもちろん関数Bなので、変数βを呼び出すと関数Bの定義通り変数aをインクリメントしてからその値を返します。変数β自体は関数Aと関係がありませんが、中身の関数Bは関数Aの内部関数なので、どこからどのように呼び出したとしても関数Bが変数aを参照できることは変わりません。繰り返しますが「関数を定義した段階で」参照できる変数が決まるからです。そのため、変数βを関数として呼び出すたびに変数aがインクリメントされていきます。

一方で、関数Aを再び呼びだして新しい変数γに代入すると、やはり変数γも変数aを0から順番にインクリメントした結果を返します。この結果のとおり、変数βが内部に持つ変数aと変数γが持つ変数aは別物であることがわかると思います。

このあたりは少々ややこしくなってきましたね。とはいえ、関数Aが呼び出されたときに変数aは初期化されるので、関数Aが10回呼び出されたときは変数aも10回初期化されてそれぞれが独立しているのは当然なので、関数Bから参照できる変数aもそれぞれが独立しているのは当然のことです。

ここまででクロージャの解説はほぼ終わりです。ここからは実用的なケースを見ていきます。

テーブルのハイライト

ここで、マウスにあわせてテーブルの列をハイライトする機能を実装してみます。

といっても、これは一部の例外をのぞいてCSSだけで実現することができます。

:hoverで背景色を変更
tr:hover{
  background-color:#ffffaa;
}
1 2 3
4 5 6
7 8 9

この通り、CSSの:hover指定だけでほとんどのブラウザで意図通りに動きます。しかし、残念ながらIE6(とIE7、IE8の互換モードなど)ではa要素以外の要素について:hover指定が効かないというバグあるため、IE6用の対応が必要となります(といっても、肝心のコンテンツは表示できているわけですから、この機能がIE6に対応していなくとも利用者はさほど不便には感じないでしょう。従って、あえてIE6対応はしないという選択も考えられます⁠⁠。

では、ひとまず:hoverの代わりにhoverというクラス名を付加するようにしてみます。

マウスオーバーで背景色を変える実装(失敗例)
window.onload = function(){
  var table=document.getElementById('js5-table');
  var trs = table.getElementsByTagName('tr');
  for (var i = 0, len=trs.length;i < len; i++){
    var tr = trs[i];
    tr.onmouseover = function(){
      tr.className = 'hover';
    };
    tr.onmouseout = function(){
      tr.className = '';
    };
  }
};

一見上手く動きそうに見えるコードですが、サンプル1のとおり、どの列にマウスを載せても一番下の列がハイライトされてしまいます。ここで、例によって関数ごとに色分けをしてみます。

マウスオーバーで背景色を変える実装(失敗例:関数ごとに色分け)
window.onload = function(){ var table=document.getElementById('js5-table'); var trs = table.getElementsByTagName('tr'); for (var i = 0, len=trs.length;i < len; i++){ var tr = trs[i];
tr.onmouseover = function(){ tr.className = 'hover'; };
tr.onmouseout = function(){ tr.className = ''; };
} };

この通り、onmouseover、onmouseoutはそれぞれ内部関数となっているので、変数trをそれぞれ参照できています。その点は問題ありません。ポイントは、この変数trがonload内のローカル変数であるということです。変数trの宣言がforループのなかにあるので、変数がループの数だけ存在するかのように間違いやすいですが、あくまでtrはonload関数のローカル変数でしかありません。つまり、onload内に変数trはただ1つしか存在しません。必然的にonmouseover、onmouseoutから参照する変数trもその1つだけです。このtrはforループを抜ける際に最後の列(tr)を参照した状態となっているので、onmouseoverでtrのclassを編集するとそれが反映されるので最後の列になります。

ではこの問題を解決するにはforループの中、onmouseoverの外で個別の変数trを作ってあげればよいと考えられます。それを実現してみたのが次のコードです。

マウスオーバーで背景色を変える実装(成功例#1)
window.onload=function(){ var table=document.getElementById('js5-table'); var trs = table.getElementsByTagName('tr'); for (var i = 0, len=trs.length;i < len; i++){ var tr = trs[i];
(function(_tr){
_tr.onmouseover = function(){ _tr.className = 'hover'; };
_tr.onmouseout = function(){ _tr.className = ''; };
})(tr);
} }

このようにforループ内に関数を作り、ローカル変数 _tr を用意しました。内部関数であるmouseover/mouseoutの中ではこの_trを参照することができます。このように、クロージャは変数のスコープを切りたい場合などによく利用されます。

クロージャを避ける

さて、折角クロージャを解説しましたが、クロージャは便利な半面メモリを消費する原因(場合によってはメモリリークも)となりますし、パフォーマンスも最善ではありません。パフォーマンスが重要なケースではクロージャに頼らないようにするべきです(といっても、JavaScriptでパフォーマンスが重要になるケースというのはそう多くはないでしょう⁠⁠。

クロージャを避ける方法は単純で、関数をネストしなければよいだけです。例えば今回のケースでは次のようにイベント内のthisがそのイベントの発生しているノードになるという性質を利用して次のように書くこともできます。

マウスオーバーで背景色を変える実装(成功例#2)

for (var i = 0, len=trs.length;i < len; i++){ var tr = trs[i]; tr.onmouseover = function(){ this.className = 'hover'; }; tr.onmouseout = function(){ this.className = ''; }; }

もしくは、イベントをセットする関数を定義しておき、ループ内でその関数を呼び出すことでクロージャを作らずにスコープを切ることができます。

マウスオーバーで背景色を変える実装(成功例#3)

for (var i = 0, len=trs.length;i < len; i++){ mouse_hightlight(trs[i]); } }
function mouse_hightlight(tr){
tr.onmouseover = function(){ tr.className = 'hover'; };
tr.onmouseout = function(){ tr.className = ''; };
}

ただし、この方法はソースの可読性、拡張性でやや劣る面があります。

このように同じ機能を実現する際に色々と工夫ができるところはプログラミングの面白さでもあります。是非、試行錯誤をして工夫してみてください。

最後に、おまけとして1万行のテーブルをハイライトするサンプルを紹介します。1万行はあまり現実的な数字ではないかもしれませんが、そういった大きなテーブルを扱いたい場合でも、工夫次第では十分に実現可能です。

最適化前は初期化が重く、ハイライトもマウスの動きからかなり遅れてしまうなど、実用的ではありません。一方、最適化後は読み込みと表示にやや時間がかかるものの、初期化とハイライトはスムーズです。

最適化の内容は、行ごとの監視からテーブル全体の監視に切り替えて初期化コストをほぼ0に抑え、ハイライト時にclass名の操作ではなく背景色を直接操作にすることで影響範囲を最小化してパフォーマンスを上げる方法を使用しました。詳しい解説は連載が進んでから改めていたします。

まとめ

今回はJavaScriptにおいて重要なクロージャを中心とした関数の扱いを解説しました。次回からはクロスブラウザの最大の難所であるDOMについて紹介しつつ、サンプルコードを解説していきたいと思います。

おすすめ記事

記事・ニュース一覧