preventDefault() と stopPropagation()

先日公開した「投稿スラッグ(Post slug)が空白なら警告してくれるWordPress用Greasemonkeyスクリプト」を開発しているときに、JavaScriptでのイベントのキャンセルまわりで見事にハマってしまいました。そのときに調べてわかったことをまとめてみようと思います。(間違いがあれば是非ご指摘ください!)

やりたかったこと

WordPressの投稿画面で「公開(Publish)」ボタンをクリックしたときに、「投稿スラッグ(Post slug)」のテキストボックスに値が入っていなければ、確認ダイアログを出す。そこで「キャンセル」ボタンが押されたら、フォームの submit を中止する。

Screenshot of WordPress Post Page

最初に思いついた方法

まず頭に浮かんだのは、submit ボタンに対して HTML でイベントハンドラを記述するという、とても古典的な方法でした。

HTML:
  1. <input name="publish" type="submit" id="publish" tabindex="5" accesskey="p" value="公開" onClick="return CheckPostSlug();" />
  2. <script type="text/javascript">
  3. function CheckPostSlug() {
  4.     if (document.forms[0].post_name.value == '') {
  5.         return confirm("WARNING!\n\n" + "Post slug is blank. Are you OK?\n");
  6.     }
  7. }
  8. </script>

しかし、今回開発するのは Greasemonkey スクリプトです。直接 HTML を書き換えるわけにはいきません。DOM 操作で無理やりできないことはないかも知れませんが、あまりにもダサい。もっとスマートな方法を探ってみました。

submit がキャンセルできない・・・

まずは、addEventListener() メソッドを使って次のように書いてみました。

JavaScript:
  1. $('publish').addEventListener('click', function() {
  2.     if ($('post_name').value == '') {
  3.         if(!confirm(msg)){
  4.             // eventをキャンセル(してるつもり)
  5.             return false;
  6.         }
  7.     }
  8. }, true);
  9. function $(id){
  10.     return document.getElementById(id);
  11. }

しかし、この方法ではうまくいきませんでした。(何度テスト用のエントリを投稿したことか・・・)いろいろ試してみたところ、clickイベントは キャンセルできているのですが、フォームのsubmitイベントがキャンセルできていないようです。では、submit 時のイベントをなくしてしまえということで、

JavaScript:
  1. $('publish').addEventListener('click', function() {
  2.     if ($('post_name').value == '') {
  3.         if(!confirm(msg)){
  4.             // submit を無効に(してるつもり)
  5.             $('post').onsubmit = function() { return false; }
  6.         }
  7.     }
  8. }, true);

としたかったのですが、これは Greasemonkey のセキュリティ的な制限でできません。むむ・・・

Event.stopPropagation() ?

行き詰って Greasemonkey Hacks に載っているサンプルをあれこれ眺めていると、Event.stopPropagation() というメソッドを見つけました。Google で調べてみたところ、

stopPropagation メソッドは、イベントフローにおいてこれ以上イベントが伝えられるのを止めるために使用します。

Event (共通 DOM API)

とあります。おぉ、これかぁと思ったのですが、これもダメ。むむむむ・・・

Event.preventDefault() !!

結局、Event.preventDefault() を使えばうまくいったのでした。

JavaScript:
  1. $('publish').addEventListener('click', function(e) {
  2.     if ($('post_name').value == '') {
  3.         if(!confirm(msg)){
  4.             // click も submit もキャンセルできた!
  5.             e.preventDefault();
  6.         }
  7.     }
  8. }, true);

preventDefault() について調べてみると、

preventDefault メソッドを使用するとイベントのキャンセルを通知できるため、そのイベントの結果として通常は実装により実行されるデフォルトのアクションが実行されません。

Event (共通 DOM API)

とあります。サブミットボタンの click イベントのあとには、フォームの submit イベントがデフォルトのアクションとして実装されているようなので、今回はこのメソッドが正解だったようです。

では、stopPropagation() は何だったのでしょう?これを理解するには、まずイベント伝播 (event propagation) という概念を理解する必要があります。

イベント伝播

私も完全に理解しているわけではないのですが、誤りを恐れずにざっくりと説明すると(JavaScript本を参考にしたのでたぶんあってるはず)、例えば、フォームのあるボタンをクリックしたとき、実は、

  1. まず、最上位である Window オブジェクトの Click イベントが発生。
    ここで例えば window.onclick = function() { alert('window was clicked'); } などと定義しているとアラートが表示される。

  2. 次に、その下位(例えばForm オブジェクト)の Click イベントが発生。
    ここで例えば document.forms[0].onclick = function() { alert('form was clicked'); } などと定義しているとアラートが表示される。

  3. 最後に、クリックした Button オブジェクトの Click イベントが発生。

ということが起こっているのです。これが「イベント伝播」です。しかし、これは Firefox や Opera に限った話で、Internet Explorer の場合は、イベントの伝わり方が逆になります。つまり、

  1. まず、クリックした Button オブジェクトの Click イベントが発生。
  2. 次に、その上位(例えばForm オブジェクト)の Click イベントが発生。
  3. 最後に、最上位である Window オブジェクトの Click イベントが発生。

となります。Firefox や Opera のように「上から下に」伝わっていくのを「イベントキャプチャリング方式」といい、IE のように「下から上に」伝わっていくのを「イベントバブリング方式」と言います。「バブリング」は「泡(バブル)」なので、下から上に伝わっていくと覚えればよいかと思います。IE なんて泡のように消えてし(ry

さきほどうまくいかなかった stopPropagation() は、このイベント伝播を途中で止めるときに利用するメソッドなのです。

IE でのイベントキャンセル

なお、IE はEvent.preventDefault()Event.stopPropagation() に対応していません。その代わりに、グローバル変数 event のプロパティである returnValue と cancelBubble を使います。

Firefox, Opera Event.preventDefault();
IE event.returnValue = false;
Firefox, Opera Event.stopPropagation();
IE event.cancelBubble = true;

まとめ

イベント伝播が理解できれば効率のよいコードを書くことができます。例えば、「プルダウンリストの中をマウスホイールで知らずに変えちゃうことがあ るので、すべてのプルダウンリストでマウスホイールが効かないようにしてほしい」という要望がユーザーからあったとします。(実際あったんですが。)この 場合、すべての select 要素にイベントハンドラを定義していくとえらい大変ですが、

JavaScript:
  1. function preventWheel() {
  2.     document.onmousewheel = function(){
  3.         var target_elm = event.srcElement ? event.srcElement : null;
  4.         if (target_elm && target_elm.tagName && target_elm.tagName.toLowerCase() == 'select' && target_elm.size <= 1){
  5.             event.returnValue = false;
  6.             event.cancelBubble = true;
  7.         }
  8.     }
  9. }

というふうに、上位の document オブジェクトで、伝播されてくるすべてのイベントを監視して、イベントの発生元が select 要素だったらキャンセルする、と書くことができるのです。ね、便利でしょ?

イベントまわりは、ブラウザごとに仕様が異なるせいもあり、よくわかっていないとはまりますが、使いこなせるようになれば、かなり強力な武器になり得ると思うので、しっかり見につけたいと思います。