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
Chrome と Firefox が HEAD の中身と BODY の中身を列挙。
Opera は、なぜか HEAD は1要素として列挙され、BODY の中身が列挙されてます。
IE6 と IE8 では HEAD は完全に無視され、BODY の中身列挙という形になりました。
ちなみに、 IE9 PP7 で試してみたところ、jQuery では Chrome/Filefox と同じ挙動になりました。
div.childNodes を列挙するだけだと IE6/IE8 と同じ結果なので、jQuery 側で吸収している部分があるのかも知れません。(追ってません)
まとめ
- html をまるっと jQuery に渡したときは、そのままの構造にはならないから注意。
- ブラウザによって挙動が異なるから気をつけよう。
ま、同僚には、html 全体を取得してきて jQuery に突っ込むとか普通やんねーよって言われちゃいましたが。