Kanasan.JS Prototype.js CodeReading #6 ― 2008年10月16日 21時42分
Kanasan.JS Prototype.js CodeReading #6 に行ってきました (当日のチャットログ、参加者のブログ記事一覧)。今回は Prototype.js 1.6.0.2 の 3375 行目、フォームに関する部分から最後まで読みきりました。
Form.Methods.findFirstElement (3449 行目)
フォームコントロールを取得するメソッドですが、tabindex 属性でタブ移動順が指定されていた場合は、その順序がもっとも早いコントロールを返そうとしているようです。しかしその際、tabindex 属性の値が 0 のものを値が 1 のものより優先しているようで、実際のタブ移動順と得られる結果が一致しません。HTML 4 では tabindex 属性の値 0 は、tabindex 属性を指定しないのと同じ効果を持つはずなのですが。
フォーカスと選択範囲
多くのブラウザでは、フォームコントロールのフォーカスと選択範囲は別個に管理されています。ですので、フォーカスのないコントロールの選択範囲を変更することも可能です。もっとも、フォーカスがなければ選択範囲を変更してもそれを視認できないかもしれませんが。
Form.Element.Serializers (3569 行目)
シリアライザという名前からフォームコントロールの値を取得する関数群かと思いきや、その名に反してゲッタとしての役割も負っています。各関数は引数 element と value をとり、value が省略されていればゲッタ、指定されていればセッタとして振舞うようです。
Form.Element.Serializers.select (3590 行目)
select 要素には type プロパティがあり、その値は multiple 属性が指定されていれば "select-multiple"、指定されていなければ "select-one" となります。Prototype.js ではこのプロパティを見て、ゲッタの処理を selectOne メソッドと selectMany メソッドに振り分けています。
Event.cache (3733 行目)
通常、ある要素に登録されているイベントリスナを取得することはできません。Prototype.js では addEventListener メソッドなどでリスナを要素に登録する際、cache オブジェクトにもそのリスナを格納しておくことで、要素からリスナを取得できるようにしています。cache オブジェクトの構造は次のようになっています。
Event.cache = {
"ある要素のイベント ID": {
"click": [
click イベントに登録されたあるリスナのラッパ関数,
click イベントに登録された別のリスナのラッパ関数,
...
],
"イベント名": [
そのイベントに登録されたリスナのラッパ関数,
...
],
...
},
"別の要素のイベント ID": { ... },
...
};
ここで、要素のイベント ID とは Prototype.js が要素ごとに生成する ID です。また、ユーザーから渡されたリスナをそのまま登録するのではなく、イベントオブジェクトの動作を拡張するラッパ関数を作成して (3866 行目、Event.createWrapper)、そのラッパ関数を登録しています。
Event.Methods.pointer (3787 行目)、Event.Methods.stop (3799 行目)
pointer メソッドはマウスポインタの位置を取得するメソッドで、IE 以外では pageX / pageY プロパティの値をそのまま使っています。IEでは文書の表示モードが標準モードでも互換モードでもうまくいくよう、document.documentElement と document.body のプロパティを併用しています。stop メソッドはイベントの伝播をとめるとともに、そのイベントのデフォルトアクションをキャンセルします。
イベントの伝播やデフォルトアクション、文書の座標系については WEB+DB PRESS Vol. 46 の連載「JavaScript + ブラウザ探検」第 3 回、「イベントの攻略……DOM Events、IE イベントモデル、ボックスモデル」でも解説されているので、ぜひ購入しましょう。
destroyCache (3896 行目)
前述の cache オブジェクトに格納したラッパ関数を削除します。IE 6 のメモリリーク対策に使われているようです。しかし、これは cache オブジェクトからラッパ関数への参照を切るだけで、リスナの登録解除を行っていません。リスナが登録する要素への参照を含んでいた場合、リスナからその要素へ、その要素からラッパ関数へ、ラッパ関数からリスナへの参照は残ったままとなり、循環参照によるメモリリークを防げない気がします。
Event.fire (3954 行目)
クリックやキー入力といったユーザーの操作によらず、スクリプト自身からイベントを発生させるためのメソッドです。IE 以外のブラウザでは任意の名前のイベントを発生させられるのですが、IE はサポートするイベント以外の名前を受け付けないので、IE の独自拡張イベントであり、Web 上でほとんど使用されていないと思われる dataavailable イベントを代わりに使っています。
ページ構築時のイベント (3997 行目)
画像などの読み込みが完了していなくても、文書自体の読み込みが完了すれば Prototype.js 独自の dom:loaded イベントが発生するようになっています。Firefox などでサポートされている DOMContentLoaded イベントと同様のものです。また、このイベントが発生した後には document.loaded プロパティの値が true になります。
document.observe('dom:loaded', function (event) {
alert('文書の構築が完了しました。');
});
非推奨の機能 (4034 行目)
4034 行目以下に書かれている機能は非推奨のものです。Prototype.js の API ドキュメントを見れば非推奨の機能にはその旨が記されています。
Prototype.js 1.6.0.2 から 1.6.0.3 への変更点
余った時間でつい先日リリースされた Prototype.js 1.6.0.3 の変更点を見ていきました。ついでに Prototype.js のチェンジログや Emacs の diff-mode の存在も教えてもらいました。
__proto__ プロパティへのアクセス
従来 obj.__proto__
としてアクセスしていた部分が obj['__proto__']
に書き換えられました。Caja という JavaScript のサブセットを用いた環境では、アンダースコアで終わるメンバ名はプライベートメンバのものと定められており、それとの互換性を取るためだそうです。
iterator 関数の this の束縛
以前から each メソッドなどの第 2 引数に this となる値を渡すことはできましたが、1.6.0.2 ではそれを使わずあらかじめ bind メソッドで this の値を指定していました。
// Prototype.js 1.6.0.2
iterator = iterator ? iterator.bind(context) : Prototype.K;
iterator(value, index);
しかし、1.6.0.3 では call メソッドを使って this の値を指定するようにしています。
// Prototype.js 1.6.0.3
iterator = iterator || Prototype.K;
iterator.call(context, value, index);
bind メソッドを使うと無名関数が生成され、呼び出しもその無名関数を経由することになります。call メソッドを直接使うことで無名関数をなくし、高速化を図ったのではないかと思います。
Selectors API を使った要素取得
CSS セレクタを用いた要素取得で、Selectors API が使えるときはそれを使うようになりました。使えなければ従来どおり XPath または DOM Core の機能を用いて要素を取得します。
Safari での bfcache の無効化
Safari では bfcache にまつわる不具合があるようで、unload イベントにダミーのイベントリスナを登録することで bfcache を無効化しようとしています。bfcache とは blazingly fast cache (驚くほど速いキャッシュ) あるいは back-forward cache の略で、Web ページをキャッシュする際、そのソース文字列だけでなく、そこから構築された DOM オブジェクトなどの状態もキャッシュする機構のことです。これにより、「戻る」「進む」動作によるページ遷移が大幅に高速化されます。この仕組みはもともと Opera で実装されており、Firefox 1.5 が同様の仕組みを採用した際に bfcache と名付けました。
雑感
約 1 年間続いた Prototype.js コードリーディングもついに最終回を迎えました。私は途中 1 回欠席したものの、ここまで読み続けられ、Prototype.js の考え方や大量のバッドノウハウに触れられたのは貴重な経験となりました。そして何より、Kanasan.JS を通してさまざまな方と知り合え、JavaScript に限らず行動範囲が大きく広がりました。このような場を支えてくださったスタッフ、参加者の方々、そしてそのきっかけを与えてくれた Kanasan さんに深く感謝します。
選択範囲のリンクを取得する ― 2008年10月18日 20時05分
Web ページの選択範囲に含まれるリンクを取得する方法として、Piro さんによる DOM 2 Range の compareBoundaryPoints メソッドを使ったやり方があります。これはリンクを探すのに DOM Core の機能を使って文書ツリーをたどっていますが、今現在ノードを探すといわれて真っ先に思いつくのは XPath でしょう。そこで、XPath を使って選択範囲のリンクを取得する方法を考えてみました。もちろん、選択範囲を扱う以上 DOM 2 Range も利用します。
基本的な考え方
基本的なアイデアは、選択範囲の終点より前にあるリンクで、選択範囲の始点より前にはないものが求めるリンクというものです。たとえば次に示した状態で、link1 と link2 はいずれも選択範囲終点より前にありますが、link1 は選択範囲始点よりも前にあるので求める結果には入りません。
<p>
<a id="link1" href="...">link1</a>
【選択範囲始点】<a id="link2" href="...">link2</a>【選択範囲終点】
</p>
また、リンク同士は入れ子にならない (a 要素を入れ子にしてはいけない) というのも重要です。もしもこれが入れ子を許す場合はどうなるでしょうか。たとえば、選択範囲に含まれる (選択範囲を一部でも含む) div 要素の取得を考えてみます。
<div id="div1">
<div id="div2">...</div>
【選択範囲始点】<div id="div3">...</div>【選択範囲終点】
</div>
上の状態で、求める div 要素は div1 と div3 となり、文書順で見た場合要素が飛び飛びになっています。入れ子にならないのならこのようなことは起こらず、求める結果は文書順に並んだ対象要素の列中で連続した集合となります。つまり、選択範囲始点より前にあるリンクを具体的に求める必要はなく、その総数がわかればいいのです。
Range オブジェクトの取得
では実際にソースコードを書いていきましょう。ここでは選択範囲に含まれるリンクを配列に収めて返すことにします。まずは選択範囲に対応する Range オブジェクトを取得します。
var links = [];
var selection = getSelection();
if (!selection.rangeCount) return links;
var range = selection.getRangeAt(0);
if (range.collapsed) return links;
links が結果となる配列です。選択範囲が存在しなかったり、選択範囲の幅が 0 だったりした場合には空の配列を返します。
選択範囲の始点より前にあるリンクの数の取得
var startNode = range.startContainer;
var startExpr = 'count(preceding::*[@href])';
if (startNode.nodeType == Node.ELEMENT_NODE) {
if (range.startOffset < startNode.childNodes.length)
startNode = startNode.childNodes[range.startOffset];
else
startExpr += ' + count(descendant::*[@href])';
}
var startResult = document.evaluate(startExpr, startNode, null,
XPathResult.NUMBER_TYPE, null);
いよいよ XPath を使う場面です。preceding 軸は、コンテキストノードの祖先ノードを除く、コンテキストノードより文書順で前にあるノードを選択します。選択範囲始点に位置するノードをコンテキストノードとすることで、選択範囲より前にあるリンクの数を得られます。ただし、ある要素の終了タグ直前に始点がある場合 (else 節に入るパターン) は、その要素の子孫ノードからもリンクを探します。なお、ここでは href 属性を持つ要素なら何でもリンクとして扱います。
選択範囲の終点より前にあるリンクの取得
var endNode = range.endContainer;
var endExpr = 'ancestor::*[@href] | preceding::*[@href]';
if (endNode.nodeType == Node.ELEMENT_NODE) {
if (range.endOffset < endNode.childNodes.length)
endNode = endNode.childNodes[range.endOffset];
else
endExpr += ' | descendant-or-self::*[@href]';
}
var endResult = document.evaluate(endExpr, endNode, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
大体の流れは始点より前にあるリンク数の取得と同じですが、数ではなく文書順に並んだノード集合を取得します。縦線 "|" を使ってノードセットの和集合を取っています。
選択されているようには見えないリンクの除外
選択範囲の始点がリンクの終了タグ直前にある、あるいは終点がリンクの開始タグ直後にある場合、そのリンクは選択されていないように見えます。たとえば次の状態で、二つのリンクはともに機械的には選択範囲に含まれると判断されますが、人の目から見れば選択されているようには見えません。
<a href="...">link1【選択範囲始点】</a>
...
<a href="...">【選択範囲終点】link2</a>
このようなリンクを除外し、人の目から見ても選択されているリンクのみを返すコードが次になります。
var i = startResult.numberValue;
var length = endResult.snapshotLength;
var contentRange = document.createRange();
if (i < length) {
var lastNode = endResult.snapshotItem(i);
while (lastNode.lastChild)
lastNode = lastNode.lastChild;
contentRange.selectNodeContents(lastNode);
contentRange.setStart(range.startContainer, range.startOffset);
if (contentRange.collapsed) i++;
}
if (i < length) {
var firstNode = endResult.snapshotItem(length - 1);
while (firstNode.firstChild)
firstNode = firstNode.firstChild;
contentRange.selectNodeContents(firstNode);
contentRange.setEnd(range.endContainer, range.endOffset);
if (contentRange.collapsed) length--;
}
for (; i < length; i++)
links.push(endResult.snapshotItem(i));
return links;
今、次のような状態であると考えましょう。
<a href="..."><em>foo</em>【選択範囲始点】</a>
ここで、最初の if 文内における lastNode はテキストノード "foo" となり、selectNodeContents メソッド呼び出し直後の contentRange の境界点は次のようになります。
<a href="..."><em>【contentRange 始点】foo【contentRange 終点】</em>【選択範囲始点】</a>
こうなれば後は contentRange の終点と選択範囲の始点との位置を比較するだけです。contentRange 終点が選択範囲始点と同位置かそれより前にあれば、選択されているようには見えないリンクであると判断できます。通常、境界点の位置の比較には compareBoundaryPoints メソッドを使いますが、Safari 3.1 以下ではこのメソッドの引数に渡す定数 Range.START_TO_END と Range.END_TO_START の意味が逆転しているというバグがあるので、単純にこのメソッドを使ってもうまくいきません。
そこで、ここでは contentRange の始点を選択範囲の始点と同位置に設定しています。Range オブジェクトは終点の後に始点が来ることはないと保証されているため、終点より後に始点を設定しようとすると終点も始点と同位置に再設定されるのです。よって、contentRange の終点が選択範囲の始点より前にあった場合、conentRange の始点と終点が一致することになります。後は collapsed プロパティを見て、contentRange が折りたたまれている、すなわち始点と終点が同位置にあれば、選択されているようには見えないリンクと判断できるのです。
選択範囲の終点に一番近いリンクも、同様にして選択されているようには見えないリンクかどうか判断できます。これで選択範囲終点より前にあるリンクのうち、どの部分を抜き出せばいいのかわかったので、それを配列に収めて返してやります。
まとめ
以上をひとつの関数にまとめると次のようになります。
function getLinksInFirstSelection() {
var links = [];
var selection = getSelection();
if (!selection.rangeCount) return links;
var range = selection.getRangeAt(0);
if (range.collapsed) return links;
var startNode = range.startContainer;
var startExpr = 'count(preceding::*[@href])';
if (startNode.nodeType == Node.ELEMENT_NODE) {
if (range.startOffset < startNode.childNodes.length)
startNode = startNode.childNodes[range.startOffset];
else
startExpr += ' + count(descendant::*[@href])';
}
var startResult = document.evaluate(startExpr, startNode, null,
XPathResult.NUMBER_TYPE, null);
var endNode = range.endContainer;
var endExpr = 'ancestor::*[@href] | preceding::*[@href]';
if (endNode.nodeType == Node.ELEMENT_NODE) {
if (range.endOffset < endNode.childNodes.length)
endNode = endNode.childNodes[range.endOffset];
else
endExpr += ' | descendant-or-self::*[@href]';
}
var endResult = document.evaluate(endExpr, endNode, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
var i = startResult.numberValue;
var length = endResult.snapshotLength;
var contentRange = document.createRange();
if (i < length) {
var lastNode = endResult.snapshotItem(i);
while (lastNode.lastChild)
lastNode = lastNode.lastChild;
contentRange.selectNodeContents(lastNode);
contentRange.setStart(range.startContainer, range.startOffset);
if (contentRange.collapsed) i++;
}
if (i < length) {
var firstNode = endResult.snapshotItem(length - 1);
while (firstNode.firstChild)
firstNode = firstNode.firstChild;
contentRange.selectNodeContents(firstNode);
contentRange.setEnd(range.endContainer, range.endOffset);
if (contentRange.collapsed) length--;
}
for (; i < length; i++)
links.push(endResult.snapshotItem(i));
return links;
}
これは Piro さんの作ったものに比べ、選択範囲の始点が子要素を持つリンク内にあった場合も適切な結果を返す、Safari 3.1 以下でも期待通り動くといった利点があります。また、選択範囲のリンクを取得するサンプルで試してみると、実行速度もこちらのほうが速いようです。
なお、これはもともと WEB+DB PRESS Vol. 47 の連載「JavaScript + ブラウザ探検」第 4 回「DOM 2 Style & DOM 2 Range」にサンプルコードとして掲載するつもりだったのを、分量の都合で連載記事から削ったものです。Range オブジェクトの基本的な操作と使い方の例については 10 月 24 日発売の WEB+DB PRESS Vol. 47 をご覧ください。DOM 2 Range、DOM 3 XPath を実装していない IE でこのコードは動きませんが、IE が実装している TextRange オブジェクトについての解説もあります。
最近のコメント