実践JavaScript Event編

「プログラミング経験はそこそこだけど、JavaScriptはあんまり」な人向け社内勉強会資料#1 Event編

Shogo Ohta, 2009-03-24

基礎編

JavaScriptを実行する

<script type="text/javascript">alert(1);</script>
<script type="text/javascript" src="alert.js"></script>

<input type="button" value="alert" onclick="alert(1);">
<a href="javascript:alert(1);">alert</a>

scriptタグでJavaScriptを記述し、なるべくファイルに分ける(src属性でファイルの場所を指定する)のが一般的です

ファイルに分ければ使い回しができる、キャッシュされる、管理もしやすくなるとメリットが多いので、特に理由がなければファイルに分けます

onclick="~"や、href="javascript:~"はなるべく避けます。特にhref="javascript:"は避けたほうが良いです。<input type="button" onclick="">などを使うことが望ましいです。リンクだと思ってalertをマウスでセンタークリックするとおかしなタブができてしまいます。

scriptとonclick

ブラウザはHTMLを順番に読み込んで表示していきます。その際scriptタグがあればそれを解釈し、JavaScriptを実行します。

scriptタグに記述されたJavaScriptはページの読み込み途中に実行されるので、読み込みが済んでいないHTML(自分より後ろ)にアクセスできないという特徴があります。そのため、必要に応じて読み込みの完了(onload、DOMContentLoaded)を待って実行する。もしくはscriptタグをbodyの閉じタグの前後に配置するといった記述をする必要があります。

onclickはその名前の通り、クリックしたときに実行される処理を記述できます。クリックできる状態であれば、読み込みは(ほぼ)完了している(ページ全体の読み込みの完了が保証されているわけではないが、少なくともその要素については表示まで完了している)ことになります。読み込み状態を気にしなくてもよいので、理解しやすく敷居が低いので昔から良く使われています。ただし、ひとつの要素に対して、ひとつの属性しか指定できないという問題があります。

JavaScriptとEvent

イベントはJavaScriptにおいて非常に重要な位置を占めます。

例えば、

などなど、とにかくJavaScriptの制御はイベントから始まるので、イベントを理解しないとJavaScriptが始まりません。

ちょっとしたScriptでも、高度なWEBアプリでも、JavaScriptでは必ずイベント処理が肝になります。

DOM Events

W3Cによって定められたイベントの標準仕様で、2009/03/24現在、DOM 2 Events および DOM 3 Events 草案が主要な仕様です。

Firefox, Safari, Opera, Google Chromeなどはこの仕様に従っている(解釈の違いなどがあったりはしますが…)ため、DOM Eventsを理解すればこれらのブラウザに自然と対応することができます。

しかし、IEは独自の仕様で実装しているため、実際にはIE用の処理も覚える必要があります。

実践編

Event処理のクロスブラウザ問題

Eventの追加、つまり「ある要素」に対して、「clickやloadなどのイベント」が発生したときに、「指定の関数を実行」させるようにしてみます。まずはIE以外の標準的な方法です。

document.addEventListener('DOMContentLoaded',function(event){
        alert
('読み込みが完了しました');

       
var element = document.getElementById('click_element');
        element
.addEventListener('click',function(event){
                alert
('clickされました');
       
},false);

       
var form = document.getElementById('search_form');
        form
.addEventListener('submit',function(event){
                alert
('フォームを送信します');
       
},false);
},false);

続いて、IEは独自仕様で、attachEventを使用します。

document.attachEvent('onload', function(){
        alert
('読み込みが完了しました');

       
var element = document.getElementById('click_element');
        element
.attachEvent('onclick', function(){
                alert
('clickされました');
       
});

       
var form = document.getElementById('search_form');
        form
.attachEvent('onsubmit', function(){
                alert
('フォームを送信します');
       
});
});

addEventListenerとattachEventを比べてみると、addEventListenerは引数が1つ多く、3つ目の引数があります。通常はfalseにしておけばattachEventとのほぼ同じ動作をしてくれます。一応大雑把に説明すると、HTMLは入れ子構造になっているので、ある要素でイベントが発生したときその親(にも|から)イベントが伝搬します。キャプチャリングフェーズ(useCaptureをtrueにした場合)では親から子にイベントが伝搬してきます。逆に、バブリングフェーズ(useCaptureがfalse)では子から親に向かってイベントが伝搬していきます。文字だけでは分かりにくいので、とみぞーノートさんの記事どうぞ

さて、本題のEventの追加をクロスブラウザにしてみます。

function addEvent(node, type, handler, useCapture){
       
if (node.addEventListener) {
                node
.addEventListener(type, handler, useCapture);
       
} else if (node.attachEvent) {
                node
.attachEvent('on'+type, handler);
       
} else {
               
var _type = 'on' + type;
               
if (typeof node[_type] === 'function') {
                       
var _handler = node[_type], __handler = handler;
                        handler
= function(evt){
                                _handler
(evt);
                                __handler
(evt);
                       
};
               
}
                node
[_type] = handler;
       
}
}
var button = document.getElementById('button1');
addEvent
(button,'click',function(evt){
        alert
([evt,this.tagName,evt.type]);
});
button
.focus();

if (node.addEventListener) でaddEventListenerを実装しているか調べています。if (typeof addEventListener === 'function')とするほうが厳密ですが、前者でも問題ありません。上記は標準仕様のaddEventListenerを優先していますので、今後IEがaddEventListenerに対応した場合や、新しいブラウザ(JavaScriptエンジン)が出てきた場合もこの記述には変更の必要がないことを期待できます。

IEのために、もうちょっと頑張ってみます。

function addEvent(node,type,handler,useCapture){
       
if (node.addEventListener) {
                node
.addEventListener(type, handler, useCapture);
       
} else if (node.attachEvent) {
                node
.attachEvent('on'+type, function(evt){
                        handler
.call(node,evt || window.event);
               
});
       
} else {
               
var _type = 'on' + type;
               
if (typeof node[_type] === 'function') {
                       
var _handler = node[_type], __handler = handler;
                        handler
= function(evt){
                               
if (evt) evt = window.event;
                                _handler
.call(this, evt);
                                __handler
.call(this, evt);
                       
};
               
}
                node
[_type] = handler;
       
}
}
var button = document.getElementById('button2');
addEvent
(button,'click',function(evt){
        alert
([evt,window.event==evt,this.tagName,evt.type]);
});
button
.focus();

ただattachEventするのではなく、callやwindow.eventでaddEventListenerとの挙動の差を小さくしています。

ここで要点をまとめてみますと、

課題:よりよい実装

上記のコードの改良点を考えてみる

もしaddEvent1000回呼び出すとしたら? ⇒ addEvent内のifが1000回の実行される ⇒ ifを外に出し、1回の分岐にする

if (document.addEventListener) {
       
var addEvent = function(node, type, handler, useCapture) {
                node
.addEventListener(type, handler, useCapture);
       
};
} else if (document.attachEvent) {
       
var addEvent = function(node, type, handler) {
                node
.attachEvent('on'+type, function(){
                        handler
.call(node,window.event)
               
});
       
};
} else {
       
var addEvent = function(node, type, handler) {
               
var _type = 'on' + type;
               
if (typeof node[_type] === 'function') {
                       
var _handler = node[_type], __handler = handler;
                        handler
= function(evt){
                               
if (evt) evt = window.event;
                                _handler
.call(this, evt);
                                __handler
.call(this, evt);
                       
};
               
}
                node
[_type] = handler;
       
};
}

こちらは比較的、見たままで理解しやすいと思います。各ブラウザの実装に応じて、addEvent関数が適切に定義されます。

無名関数を使って書く(高階関数)

var addEvent = (function (){
       
if (document.addEventListener) {
               
return function(node, type, handler, useCapture) {
                        node
.addEventListener(type, handler, useCapture);
               
};
       
} else if (document.attachEvent) {
               
return function(node, type, handler) {
                        node
.attachEvent('on'+type, function(){
                                handler
.call(node,window.event);
                       
});
               
};
       
}
})();
var button = document.getElementById('button3');
addEvent
(button,'click',function(event){
        alert
([event,this.tagName,event.type]);
});
alert
(addEvent);
button
.focus();

addEvent関数の中身はIE以外なら、

function(node, type, handler, useCapture) {
        node
.addEventListener(type, handler, useCapture);
}

IEの場合、

function(node, type, handler) {
        node
.attachEvent('on'+type, function(){
                handler
.call(node,window.event);
       
});
}

と、それぞれ適切な関数になっていることが確認できると思います。

初期化コストが発生するものの、addEventを何度も使う場合は効率が良い方法です。ただし(特に汎用的なライブラリでは)、使用しない関数の初期化処理をすることになりがちなので、必ずしもベストプラクティスとは言えない面もあります。

addEventの改良点

これらの問題を解決するため、Event周りをラップするクラスを作るのが望ましいのですが、IEの勝手仕様やIEのメモリリーク、Firefox,Safari,Google Chrome,Operaでも微妙にバグや仕様の解釈の違いなど、問題はいろいろあるので自力で実装するのは大変です。

そこで、jQueryを使えばこれらの問題を気にする必要は(ほとんど)なくなるので、使えるなら使ってしまいましょう。

下記をIEで実行してみると…

function addEvent(node,type,handler,useCapture){
       
if (node.addEventListener) {
                node
.addEventListener(type, handler, useCapture);
       
} else if (node.attachEvent) {
                node
.attachEvent('on'+type, function(evt){
                        handler
.call(node,evt || window.event);
               
});
       
} else {
               
var _type = 'on' + type;
               
if (typeof node[_type] === 'function') {
                       
var _handler = node[_type], __handler = handler;
                        handler
= function(evt){
                               
if (evt) evt = window.event;
                                _handler
.call(this, evt);
                                __handler
.call(this, evt);
                       
};
               
}
                node
[_type] = handler;
       
}
}
var button = document.getElementById('button4');
for (var i = 0; i < 10; i++) (function(i){
        addEvent
(button,'click',function(evt){alert(i);});
})(i);
button
.focus();

クロージャ

JavaScriptにはブロックスコープがありません。スコープを切りたい場合、functionで区切る必要があります。その際、クロージャを意識する必要があります。

var element = document.getElementById('output1');
for (var i = 0; i < 10; i++) {
       
var button = document.createElement('input');
        button
.type = 'button';
        button
.value = i;
        element
.appendChild(button);
        addEvent
(button,'click',function(event){
                alert
([i,button.value]);
               
/* i はすべて10、buttonは常に9(最後のボタン) */
       
});
}
id=output1

上記のサンプルコードでは、iはすべて同じモノを参照しているので、どのボタンをクリックしても10がalertされてしまいます。

var element = document.getElementById('output2');
for (var i = 0; i < 10; i++) (function(_i){
       
var button = document.createElement('input');
        button
.type = 'button';
        button
.value = _i;
        element
.appendChild(button);
        addEvent
(button,'click',function(event){
                alert
([_i,button.value]);
               
/* _i もbuttonも、 0~9までの値が入っている */
       
});
})(i);
id=output2

このように、関数の引数とvarで宣言されたローカル変数はその関数内の変数として閉じ込めることができます。

ただし、無名関数はループのたびに関数を作って呼び出すので、若干無駄があります。

そこで、下記のように関数は先に作っておくことで、呼び出すだけにできます。(この例はあまり美しくありませんが…)

var element = document.getElementById('output3');
function create_button(_i){
       
var button = document.createElement('input');
        button
.type = 'button';
        button
.value = _i;
        element
.appendChild(button);
        addEvent
(button,'click',function(event){
                alert
(_i);
       
});
}
for (var i = 0; i < 10; i++) {
        create_button
(i);/* 関数は先に作っておいたほうが効率的 */
}
id=output3

Firefox,Safari3+,Opera9.5+,Google Chrome(要はIE以外)ではArray#forEachを使うという方法もあります。

var element = document.getElementById('output4');
[0,1,2,3,4,5,6,7,8,9].forEach(function(_i){
       
var button = document.createElement('input');
        button
.type = 'button';
        button
.value = _i;
        element
.appendChild(button);
        addEvent
(button,'click',function(event){
                alert
(_i);
       
});
});
id=output4