ROCHAS

高速で安全なjQueryを書くために今できること

先人達が模索し続けたjQueryの定説を、私はちゃんと理解できているだろうか?またそれはjQuery1.8~2.0世代の現在においても有効なのだろうか?
jsPerfよりベンチマークをシェアさせていただきつつ、パフォーマンスやXSS対策についても少しだけ。高速で安全なjQueryの書き方をまとめてみました。
jsPerfはベンチマークを自分で作成したり、他の方がつくったものをシェアできるツールです。ブラウザやデバイズ別の履歴が残っているので、精度の高い検証が行えます。

1. jQueryセレクタのメカニズムを理解する

セレクタは$("Selector")と記述し、HTMLドキュメントから特定の要素を取得します。その取得した要素にメソッドで処理をする、というのがjQueryの基本。またjQueryは内部的にJavaScripで処理を行っています。
セレクタを生成する方法は3つあり、その3つのうち内部的にどの方法で処理されているか、またその処理の回数をいかに減らすかがパフォーマンスの鍵を握っているのです。

id vs class vs tag vs pseudo vs. attribute selectors

id vs class vs tag vs pseudo vs. attribute selectors | jsPerf

$("#foo");     // IDセレクタは1番高速
$("blockquote");     // タイプセレクタは2番目に速い
$(".bar");     // Classセレクタは3番目
$("[name='baz']");     // 属性セレクタは4番目でやや低速
$(":hidden");     // jQuery独自拡張セレクタは5番目で最も低速

getElementById()系メソッドによってセレクタを生成する方法

getElementsByTagName() getElementById() getElementsByClassName() getElementsByName()によってDOMから要素を取得する方法。
IDセレクタやタイプセレクタを指定した際に呼び出されます。ネイティブのJavaScriptのメソッドによるものなので最速。

querySelectorAll()によってセレクタを生成する方法

Selectors APIのquerySelectorAll()によって、CSSセレクタを利用して要素を取得する方法。
getElementById()系では処理しきれないグループセレクタや属性セレクタを指定した際に呼び出されます。これもネイティブのJavaScriptのメソッドによるものなので速い方ではありますが、getElementById()系よりは低速になります。

Sizzleによってセレクタを生成する方法

SizzleはCSSセレクタを指定して要素を取得できるjQuery拡張のセレクタエンジンです。 getElementsByClassName()querySelectorAll()が非対応のレガシーIEでの要素取得を実現したり、[attribute!="value"] :first :last :animatedなどjQuery独自拡張セレクタやカスタムセレクタを指定した際に呼び出されます。
jQuery1.3から実装され、jQuery1.8で大幅な改訂によってパフォーマンスもだいぶ向上してきています。柔軟な指定が可能である反面、ネイティブに比べるとかなり処理は遅くなってしまいます。

最速 getElementById() ネイティブのJavaScript
やや速 querySelectorAll() ネイティブのJavaScript
低速 Sizzle jQuery独自拡張

2. IDセレクタは1番高速

$('#id');
// getElementById()で取得、最速 
$('#id p').hide();
// ID要素を先頭にして検索範囲を絞り込むと高速

IDセレクタはgetElementById()によって要素を取得できるため高速になります。 要素を複数指定する時はID要素を先頭にするとドキュメント内からの要素検索の範囲を絞り込むことができます。

3. タイプセレクタは2番目に高速

$( 'form' ); 
// getElementsByTagName()で取得、速い

タイプセレクタはgetElementTagName()によって要素を取得できるため高速になります。

4. Classセレクタはモダンブラウザはやや高速、IE8以下では低速

$('.class'); 
// モダンブラウザはgetElementsByClassName()で取得、やや速い
// IE8以下はSizzleで取得、低速

classセレクタはモダンブラウザであればgetElementsByClassName()によって要素を取得できるためやや高速になります。 ただしgetElementsByClassName()が非対応なIE8以下ではSIzzleが呼び出され低速になってしまいます。対応策としてセレクタにID要素を先頭に追加してgetElementById()によって呼び出す方法が考えられますが、無駄な指定を増やさないように気を付けなければなりません。

5. 属性セレクタは4番目でやや低速

$('[attribute="value"]');
// querySelectorAll()で取得、やや低速
// IE7以下はSizzleで取得、低速

属性セレクタはquerySelectorAll()によって要素を取得できるため、getElementById()系よりは低速になります。 またquerySelectorAll()が非対応なIE7以下ではSIzzleが呼び出され、さらに低速になってしまいます。

6. jQuery独自拡張セレクタは5番目で最も低速

(":hidden");
$('[attribute!="value"]');
// Sizzleで取得、最も低速

jQuery独自拡張セレクタはSizzleによって要素を取得するため、最も低速になります。 また属性セレクタの中でも$('[attribute!="value"]');(属性の値がvalueでない要素)はquerySelectorAll()でも取得できないため、Sizzleが呼び出され低速になります。

ネイティブのJavaScriptのメソッドで処理できないセレクタによってSizzleが呼び出されるとjQueryのパフォーマンスに打撃を与えてしまうのです。 またセレクタは右から左へと解釈されるため、指定する要素が増えるほど検索工程が増え、処理が遅くなります。 このメカニズムはCSSセレクタと同じですね!

7. 無駄な要素を指定せず、ID要素を先頭に指定する

$( 'div#id' ); ⇒ $( '#id' );
$( '#id ui li a' ); ⇒ $( '#id a' );

複数の要素指定も低速になります。ID要素の前にdiv要素を入れると、ID要素を全てのdiv要素から検索してしまうといったように、指定する要素が増えるほどドキュメント内の要素検索が繰り返されてしまうためです。
無駄な要素指定はしないようにし、子孫セレクタで指定範囲を限定する時は先頭をID要素にして検索範囲を絞り込みましょう。

8. jQuery独自拡張セレクタを他の方法に書き変えて高速化

$(':checkbox'); ⇒ $('input:checkbox'); // タイプセレクタと組み合わせ(チェックボタン)
$(':submit'); ⇒ $('[type="submit"]'); // 属性セレクタに代替(送信ボタン)
$('li:first'); ⇒ $('li').first(); // メソッドによる絞り込み(指定された要素の先頭)
$('li:eq(2)'); ⇒ $('li').eq(2); // メソッドによる絞り込み(指定した位置の要素)
$('[attribute!="value"]') ⇒ $("selector").not('[attribute="value"]');
 // メソッドによる絞り込み(値がvalueでない要素)

Query独自拡張セレクタを高速化するためには、タイプセレクタと組み合わせて指定する方法、属性セレクタに代替する方法、メソッドによる絞り込みで代用できないかを検討してみましょう。
セレクタと同じ要素の絞り込みの働きをするメソッドがあります。要素の絞り込みはまずセレクタで要素を指定し、それからこメソッドでさらに絞り込むとフォーマンスが向上します。

9. コンテキストを正しく理解する

// 誤 
$('a', '#myContainer').context; 
$('#myContainer').find('a');
// 内部的には下のように処理され、要素が絞り込まれていないので低速  


// 正
var context = $('#myContainer')[0];
$('a', context).context;
// 最初にマッチした#myContainerだけを取得し、contextに渡すと高速

要素の検索範囲を絞り込むために第2引数にコンテキストを指定する方法がありますが正しく理解しないと、高速化にはつながりません。
[0]とすることで最初にマッチした#myContainerを取得して、それ以降無駄な検索を行わないようにしています。

10. 要素の絞り込みにはfind()メソッドを使って高速化

ベンチマークではコンテキストで絞り込むよりもfind()(引数に適合する全ての子孫要素)の方が高速との結果になっています。

$('#myContainer').find('a'); // 1番高速
$('a', $('#myContainer')[0]); // 2番目に速い
$('#myContainer a'); // 3番目低速

$('#myContainer a');は先にa要素を検索してその中から#myContainerが先祖要素にあたるa要素をjQueryオブジェクトにします。 一方find()は先に#myContainerを検索した後にその中からa要素をjQueryオブジェクトにするため高速になります。

11. コンテキストをさらにfind()メソッドで絞り込むのが高速

コンテキストもfind()も速い!ということで、子要素を絞り込むベンチマークで比較してみると、コンテキストを正しく使って、さらにfind()で絞り込むのが最も最速であることがわかります。

jQuery parent/child selectors | jsPerf

jQuery parent/child selectors

var $test = $('#list .test'); // 子孫セレクタは6番目で最も低速
var $test = $('#list > .test'); // 子セレクタ絞り込まれているが5番目で低速
var $test = $(list).children('.test'); // children()は他の兄弟要素まで検索してしまい4番目
var $test = $('.test', list); // コンテキストは3番目
var $test = $('#list').find('.test'); // find()は2番目に速い
var $test = $(list).find('.test'); // コンテキストをさらにfind()で絞り込むのが1番高速

もういっそうの事・・・

12. ネイティブのJavaScriptで高速化

$('#id');  ⇒  $(document.getElementById('id')); 
// IDセレクタをgetElementById()で爆速
$(':focus');  ⇒  $(document.activeElement()); 
// jQuery独自拡張セレクタ:focusをactiveElement()で高速

ネイティブのJavaScriptのメソッドで要素を取得する方法は、jQueryを介さない分何よりも爆速。 activeElement()は現在フォーカスが当たっている要素を取得するメソッドです。こちらを代用することで:focusを高速化することができます。これは速い・・・・。

13. メソッドチェーンやキャッシュで高速

jQuery chaining | jsPerf

jQuery chaining

// 低速
$( '#theDiv' ).addClass('test');
$( '#theDiv' ).removeClass('test');

// メソッドチェーンは最も高速
$('#theDiv').addClass('test').removeClass('test');

// キャッシュも高速
var d = $( '#theDiv' );
d.addClass('test');
d.removeClass('test');

// メソッドチェーンとキャッシュの組み合わせも高速
var d = $( '#theDiv' );
d.addClass('test').removeClass('test');

何度も同じセレクタを実行したり、何度も同じjQueryオブジェクトを作成することはパフォーマンスを低下させます。 jQueryオブジェクトは変数に格納してキャッシュに登録するか、メソッドチェーンを使いましょう。 追記:最初は、ベンチマークではキャッシュが最速だと思っていたのですが、ブラウザやOSによってはメソッドチェーンや両方組み合わせの方が高速だったり、明確な違いがこの条件だけでは見出せなかったので、条件やコードの管理のしやすさなどを考慮して使い分けていくのがよさそうです。アドバイスをくださった方、ありがとうございます!

14. $()によるXSS対策のためにjQuery1.8から追加された$.parseHTML()について

重くて有名な$()「jQueryオブジェクト」にタグを書いて文字列から要素を生成する方法。これはパフォーマンスに負担がかかるだけではなく、誤った記述がXSSと解釈されてしまう危険をはらんでいます。
それを回避するため、jQuery1.8からは$()の代わりとなる$.parseHTML()が追加されました。
またjQuery1.9からはさらにXSS対策が強化され、先頭が<以外の文字列にするとSizzleが発動し、エラーが返されるようになりました。ですので文字列から要素を生成する場合は$.parseHTMLを使いましょう。

$ vs createElement vs .parseHTML | jsPerf

 $ vs createElement vs .parseHTML

値を配列で返してくれるparseHTMLはパフォーマンスも格段に向上しますね!
$()は内部的にcreateElement()を経由しているため、やはりネイティブの$(document.createElement('div'))が最速ですが。
またSizzleによってエラーを返すところがXSS対策には重要なのです。 クロスブラウザを実現してきたSizzle(時には遅い遅いと嘆かれながらも)。jQuery2.0からはIE8以下をサポートしなくなるので、今後は新しい役割を担っていくのですね。がんばれSizzle!

15. DOMの操作の回数は最小限にして高速化

// 低速
var ul = $('ul');
for (var i=0; i<50; i++) {
   ul.append('<li>' + i + '</li>');
}

// 高速
var ul = $('ul');
var lis;
for (var i=0; i<50; i++) {
   lis += '<li>'+ i +'</li>';
}
ul.append(lis);
}

DOM操作は非常に負荷がかかるので処理回数を減らしましょう。 forループで50回のappend()を実行しているスクリプトを変数lisに格納し、最後にまとめて要素をDOMに追加することで高速化を図っています。

16. bind/unbind、live/die、delegate/undelegateはjQuery1.7からon/offに統一された

イベントを設定/削除するメソッドにはbind/unbind、live/die、delegate/undelegateがあり、イベントが発生する場所や条件によってどれを使うかが分かれていましたが、ご存じのとおりjQuery1.7からon/offに統一されました。 ではそれぞれどのような違いがあり、どうやって書き変えれば高速化なのかを検証してみます。

  • .bind()はセレクタで指定した要素全てに対してイベントを設定してます。
  • .live().bind()の違いは.live()は要素が存在しない段階でも追加予定の要素にイベントを設定できる点と$(document)に対してイベントが設定されている点です。
  • イベントが発生しバブリングで上がってきたイベントターゲットとセレクタが一致するかを判定してから処理を実行してます。
  • .delegate().live()と同様に要素が存在しない段階でイベントを設定することができます。 .live()との違いはイベントが発生する親要素に対してイベントを指定し、範囲を限定することができる点です。$(document)にも設定できます。

17. イベントフロー(キャプチャリング / ターゲット / バブリング)を最小限にして高速化

こちらのテストでは同じ条件下での速度比較でなく、それぞれイベントターゲットが異なります。イベントフローの違いによってどう速度に影響するか、どのように.on()/.off()に書き換えることができるのかを検証してみたいと思います。

jQuery .live() vs .on() vs .delegate() vs .bind() | jsPerf

 jQuery .live() vs .on() vs .delegate() vs .bind()

  • .bind()はページ内全ての$('a')がイベントターゲットとなってしまうため、メモリに負荷がかかり低速になります。
  • .live()$(document)の一か所にイベントの設定が集約されているため.bind()よりは速くなります。 ただし.live()は最初にa要素の検索が実行されてしまうため.delegate()よりバブリングフェーズが増え低速になります。
  • .delegate()$(document)にイベント発生を限定しているため、最初にa要素を検索することはなく、キャプチャリングでイベントが下りていくだけなので.live()より高速になります。
  • .delegate().onをさらにコンテキストで絞り込むよりも$(document)に設定した方が高速。 これはイベントフローの関係で('#context')を指定することで最初にセレクタ検索が行われてしまうためです。バブリングフェーズを増やしてしまうより$(document)からキャプチャリングさせるだけのほうが伝達が速く、高速になります。

jQuery1.7からは.on()/.off()推奨!どこでイベントが発生し、どこに伝達させるのか、対象となる要素はどこかを考え、イベントフローは最小限にしてon()/off()に書き変えましょう!

jQuery1.6以前 jQuery1.7以降 イベント発生箇所
$(‘a’).bind(‘click’, handler) $(‘a’).on(‘click’, handler) $(‘a’)全てで発生
$(‘a’).unbind(‘click’, handler) $(‘a’).off(‘click’, handler) $(‘a’)全てで発生
$(‘a’).live(‘click’, handler) $(document).on(‘click’, ‘a’, handler) $(document)で発生
$(‘a’).die(‘click’, handler) $(document).off(‘click’, ‘a’, handler) $(document)で発生
$(‘div’).delegate(‘click’, ‘a’, handler) $(‘div’).on(‘click’, ‘a’, handler) $(‘a’)の親要素で発生
$(‘div’).undelegate(‘click’, ‘a’, handler) $(‘div’).off(‘click’, ‘a’, handler) $(‘a’)の親要素で発生

参考にさせていただいたソースでだいぶ時が経ってしまったものに関しては、できる限り検証したつもりではありますが、もちろんブラウザやjQueryのバージョンによっても変わってきますし、間違いもあるかと思います。まだまだ他にも、gruntを使って必要な機能だけにモジュールを絞り込んでjQuery自体を圧縮、なんて方法もjQuery1.8から可能になりました。 jQuery APIの公式サイトも一新!1.8~2.0にかけて大きく変化を遂げることとなると思いますので、常に新しい情報に目を向けつつも、実際のWebサイト上での検証を第一に心掛けていきたいと思います。

参考