CSSセレクタの開発から得られたノウハウのフィードバック + IE8でメソッドをフックする(HTMLElementプロトタイピング)

ここ数日は、CSSセレクタ(uupaa-selector.js)の高速化と同時に、DOM Level2 Mutation Events をサポートする小さなライブラリ(uupaa-mutationevent.js)を作っていました。

uupaa-mutationevent.js は、

  • CSSセレクタにキャッシュを導入するなら、DOMツリーの改変を知る必要がある
  • IE には Mutation Events を実装するための基礎がそもそも無いので、それらを入れ込んだライブラリを作ってしまおうか

といった動機付けから作成が始まりました。

これらを作成する過程で得られたノウハウを、今日は むぎゅっと縮めてお届けします。

HTMLElement にオレオレメソッドを追加する

IE8 には HTMLElement や Node を改変できる仕組みが追加されています。

  • constructor が追加されているので、継承元の interface を constructor.prototype や constructor.prototype.constructor.prototype で辿れる。
  • __defineSetter__, __defineGetter__, __lookupSetter__, __lookupGetter__ が使える

これらを活用することで、HTMLElement や innerHTML をフックしたり、オレオレメソッドを追加することが可能になっています。

appendChildをフックしてみる

var p = document.createElement("p").constructor.prototype.constructor.prototype;
var hook = {};
hook.appendChild = p.appendChild; // keep original method
p.appendChild = function(a) {
  var rv = hook.appendChild.call(this, a);
  alert("call appendChild");
  return rv;
};

innerHTMLをフックしてみる

var p = document.createElement("p").constructor.prototype.constructor.prototype;
var hook = {};
hook.innerHTML = p.__lookupSetter__("innerHTML"); // keep original setter
p.__defineSetter__("innerHTML", function(a) {
  hook.innerHTML.call(this, a);
  alert("call innerHTML");
});

最初は、innerHTML を上手くフックできず、色々と試した覚えがあります。

Operaの罠(HTMLInputElement.replace って何に使うの?)

Operaで動かないコード

<html><head><title>opara</title></head><body>
<input type="button" value="replace" onclick="replace()" />
<script>function replace() { alert("click"); }</script>
</body></html>

input 要素に replace プロパティ(値は"")があるためTypeErrorになる。window.replace() と明示すればOperaでも動く。

<input type="button" value="replace" onclick="window.replace()" />

時々これを踏んでしまうので書き残します。

IEの罠(テキストをドラッグするとノードが増える)

IE6,IE7でノードが挿入されたタイミングと属性変更のタイミングを知るために CSS::expression で以下のようにしました。
新ノードが追加されると DOMNodeInserted(this) がコールされます。behavior = "none" とすることで、DOMNodeInserted() が再度呼ばれないようにケアもしています。

document.createStyleSheet().cssText = "*{behavior:expression(DOMNodeInserted(this))}";
function DOMNodeInserted(elm) {
  elm.style.behavior = "none"; // disable CSS::expression
  elm.attachEvent("onpropertychange", DOMAttrModified);
  alert("DOMNodeInserted");
};
function DOMAttrModified() {
  alert("DOMAttrModified");
};

ところが、テキストを選択 + 選択範囲をドラッグすると、マウスが1px動くたびに DOMAttrModified が際限なく呼ばれてしまいます。
どうやらIEでは見えないノードが自動で追加され、何らかの属性値がマウスの動きにあわせて改変され続けるようです。
この問題は、DOMNodeInserted の先頭にガードを追加することで解決しました。

function DOMNodeInserted(elm) {
  if (elm.style.behavior === "none") { return; } // guard: text selection + drag
  elm.style.behavior = "none"; // disable CSS::expression
  ...
}

IE8には uniqueID と uniqueNumber がある

IEの各ノードには uniqueID と uniqueNumber なるプロパティが存在し、ユニークな文字列と数値が格納されています。

  • 正確には初めてアクセスしたときに裏でユニークな値が代入され、その値が返される
  • uniqueNumberはIE8から

CSSセレクタで「ノードの重複を取り除くため各ノードにIDを付与する」という処理があるのですが、uniqueID を使おうとすると、10msオーダーで遅くなってしまうため、node.uniqueID ではなく node.uuid (独自プロパティ) を各ノードに追加することで重複を回避しています。
ただ、IE以外はこれで良いとしても、なにかモヤモヤしたものがずっと残っていました。

あるとき、「もしかして uniqueID は動的に生成されており(stringに見えるよう偽装されたfunctionで)、一番最初に全てのノードの uniqueID にタッチしておけば、uuid は不要になり、セレクタの速度もより速くなるのでは?」と発想し、試行してみたところ、期待を裏切らない結果になりました。

  • さらに速く
  • より少ないメモリで動作するように
window.onload = function() {
  var n = document.getElementsByTagName("*"), i = 0, iz = n.length;
  for (; i < iz; ++i) {
    n[i].uniqueID;
  }
}

普通に見ればただの空ループですが、uupaa-selector.js の実装を見てもらった上で上記の記事を読むと、このループに練り込められた何かが理解できると思います。

  • 最初からシンプルな答えに辿り付けた訳ではない。刀を磨いていたら最後にシンプルなものが残っただけ。

追記: MSDNに言質がありました(最近追加された気がする)
When you apply this property to the document object, the browser automatically generates a new ID that you can assign to an element's id property.
A new ID is generated and assigned to the element the first time the property is retrieved. Every subsequent access to the property on the same element returns the same ID.

おわりに

最近の記事だけを読んでいると、CSSセレクタの実装は、なぜそうなっているのか分かりづらい部分が多いと思います。

  • 変数名が極端に短すぎて理解しづらく素人くさい。
  • わざわざループ展開してる。関数にまとめればすっきりするのに。
  • 直接引数で文字列を書けるのに、文字列をわざわざ変数にしてる。記述が分離してて見づらいし、変数名のコード量が無駄。
こう書いてるけど、
var CSS_xxx = /regexp/;
x.match(CSS_xxx);

これで十分でしょう?
x.match(/regexp/)
  • functionの中でわざわざグローバル変数の別名を作っている。そのまま使えばいいのに。
var _globalVars = 1;
function hoge() {
  var _g = _globalVars;
  for () {
    if (_g) {...}
  }
}
  • switch 〜 case の書き方が不自然。
こう書いてるけど、
CSS_ICPA = { "#": 1, ".": 2, ":": 3,  "[": 4 };
switch (CSS_ICPA[x.charAt(0)]) {
case 1:
case 2:

これで十分でしょう?
switch (x.charAt(0)) {
case "#":
case ".":
}
  • if (xxx) { bbb(); } ではなく、 xxx && bbb(); とか見慣れない記述が多くて読み辛い。
  • if (a === void 0) {} ってなに? if (typeof a == "undefined") {} のほうが自然だよね。

これらのうちいくつかは既に説明済みなので省きます。