HTML と XHTML で同じ XPath を使う2008年12月11日 22時43分

通常、XPath を書くときは //p のようにすることが多いと思いますが、これには名前空間の指定が含まれていないため、XHTML 文書 (MIME タイプが application/xhtml+xml で提供されている文書) では使えません。これに対するアプローチとしては、//h:p のようにあらかじめ XPath 式に名前空間の指定を含めておき、リゾルバによる名前空間接頭辞の解決時に HTML と XHTML とで処理を分けるというのが一般的でした。「XPathNSResolver のクロスブラウザとか」や「document.contentType == "application/xhtml+xml"なページでの$X」で扱っている方法です。

とはいえ、いちいち名前空間接頭辞を指定するのは面倒くさいですし、同じ名前空間に対する接頭辞が人によって違うのも不便です。XPath 式の中で要素名と思われる部分は限定されるのだから、正規表現による置換で接頭辞を追加できないかと考えていました。しかし、ここでネックになるのが演算子です。XPath の演算子には英字からなるものがあり、たとえば式 div div div div div において、1、3、5 番目の div は div 要素に対する名前テストですが、2、4 番目の div は除算演算子となります。このような場合に、要素名に相当する箇所にだけマッチする正規表現を作るのは非常に難しいことです。

そこで、正規表現で要素名のみを抜き出すのをあきらめ、XPath 式を構成するすべてのトークンを順に見ていき、直前のトークンの情報を参考にして現在のトークンが要素名か否かを判断することにしました。この方法で XPath 式中の要素名に名前空間接頭辞を追加するコードは以下のようになります。

function addDefaultPrefix(xpath, prefix) {
  const tokenPattern = /([A-Za-z_\u00c0-\ufffd][\w\-.\u00b7-\ufffd]*|\*)\s*(::?|\()?|(".*?"|'.*?'|\d+(?:\.\d*)?|\.(?:\.|\d+)?|[\)\]])|(\/\/?|!=|[<>]=?|[\(\[|,=+-])|([@$])/g;
  const TERM = 1, OPERATOR = 2, MODIFIER = 3;
  var tokenType = OPERATOR;
  prefix += ':';
  function replacer(token, identifier, suffix, term, operator, modifier) {
    if (suffix) {
      tokenType = (suffix == ':' || (suffix == '::' &&
                   (identifier == 'attribute' || identifier == 'namespace')))
                  ? MODIFIER : OPERATOR;
    } else if (identifier) {
      if (tokenType == OPERATOR && identifier != '*')
        token = prefix + token;
      tokenType = (tokenType == TERM) ? OPERATOR : TERM;
    } else {
      tokenType = term ? TERM : operator ? OPERATOR : MODIFIER;
    }
    return token;
  }
  return xpath.replace(tokenPattern, replacer);
}
addDefaultPrefix("div div div div div", "h");
// => "h:div div h:div div h:div"

addDefaultPrefix("//div[not(@id)][p]", "h");
// => "//h:div[not(@id)][h:p]"

この中で、トークンを切り出すための正規表現を、Perl 正規表現の x オプションをイメージして整形すると次のようになります。XML 1.0 第 5 版で名前として使える文字列はすべて識別子として受け入れられるようにしました。

/ # identifier
  ( [A-Za-z_\u00c0-\ufffd] [\w\-.\u00b7-\ufffd]* | \* )
      # suffix
  \s* ( ::? | \( )?
  # term
| ( ".*?" | '.*?' | \d+ (?: \.\d* )? | \. (?: \. | \d+ )? | [\)\]] )
  # operator
| ( \/\/? | != | [<>]=? | [\(\[|,=+-] )
  # modifier
| ( [@$] ) /g

この関数を実際に使用するには文書が XHTML であることを判別しなければなりません。ここでは、もう少し一般的に考え、XPath 式を評価する際のコンテキストノードがデフォルト名前空間を持っていれば、XPath 式中の接頭辞を持たない要素名はそのデフォルト名前空間に属するものとして扱うことにします。デフォルト名前空間は lookupNamespaceURI メソッドの引数に null または空文字列を渡せば取得できますが、Safari では null を、Opera では空文字列を渡さないとうまく動かないので、引数が null の場合と空文字列の場合を併記しています。

function $X(xpath, context) {
  context = context || document;
  var expr   = createXPathExpression(xpath, context);
  var result = expr.evaluate(context, XPathResult.ANY_TYPE, null);
  switch (result.resultType) {
  case XPathResult.NUMBER_TYPE:  return result.numberValue;
  case XPathResult.STRING_TYPE:  return result.stringValue;
  case XPathResult.BOOLEAN_TYPE: return result.booleanValue;
  default:
    result = expr.evaluate(context, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    var nodes  = [];
    var length = result.snapshotLength;
    for (var i = 0; i < length; i++)
      nodes.push(result.snapshotItem(i));
    return nodes;
  }
}

function createXPathExpression(xpath, context) {
  context = context || document;
  var doc       = context.ownerDocument || context;
  var resolver  = doc.createNSResolver(context.documentElement || context);
  var defaultNS = context.lookupNamespaceURI(null) ||
                  context.lookupNamespaceURI("");
  if (defaultNS) {
    const defaultPrefix = "__default__";
    xpath = addDefaultPrefix(xpath, defaultPrefix);
    var defaultResolver = resolver;
    resolver = function (prefix) {
      return (prefix == defaultPrefix)
             ? defaultNS : defaultResolver.lookupNamespaceURI(prefix);
    };
  }
  return doc.createExpression(xpath, resolver);
}

以下がサンプルです。文書が text/html、application/xhtml+xml、text/xml のいずれの MIME タイプで提供されていても、同じ XPath 式を使えることが確認できるでしょう。

これを使えば、XPath を使うアプリケーションを XHTML へ対応させるのが簡単になるかと思います。実際に、AutoPagerize を XHTML に対応させるパッチを書いてみました。これには、XHTML への対応に加え、現在のページと 2 ページ目以降で URI の階層が異なるとき、相対 URI の解決に失敗する問題の修正も含まれています。このパッチを AutoPagerize に適用した上で、次の SITEINFO を追加すれば、MIME タイプ application/xhtml+xml で提供されている「水無月ばけらのえび日記」でも AutoPagerize が動作します。

    {
        url:          'http://bakera\\.jp/ebi(?:[/?#]|$)',
        nextLink:     '//link[@rel="prev"]',
        pageElement:  'id("main-contents")',
        exampleUrl:   'http://bakera.jp/ebi',
    },

誰が AutoPagerize を提供すべきか2008年11月25日 13時00分

はてなブックマークがリニューアルしました。新しいはてなブックマークの個人ページにはページ自動ロード機能、通称 AutoPagerize 機能 (そう呼んでいる人がどれだけいるかは知りませんが) がついています。さて、AutoPagerize のような一般の Web ページにも適用しうる機能は誰が提供すべきでしょうか。ページの製作者でしょうか? ユーザー側が (ブラウザの拡張機能も含む) ユーザースクリプトとして導入すべきでしょうか? はたまたブラウザ側の仕事でしょうか? Twitter 上でそのことに関するやり取りがあったので少しまとめてみました。

hotchpotch
bbeta ってデフォルトで AutoPagerize ついてるんだ。変なボタン押すと有効になるっぽい(haihai sakura sakura) (2008-11-10 11:50)
os0x
はてなブックマークβ の AutoPagerize SITEINFO どうしたものかね。個人的にはサイト側で用意されている機能を優先したい。 (2008-11-10 18:10)
サイト側で(Greasemonkey の)AutoPagerize をブロックできる仕組みがあればいいのかな。 (2008-11-10 18:31)
nanto_vi
@os0x AutoPagerizeに限らずユーザースクリプトと製作者スクリプトの競合を解決する仕組みが作れればいいんですけどね。 (2008-11-10 18:36)
製作者スクリプトは<meta name="UserSideAutoPagerize" content="no" />を生成する、AutoPagerizeはそのようなmeta要素があったら停止する、というのを思いついた。 (2008-11-10 18:42)
http://nanto.asablo.jp/blog/2008/08/02/3668606 の延長線上の話で。 (2008-11-10 18:42)
meta要素生成だとユーザースクリプトを強制することができないけど。 (2008-11-10 18:44)
os0x
metaタグで拒否Script名を書くと、それにマッチするユーザースクリプトは実行されないとか。名前変えればいいだけだけど、それぐらいで丁度良さそうな気がする。 (2008-11-10 18:43)
@nanto_vi 見事に同じことを考えてました。 (2008-11-10 18:53)
AutoPagerizeについていえば、製作者スクリプトとして提供されたほうが望ましいと思う。わざわざ SITEINFO なんて用意しないといけないのは、製作者がやることを強引にユーザースクリプトで対応しようとしているからだし。 (2008-11-10 18:56)
kanasan
@nanto_vi autopagerizeをsite側で切れるようになると、ニュースサイトは全滅しそう。page viewが稼げないから...。 (2008-11-10 18:54)
nanto_vi
@kanasan あー、「ユーザースクリプトのAutoPagerizeを使わない」と「製作者スクリプトのAutoPagerizeを使う」を同一視してました。この二つは別物でしたね。 (2008-11-10 23:21)
hotchpotch
AutoPagerize が入っていればデフォルトで有効にしたいなー。 (2008-11-10 20:18)
snj14
@os0x AutoPagerizeはユーザスクリプトでやるべきじゃないってのには同意.ただ,製作者スクリプトでやるってのには同意できないです.あれはブラウザが標準でやるべきことだと思います.ブラウザの標準機能でナビゲートされることを期待されていたrel-nextなんかと一緒. (2008-11-11 00:21)
os0x
@snj14 ブラウザが提供するなら、OperaのFast Forwardぐらいになると思います。 http://labs.gmo.jp/blog/ku/2007/10/operafast_forward.html AutoPagerizeみたいなページ壊しちゃう機能をブラウザが実装ってのはあんまり現実的じゃないかと。 (2008-11-11 00:28)
snj14
@os0x Siteinfoなんて物を用意しないといけないのは別の問題で,ページを機械的に操作されることを予期せずにフリーダムにマークアップしても「人間が見えればオッケー」な風土を作った誰かが悪くて,rel-nextを考えたような人の描いた通りになってれば問題無かったと思います. (2008-11-11 00:29)
@os0x ページなんて,コンテンツを表示するためのガワでしかないのだから,ぶっ壊れようがなにしようが,コンテンツさえ見えれば(それが本来の目的なのだから)全然問題無いと思ってます. (2008-11-11 00:35)
@os0x あ,ちょっと違いました.本来の目的はコンテンツを見ることによって情報を得るだとか,楽しむだとか,そういうことです.そのための手段としてコンテンツを見ます.ページはそのコンテンツを表示するための手段です.本来の目的により近い部分が達成されれば下位はどうでも良いかと. (2008-11-11 00:42)
os0x
@snj14 その発想はユーザー側の発想で、コンテンツ提供者の意見も取り入れないといけないブラウザの立場ではないと思います。確かにユーザーの目的は常に情報を得ることですが、提供者側の目的は様々です。 (2008-11-11 00:46)
snj14
@os0x コンテンツ提供者はユーザでもあります.メモをWeb上に取っておいて読み返す人なんて典型的です.ユーザの目的を多く達成することを優先するほうが理に叶ってると思いますが.逆に,ページレイアウトが崩れて困る人ってどういう目的の人でしょう? (2008-11-11 00:54)
os0x
@snj14 すみません、コンテンツ提供者と製作者(サイトオーナー)が混ざってました。ここでは製作者側、特に個人ではなく企業とかでサイトを作っているケースを考えています。 (2008-11-11 01:35)
@snj14 Twitterとかはてなとかlivedoorとか各種ニュースサイトとかのことです。 (2008-11-11 01:37)
@snj14 ただでさえ日ごろクロスブラウザには手を焼いているのに、ブラウザの機能でページが崩れることを容認できるとは思えません。製作者は可能な限り自分達の管理下に置きたいと考えます。実際、はてなもそうなんだと思います。 (2008-11-11 01:47)
指定パスのDocumentを取ってきて表示するとかone linerで出来て、AutoPagerみたいなのも数行で実現できるようなブラウザってのが現実的な落とし所なのかなぁ。 (2008-11-11 02:44)
いや、それが標準化されてなきゃ意味ないか。 (2008-11-11 02:45)
hotchpotch
@os0x レイアウト崩れる、デザインの見せ方以外にも、スターなどのJS周りの付与なんか(APがfilter でできるようなJSの実行)もしたいというのがありますね。 (2008-11-11 08:48)
snj14
@os0x 企業の目的ってのは,ページを壊さないことでなくて,利益(利潤?)を得ることの筈.利益を得る為の手段としての広告が,とかページビューが,とかの話ならば,広告やらの「ユーザから嫌われるもの」を守ってまでユーザの体験が正当に進化しないのは企業にしたって本意でないと思います. (2008-11-11 22:34)
@os0x 客でもあるユーザに嫌われる役を買ってまで今の広告スタイルを守らなくても,コンテンツと広告の距離がもっと近く(今のgoogleのやつよりも.)なれば,adblock使ってまで対策を取るようなことにはならないし,その方が広告効果も見込めそうな気がしてならないのですが. (2008-11-11 22:44)
@os0x そして,ユーザの興味を持つコンテンツに近い広告効果のあるナニカを作ったとして,そのユーザに届けるためには,ユーザに関する機械可読なデータ(その人が何に興味を持つのか,等)が必要不可欠であると思います.なので,ユーザに機械可読なアウトプットをさせる必要があります. (2008-11-12 00:34)
@os0x ユーザにアウトプットさせるためには(そしてそれを持続可能にするためには),軽い,面倒臭くない,AutoPagerizeのような,TomblooのようなUIが必要だと思います. (2008-11-12 00:43)
@os0x なので,今すぐ利益があるとは言えませんが,AutoPagerizeのようなUIをブラウザ側で実装することではてな等が未来永劫不利益を被る,ということにはならないとおもいますし,むしろ,適当にそれっぽく表示している今より良くなると思ってます. (2008-11-12 00:53)
たぶんだけど,Siteinfoもちゃんと考えてSemanticWebの方向に持っていくと,Trustとかの層まで来て,人の繋がりの情報を持った方が精度があがっていって.スパムがどうとか考えなくてすむようになるんじゃないカナー (2008-11-12 01:18)
os0x
@snj14 ブラウザ側AutoPagerizeはWEB製作を難しくしてしまいます。セマンティック・ウェブは特に、ですね。メリットがあることは理解できるけど、正直なところ現実的な方法とは思えないんです。 (2008-11-12 01:25)
@snj14 逆に、いわゆるデータマイニング的な手法のほうが現実的で、将来性があるんだろうと思います。 (2008-11-12 01:28)
@snj14 実際、履歴データだけでもそれなりのレコメンデーションは実現できています。情報が足りなくて精度が低いなら、幾つかの選択肢を用意すればユーザーが選んでくれます。 (2008-11-12 01:35)
(つい、話がレコメンデーションに。。) (2008-11-12 01:36)
@snj14 それで、ブラウザ側で実装されちゃうとコッチ(製作者)でコントロールできなく(し難く)なるので、歓迎できないって感じです。だから、AutoPagerizeが簡単に実現できるブラウザなら大歓迎です。 (2008-11-12 01:39)
bulkneets
@os0x それが当然になったなら、それに合わせてビジネスモデルとか作るモンじゃないの (2008-11-12 01:47)
os0x
@bulkneets そうなったらそうでしょうね。ただ、現状でも自前で実装してAutoPagerが活きるビジネスモデルは出来ますね。身も蓋もないけど、広告も挿入されるとか。 (2008-11-12 01:58)
なんだかんだ言って、ブラウザが実装するAutoPagerizeのイメージが湧いてきた。少し手を動かしてみるか。 (2008-11-12 02:21)
まだ出来は良くないとはいえ Google Chrome に Greasemonkey が乗ったから、Safari が追従することが期待できるし、ブラウザの標準機能になる可能性も見えてきた。 (2008-11-14 02:37)

AutoPagerize に関していうなら、個人的にはこれはユーザースクリプト、またはデフォルトで無効にされたブラウザ組み込みの機能 (私はこの二つにあまり違いを感じません) として提供されるべきだと思っています。文書内容をじかにいじるものをブラウザが提供し、さらにそれをフォルトで有効にするというのには少々抵抗を感じます。

「少々抵抗を感じます」という言葉で濁しましたが、これは私の中で考えがまとまっていない部分です。突き詰めれば単に今までブラウザにそのような機能が提供されてこなかったからという慣れの問題のような気がします。ですからこれから Web に触れる人には関係ありませんし、私自身そういう機能がデフォルトで提供されれば案外すんなりと受け入れるのではないかとも思います。かつての iCab はデフォルトで accesskey 属性の値を要素の右肩に表示していました (iCab が独自エンジンから WebKit に鞍替えした現在、この機能が提供されているのかは知りません)。私はこの表示方法を受け入れ、素晴しいと思ったのですから。

SITEINFO をどうするかという問題はありますが、上のやり取りで言及されていた Opera の Fast Forward の仕組みや本文抽出モジュール (ちなみにそこで紹介されている Perl モジュールは第 1 回はてなインターンの成果が基になっています) などを組み合わせれば、意外と多くのページで期待通り動くのではないかと思います。HTML 5 が正しく運用されればそんなヒューリスティックに頼る必要も減るでしょうし。

しかしながら、はてなブックマークのように製作者側でプラスアルファの機能をつけたいということもあります。ユーザースクリプトと製作者スクリプトでは、CSS のように、重要指定付きユーザースクリプト、製作者スクリプト、通常のユーザースクリプトと排他的な優先順位を設定する仕組みが整っていればいいと言いましたが、CSS のようにという観点からすれば排他的な部分の粒度はもっと細かくして全体としては多層的、すなわちユーザースクリプトの動作を製作者スクリプトがフックできるような仕組みが望ましいのではないかと思います (現状の AutoPagerize でも、DOMNodeInserted イベントを監視し、それらしき要素が挿入されたらといった具合にフックすることは可能でしょうが)。

Kanasan.JS JavaScript 第 5 版読書会 #62008年11月21日 21時40分

Kanasan.JS JavaScript 第 5 版読書会 #6 に行ってきました (当日のチャットログ参加者のブログ一覧)。今回は 14 章「ブラウザウィンドウの制御」から 16 章「CSS とダイナミック HTML」の途中まで読み進めました。

タイマーの仕様 (14.1、p. 282)

setTimeout などのタイマーはブラウザが独自に実装したもので、標準化された仕様は存在しません。HTML 5 の草案には一応含まれているものの、独立した仕様に移すことが示唆されています。かといって、その場合の受け皿となるであろう W3C WebApps WG にもはっきりとした動きは見られず (Apple の人から高精度タイマーの提案が出ていましたが)、タイマーの標準化がどうなるのかよくわかりません。

Navigator オブジェクト (14.3.3, p. 289)

Navigator オブジェクトを基にブラウザを判別して動作を変えるのはよくないという話。ただし、例外としてあるブラウザの特定バージョンのみに存在するバグに対処する場合が挙げられています。あるメソッドが存在しないというのならまだいいのですが、メソッドは存在するけど動作が異なるとなったら苦労しますからね。

window.open の引数 (14.4.1, p. 292)

window.open メソッドは第3引数にウィンドウの表示情報などを渡せますが、これは "width=400,height=350" といった文字列を使って指定します。これに対して、文字列だといかにも格好悪いから、オブジェクトリテラルを使って { width: 400, height: 350 } のように指定できないのかという声がありました。しかし、window.open メソッドが導入されたのが JavaScript 1.0 (NN 2、このころは JavaScript の言語機能とブラウザのオブジェクトモデルが分離されていませんでした)、対してオブジェクトリテラルの導入が JavaScript 1.2 (NN 4) と、歴史的経緯で文字列指定になっていると思われます。

window のメソッドと document のメソッド (14.4.2, p. 293)

window オブジェクトにも document オブジェクトにもそれぞれ open、close メソッドが存在しますが、それらの動作は異なります。open() とだけ書いた場合、外部ファイルまたは script 要素内にスクリプトを書いていれば、グローバルオブジェクトである window オブジェクトの open メソッドと解釈されます。しかし、<input onclick="open()" /> のように HTML のイベントハンドラ属性にスクリプトを書いた場合、スコープチェーンに document オブジェクトが含まれ、document オブジェクトの open メソッドと解釈されることがあります。このような場合には、明示的に window オブジェクトを指定しなくてはいけません。

window.onerror (14.7, p. 300)

window.onerror に関数を設定すると、すべてのプログラムの最上位に try-catch 文があるかのように振舞います。例外が発生したがそれより上位に try-catch 文がないときに、onerror に設定した関数が呼び出されるのです。とはいえこれは JavaScript に try-catch 文がなかったころの遺物であり、try-catch 文が広く使える今日では利用する機会はほとんどないでしょう。

frames プロパティ (14.8.2, p.303)

その文書が含むフレームの集合を得るために frames プロパティがありますが、実は Firefox、IE、Safari では window オブジェクトの frames プロパティはその window オブジェクト自身を指します。なので、window.frameName として参照できるものは window.frames.frameName としても参照できますし、window[i]i 番目のフレームを取得可能。さらには frames.setTimeout() なんてこともできます。

要素への名前付け (15.3.1, p. 317)

HTML には name 属性が存在しますが、その意味は要素によって異なります。

  1. 要素の名前を表すもの (a 要素、img 要素、form 要素など)
  2. フォームコントロールの名前を表すもの (input 要素、textarea 要素、select 要素、button 要素)

このうち、1 については今や name 属性を使う必要はなく、id 属性を使うべきです。

getElementsByClassNameメソッド (15.6, p. 335)

Firefox 3、Safari 3.1、Opera 9.5 以降では getElementsByClassName メソッドが実装されていますが、これは引数として指定されたクラス名をすべて含むクラス名を持つ要素を取得します。引数として指定されたクラス名と一致するクラス名を持つ要素のみではありません。getElementsByClassName("a b") とすれば、class 属性の値が "a b"、"b a"、または "a b c" などである要素は取得できますが、class 属性の値が "a" である要素は取得できません。

JavaScript のソートアルゴリズム

JavaScript のソートアルゴリズムは実装依存であり、安定性も要求されません。SpiderMonkey の場合、バージョン 1.7 以下 (Firefox 2 以下) ではヒープソートを使っていましたが、やはり安定なソートのほうがいいとのことでバージョン 1.8 (Firefox 3) ではマージソートに変更されました。JavaScriptCore では場合によって選択ソート、マージソート、クイックソートのいずれかが、V8 ではクイックソートと挿入ソートの組み合わせが使われるようです。

unicode-bidi CSS プロパティ (16.1, p. 365)

unicode-bidi CSS プロパティ (bidi は biderectional の略か?) は、Unicode の双方向書字アルゴリズムに関する制御文字の扱いを指定するためのプロパティです。双方向書字アルゴリズムに関しては HTML にも bdo 要素があります。

「控えめな JavaScript」? (16.2.2, p. 375)

CSS を使った影つきテキストの生成が「控えめな JavaScript」の例として出てきますが、これは同じ内容を持つ要素を CSS の位置指定で重ねるというもので、同書で控えめな JavaScript の 3 番目の目標として挙げられていたアクセシビリティを低下させないということに反する気がします。音声ブラウザの例を取り上げなくとも、コピーアンドペーストの際などに意図した結果が得られなくなりますし。

雑感

思い起こせば 1 年前、第 1 回 Kanasan.JS に参加したのをきっかけにさまざまな人と出会え、関西での活動の場を広げられたと思います。その Kanasan.JS を立ち上げた Kanasan さんが関東に引っ越すとのことで、今回を最後に運営スタッフから抜けることになってしまいました。他の運営スタッフの方々が後を引き継ぐとのことですが、全員で同じものを読み進めていくという勉強会のスタイルを確立し、ここまで続けてこられたのはやはり Kanasan さんの力によるところが大きいでしょう。これまで本当にありがとうございました。

選択範囲のリンクを取得する2008年10月18日 20時05分

Web ページの選択範囲に含まれるリンクを取得する方法として、Piro さんによる DOM 2 Range の compareBoundaryPoints メソッドを使ったやり方があります。これはリンクを探すのに DOM Core の機能を使って文書ツリーをたどっていますが、今現在ノードを探すといわれて真っ先に思いつくのは XPath でしょう。そこで、XPath を使って選択範囲のリンクを取得する方法を考えてみました。もちろん、選択範囲を扱う以上 DOM 2 Range も利用します。

  1. 基本的な考え方
  2. Range オブジェクトの取得
  3. 選択範囲の始点より前にあるリンクの数の取得
  4. 選択範囲の終点より前にあるリンクの取得
  5. 選択されているようには見えないリンクの除外
  6. まとめ

基本的なアイデアは、選択範囲の終点より前にあるリンクで、選択範囲の始点より前にはないものが求めるリンクというものです。たとえば次に示した状態で、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 オブジェクトを取得します。

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 オブジェクトについての解説もあります。

Kanasan.JS Prototype.js CodeReading #62008年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 さんに深く感謝します。