jQuery の解釈するHTML 文字列

完全にHTMLなインタフェースで作ったWebアプリを jQuery 使って無理矢理、無遷移化してみてたら、
jQuery (というか実際にはブラウザの実装)の面白い特性を見つけたので書いておく。

おさらい

jQuey は、 jQuery オブジェクトに対してHTML文字列を突っ込むと、その構造の DOM Elementを含む jQueryオブジェクトとして解釈してくれます。
例えばこんな感じ

Source1
  var elm = $('<div class="hoge1">hoge<strong class="fuga">fuga</strong>bar</div><span class="hoge2"></span>');
  alert(elm[0].className);          // hoge1 : トップレベルの要素が配列になるイメージ
  alert(elm[1].className);          // hoge2
  alert(elm.find('.fuga').text());  // fuga  : fuga クラスセレクタ。当然下位の要素も含まれる

いやー、jQuery って便利ですね−。

HTMLなインタフェースを無遷移化

HTMLなインタフェースを無遷移化って、何のことかと言えば、
単純に遷移の発生するA タグの中身をごにょごにょと書き換えて、遷移させずに Ajax 叩いて入れ替えるだけです。

Source2
function move(next_url) {
  $.ajax(next_uri, {
      dataType: 'html', 
      success : function(data){
          var h = $(data);
          
          h.find('a').each(function (i, a) {
            $(a).attr('href', 'javascript:move("' + $(a).attr('href') + '");');
          });
  
          view.html('');
          view.append(h);
      }
  });
);

こんな感じで、遷移先のHTMLをそのまま view に入れてやる。
実際には、遷移先によって新しいタブ作ったりとか、表示先を変えたりすることで、無遷移なUI完成。


完璧にやるなら、FORM による遷移も submit をフックしてやるべきなんだろうけど、今回は必要なかったのでやってない。

セレクタが効かない!?

さて本題。

Ajax で引っ張ってきた HTML は、当然 HTML 全体が含まれています。
なので、上記 Source2 の 変数 h には、HTML タグをトップレベルとした DOM 構造が含まれると思っていました。

Source3
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
  	<title>test</title>
  </head>
  <body>
  	<div class="group"><a href="xxx">xxx</a></div>
  	<div class="group"><a href="yyy">yyy</a></div>
  </body>
</html>

ところが、Source3 のような HTML を取得してきた場合、Source2 の変数 h に対して、

h.find('a').length

の結果は 2 であるのに

h.find('.group').length

を実行してみると、結果は 2 ではなく 0 となってしまいました。

DOCTYPE や HEAD タグが邪魔してる? と思い取り除いてみるも結果は同じ。
Chrome/Firefox/Opera/IE それぞれのブラウザで試して見るも、やはり 0 でした。

不可解に思いながら、ブレイクポイント仕込んで中身を見てみると、
変数 h の中身が [title, div.group, div.group] となっていました。
つまり、 HEAD の中身と BODY の中身が列挙されてたんですね。HTML タグは消えてしまっていて。

この場合、変数 h が div.group 自身であり、find 関数は下位の DOM に対する探索となるので、マッチしなかったようです。

jQuery のコードを追う

どうしてこんなのことになるのか、 jQuery の処理を追ってみました。バージョンは 1.5.1 です。

まず、 jQuery オブジェクトの構築のために、
25行目の new jQuery.fn.init( selector, context, rootjQuery ); が呼ばれます。

その後、selector のチェック等を経て、
5429行目の buildFragment( args, nodes, scripts ) が呼ばれることになります。
この関数では、構築済みのDOM Fragment があるかをチェックし、無ければ jQuery.clean が呼ばれます。
jQuery.clean は、5550 行目に定義されています。

この clean 関数がキモで、5584 行目付近の

  div = context.createElement("div");
  div.innerHTML = wrap[1] + elem + wrap[2];

が、html文字列を解釈する中枢でした。(wrap は、特定のタグに対して補う必要がある場合のみ有効となるので、今回の場合は無視してよい)
つまり、 div タグの innerHTML に html文字列を突っ込んでいるだけ。

このあと、div.childNodes の各要素を配列にポンポン追加していった結果が、最終的な jQuery オブジェクトとして返されていました。


ということで、Source4 の result1 と result2 は一致します。

Source4
var hs = '<html>'
       + '<head>'
       +   '<title>hoge</title>'
       + '</head>'
       + '<body>'
       +   '<div class="group"></div>'
       +   '<div class="group"></div>'
       + '</body>'
       + '</html>';

  // jQuery によるDOM構築
  var h1 = $(hs);
  var result1 = '';
  h1.each(function(i,e){
  	result1 += e.tagName+"/"+e.className+"\n";
  });

  // jQuery の実装と(ほぼ)同じこと
  var div = document.createElement('div');
  div.innerHTML = hs;
  var result2 = '';
  for (i=0; i<div.childNodes.length; i++){ 
    result2 += div.childNodes.item(i).nodeName +"/"+ div.childNodes.item(i).className+"\n";
  }
  
  alert(result1);
  alert(result2);

ブラウザによる違い

さて、innerHTML を使っているということは、解釈の仕方はブラウザに依存します。

実際に、Source4 をそれぞれのブラウザで実行してみました。

Chrome9 / Firefox3
TITLE/
DIV/group
DIV/group
Opera11
HEAD/
DIV/group
DIV/group
IE6 / IE8
DIV/group
DIV/group


ChromeFirefox が HEAD の中身と BODY の中身を列挙。
Opera は、なぜか HEAD は1要素として列挙され、BODY の中身が列挙されてます。
IE6 と IE8 では HEAD は完全に無視され、BODY の中身列挙という形になりました。


ちなみに、 IE9 PP7 で試してみたところ、jQuery では Chrome/Filefox と同じ挙動になりました。
div.childNodes を列挙するだけだと IE6/IE8 と同じ結果なので、jQuery 側で吸収している部分があるのかも知れません。(追ってません)

まとめ

  • html をまるっと jQuery に渡したときは、そのままの構造にはならないから注意。
  • ブラウザによって挙動が異なるから気をつけよう。


ま、同僚には、html 全体を取得してきて jQuery に突っ込むとか普通やんねーよって言われちゃいましたが。

リンク

jQuery 本家
http://jQuery.com/