こんにちは。寺岡です。
この記事は TECHSCORE Advent Calendar 2014 の 8 日目の記事です。
今回はJavaScript のload関連イベントにおける実行順序について書きます。
調査するきっかけ
ある日、以下の要件を満たすリダイレクタを作成する必要に迫られました。
「リダイレクト実行時に、とある外部JavaScriptを実行しアクセス解析などを行いたい」
当初、あまり深く考えずに二つ返事で頷いてしまったのですが、検討を開始してみると色々考慮事項が多くて大変でした。
そして、未だにベストな解決ができた気がしていません。
本稿では三十路が迫ったおっさんエンジニアの苦悶の記録をお送りします。
リダイレクトを実行する方法のおさらい
まずは、リダイレクト(的な動作)をさせる方法を一通り考えて見ます。
大きく分類すると、以下の3種類になるでしょう。
- HTTPのステータスコードとLocationヘッダ
- HTMLのmetaタグでrefresh
- JavaScriptのlocation.hrefやlocaiton.replace
今回は解析用のJavaScriptを実行させるために、一旦HTMLを表示させJavaScriptを実行させる必要があります。
それぞれのパターンで実現可能か検討してみましょう。
1. HTTPのステータスコードとLocationヘッダ
ブラウザにHTMLを表示させる前に遷移してしまうため利用できません。
2. HTMLのmetaタグでrefresh
色々試してみましたが、確実に外部JavaScriptを実行させるのは難しそうなのであきらめました。
3. JavaScriptのlocation.hrefやlocaiton.replace
JavaScriptで制御できる分、自由度が高そうです。この方法ならなんとかなるかも知れません。
そんなわけで、JavaScriptでリダイレクトする方針で調査が開始されたのです。
ページ読み込み時にJavaScriptを実行する方法
今回の要件では外部JavaScriptによるアクセス解析が終わった後にページ遷移を行うJavaScriptを実行させる必要があります。
そのため、解析処理がどのタイミングで動作しているかを把握する必要があります。
ページ読み込み時にJavaScriptを実行させる方法として、以下の3パターンがあげられます。
- headやbodyタグ内のscriptタグで実行
- DOMContentLoadedイベントハンドラでの実行
- loadイベントハンドラでの実行
この中で、一般的に多く用いられるのがDOMContentLoadedイベントです。
他の方法では以下のようなデメリットがあるためです。
- scriptタグ内で直接処理を書いてしまうと、まだページ上のDOM要素の構築が完了しておらず必要な要素にアクセスできない可能性がある
- loadイベントハンドラは画像やCSSの読み込み後になるため、実行タイミングが遅い
よく使われるjQuery.ready()もDOMContentLoadedイベントハンドラを利用して実行されています。
今回対象となった外部JavaScriptも「基本的には」DOMContentLoadedイベントを利用していました。
クロスブラウザは大変ですよ
「基本的には」と書いたということは基本的じゃない場合もあるわけで……
DOMContentLoadedイベントがIEに実装されたのは9以降であるため、IE8以前では動作しません。
もちろん今回の外部JavaScriptもIE8以前に対応しているため、DOMContentLoadedが使えない場合も考慮されています。その場合、loadイベントハンドラが使われるようになっていました。
複雑な条件分岐をしたくなかった今回は、DOMContentLoadedの利用を泣く泣くあきらめることになりました。
イベントハンドラを登録する方法あれこれ
気を取り直して、今度は外部JavaScriptから登録されたloadイベントハンドラの直後に処理を実行させる方法を検討してみます。
まずはJavaScriptでイベントハンドラを登録させる方法をおさらいしてみましょう。
- HTML属性として記述。例)<body onload="alert('hoge')">
- JavaScriptのオブジェクトプロパティで設定。例) window.onload=function(){alert('fuga')};
- window.attachEvent (<=IE8など)
- window.addEventListener (モダンなブラウザ)
HTML属性とJavaScriptのオブジェクトプロパティで設定する方法は、表現は違えど実質同じ事をしています。
これら手法は古くから利用されてきましたが、設定されたイベントハンドラが以前のハンドラを上書きしてしまうため最近はあまり利用されません。
attachEventやaddEventListenerを使えば、複数のハンドラを登録することができるため、上記デメリットはありません。
問題は、これらのメソッドは対応ブラウザに注意して呼び分ける必要があることです。
ただし、一般的にはjQueryなどのライブラリによってクロスブラウザで動作するイベント処理手段が用意されているため、これらを意識することは少ないでしょう。
今回は以下を考慮してHTML属性やオブジェクトプロパティでのイベントハンドラの利用も検討します。
- レスポンスタイム要件がシビアかつ出力は単純なHTMLになるため、外部ライブラリは使いたくない
- クロスブラウザを考慮せず使える
- 外部JavaScriptは呼び出し元HTMLで設定されたイベントハンドラを上書きすることはない筈
イベントハンドラ実行順序
今回は「外部JavaScriptの処理の後」というタイミングを狙ってリダイレクト処理を実行させる必要があります。
ここで一つの疑問を解消する必要に迫られました。
複数登録されたイベントハンドラはいったいどんな順序で実行されているのでしょうか。
世界70億人に及ぶJavaScripterのバイブル、サイ本を開いてみると、以下の記述がありました。
・オブジェクトプロパティやHTML属性を設定する方法で登録したイベントハンドラがあれば、最初にこのイベントハンドラを呼び出します。
・addEventListener()を使って登録したイベントハンドラは、登録した順序で呼び出します。
・attachEvent()を使って登録したイベントハンドラは、順不同で呼び出されます。また、シーケンシャルに呼び出されることを前提にコードを記述してはいけません。
オブジェクトプロパティやHTML属性で設定したイベントハンドラは最初に実行されてしまうため、ここでリダイレクト処理をすることは出来ません。
さらに、attachEventで登録されたイベントハンドラの実行順序は保障されていません。
これではloadハンドラの実行直後を狙ってイベントハンドラを登録することができません。
しかし、我等がJavaScriptにはこんな時のための心強い救世主がいるではありませんか!
そう、それは「setTimeout」です。
HTML属性に設定したonloadイベントハンドラ内でsetTimeoutメソッドを呼び出し、setTimeoutのハンドラ内でリダイレクト処理をすればよさそうです。
実際に試してみる
方針が決まったら調査あるのみ!
以下のHTMLでloadイベントに登録されたハンドラの実行順序を調査してみます。
ついでに、head内とbody内で登録したイベントハンドラに違いが出るのかも確認してみましょう。
イベントハンドラに10件登録しているのは、attachEventで登録したハンドラが順不同で呼び出されることを確認するためです。
※attachEventは件数が少ないと一定の順序(登録の逆順)で発火するように見えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<html> <head> <script type="text/javascript"> function log(str) { document.getElementById('log').value += str+"\n"; } // addEventListenerとattachEventの判別 var method = window.addEventListener ? 'addEventListener' : 'attachEvent'; // attachEventの際のprefix var prefix = window.addEventListener ? '' : 'on'; // head内で5件のイベントハンドラを登録 for (var i=1,l=5;i<=l;i++) { (function(msg) { window[method](prefix+'load', function(){ log(msg) }, false); })(method+'[head]'+i); } </script> </head> <!-- onload属性のハンドラ。0ミリ秒でのsetTimeoutも試す --> <body onload="log('body.onload');setTimeout(function(){log('body.onload+timeout')}, 0)"> <script type="text/javascript"> // body内で5件のイベントハンドラを登録 for (var i=6,l=10;i<=l;i++) { (function(msg) { window[method](prefix+'load', function(){ log(msg) }, false); })(method+'[body]'+i); } </script> <textarea id="log" rows="50" cols="100"></textarea> </body> </html> |
実験結果
今回はIE10(IE8標準モード)・IE10(標準モード)・Firefox・Chromeで調査しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
# IE10(IE8標準モード) body.onload attachEvent[body]9 attachEvent[body]8 attachEvent[body]7 attachEvent[body]6 attachEvent[head]5 attachEvent[body]10 attachEvent[head]4 attachEvent[head]3 attachEvent[head]2 attachEvent[head]1 body.onload+timeout # IE10(標準モード)・Firefox・Chrome addEventListener[head]1 addEventListener[head]2 addEventListener[head]3 addEventListener[head]4 addEventListener[head]5 body.onload addEventListener[body]6 addEventListener[body]7 addEventListener[body]8 addEventListener[body]9 addEventListener[body]10 body.onload+timeout |
「body.onload+timeout」が常に最後に実行されているので、今回の要件はこの方法で満たせそうです。
新たに判明した事実
さらに、この実験で面白いことがわかりました。
サイ本にあったこの記述、どうやら正確ではなさそうです。
・オブジェクトプロパティやHTML属性を設定する方法で登録したイベントハンドラがあれば、最初にこのイベントハンドラを呼び出します。
IE10(IE8標準モード)では記述どおりの動作になりましたが、IE10(標準モード)・Firefox・Chromeでは以下の順序で動作しました。
- head内のスクリプトで登録されたイベントハンドラ
- HTML属性で設定されたイベントハンドラ
- body内のスクリプトで登録されたイベントハンドラ
動作から想像すると、addEventListenerのハンドラとonload属性は単純に登録された順で呼び出されているようです。
結論
- JavaScriptのイベントハンドラの呼び出し順序はブラウザによって違うので、常に意識しておかないとダメ
- 呼び出し順序に依存しないコードを書けばいいんだけど、DOMContentLoadedとかloadイベントについてはそうも言ってられない
- 本を鵜呑みにせず実験してみるべき!新たな知見が得られるかも?
追伸:実験結果では0msでいけそうなsetTimeoutを日和って100msに設定したのは内緒です。