フォームコントロールのデフォルト値2009年01月19日 23時25分

WebKit のコントロール値キャッシュ対策

日本野望の会-Yabooo.org > Safari/Webkitのおせっかいキャッシュとその対策。」にコメントしたはずなのですが、いつの間にかコメントが消えているようなので推敲の上再掲。

上記のページで問題にしているのは、Safari において bfcache を無効にしていても、動的に追加したフォームコントロールの値がキャッシュされた値に書き換えられてしまうことです。これに対し、文書中のコントロールをすべて記憶し、文書のアンロード時にそれらの名前 (name 属性の値) を変更することで解決を図っています。

しかし、書き換えられるのは一時的な値のみで、コントロールのデフォルト値 (フォームをリセットしたときに設定される値) まで変化するわけではありません。ならば、コントロールが文書中に挿入されたときに、その値をデフォルト値に設定してやればすむことではないでしょうか。この考えに基づいた解決策が以下になります。

// bfcache を無効化しているときにこの問題は発生する。
window.onunload = window.onunload || function () {};

if (navigator.userAgent.indexOf("WebKit") != -1) {
  // 動的 (DOMContentLoaded イベント発生後) に
  // 追加されるコントロールのみを処理の対象とする。
  document.addEventListener("DOMContentLoaded", function () {
    var states = {
      radio:    "checked",
      checkbox: "checked",
      option:   "selected",
      OPTION:   "selected"
    };
    var defaults = {
      value:    "defaultValue",
      checked:  "defaultChecked",
      selected: "defaultSelected"
    };
    function resetState(control) {
      // option 要素ノードに type プロパティは存在しないので、
      // その場合は nodeName プロパティを参照する。
      var state = states[control.type || control.nodeName] || "value";
      control[state] = control[defaults[state]];
    }
    document.body.addEventListener("DOMNodeInserted", function (event) {
      var target = event.target;
      // target はテキストノードの場合もあるので、
      // tagName プロパティではなく nodeName プロパティを用いる。
      var name = target.nodeName.toLowerCase();
      if (name == "input" || name == "textarea" || name == "option") {
        resetState(target);
      } else if (target.nodeType == target.ELEMENT_NODE) {
        var controls = target.querySelectorAll("input, textarea, option");
        Array.prototype.forEach.call(controls, resetState);
      }
    }, false);
  }, false);
}

DOMContentLoaded イベントに対応しているのは Safari 3.1 以上なので、同じく Safari 3.1 以上で利用可能な Selectors API の querySelectorAll メソッドを使っています。Safari 3.0 にも対応させたいときは、DOMContentLoaded の使用を避け、querySelectorAll の代わりに getElementsByTagName なり XPath なりを使う必要があります。配列拡張の forEach メソッドに関しては Safari 3.0 でも使えるので問題ありません。

注意点として、以下のように value プロパティだけ設定したコントロールを追加すると、その値が空になってしまうことがあります。defaultValue プロパティも同時に設定するか、setAttribute メソッドで value 属性の値を設定しなければなりません。

// NG
var input = document.createElement("input");
input.type = "text";
input.value = "foo";
container.appendChild(input);
// OK
var input = document.createElement("input");
input.type = "text";
input.defaultValue = input.value = "foo";
container.appendChild(input);

コントロール値に関するプロパティ

上で触れたように、コントロールの値または状態を取得するプロパティには、現在の値を表すものとデフォルトの値を表すものがあります。これらのプロパティは DOM 2 HTML で以下のように定義されています。

value プロパティ (input 要素)
type 属性の値が "text"、"file"、"password" のいずれかならば、コントロールの現在の値を表す。このとき、このプロパティの値を変更しても value 属性の値は変更されない。
type 属性の値が "button"、"hidden"、"submit"、"reset"、"image"、"checkbox"、"radio" のいずれかならば、value 属性の値を表す。
defaultValue プロパティ (input 要素)
type 属性の値が "text"、"file"、"password" のいずれかならば、value 属性の値を表す。
value プロパティ (textarea 要素)
コントロールの現在の値を表す。このプロパティの値を変更しても要素の内容は変更されない。
defaultValue プロパティ (textarea 要素)
要素の内容を表す。
checked プロパティ (input 要素)
type 属性の値が "radio" または "checkbox" ならば、コントロールの現在の状態を表す。このプロパティの値を変更しても checked 属性の存在は変更されない。
defaultChecked プロパティ (input 要素)
type 属性の値が "radio" または "checkbox" ならば、checked 属性の存在を表す。
selected プロパティ (option 要素)
コントロールの現在の状態を表す。このプロパティの値を変更しても selected 属性の存在は変更されない。
defaultSelected プロパティ (option 要素)
selected 属性の存在を表す。

各ブラウザの実装は以下のようになっています。下の表で、A は要素の属性、P は現在値を表すプロパティ、DP はデフォルト値を表すプロパティを意味します。type が text、設定が A、取得が P の行は、type 属性の値が "text" である input 要素に対して、setAttribute メソッドを用いて value 属性の値を "foo" に設定し、その要素を文書中に挿入した後、value プロパティを用いて取得した値を表しています。type が option、設定が DP、取得が A の行は、option 要素に対して、defaultSelected プロパティの値を true に設定し、その要素を文書中に挿入した後、getAttribute メソッドを用いて取得した selected 属性の値です。(IE 8 RC1 はバージョン 8.0.6001.18344 時点のもの。いずれも標準モードの場合。)

フォームコントロールの値または状態の設定・取得結果一覧
type 設定 取得 IE 7 IE 8 RC1 Firefox 3 Safari 3.1 Opera 9.6
text A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" null null null
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "" "" ""
DP A "" null "foo" "foo" "foo"
P "" "" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
password A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" null null null
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "" "" ""
DP A "" null "foo" "foo" "foo"
P "" "" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
checkbox A A false "checked" "checked" "checked" "checked"
P false true true true true
DP false true true true true
P A false "" null null null
P false true true true true
DP false false false false false
DP A true "checked" "" "" "true"
P true true true true true
DP true true true true true
radio A A false "checked" "checked" "checked" "checked"
P false true true true true
DP false true true true true
P A false "" null null null
P false true true true true
DP false false false false false
DP A true "checked" "" "" "true"
P true true true true true
DP true true true true true
submit A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
DP A "クエリ送信" "Submit Query" "foo" "foo" "foo"
P "クエリ送信" "Submit Query" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
reset A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
DP A "リセット" "Reset" "foo" "foo" "foo"
P "リセット" "Reset" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
button A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
DP A "" null "foo" "foo" "foo"
P "" "" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
image A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
P A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
DP A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
file A A "" null "foo" "foo" "foo"
P "" "" "" "" ""
DP "" "" "foo" "foo" "foo"
P A "" null 例外発生 null null
P "" "" 例外発生 "" ""
DP "" "" 例外発生 "" ""
DP A "" null "foo" "foo" "foo"
P "" "" "" "" ""
DP "foo" "foo" "foo" "foo" "foo"
hidden A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
DP A "" null "foo" "foo" "foo"
P "" "" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
textarea A A "foo" "foo" "foo" "foo" "foo"
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "foo" "foo" "foo"
P A "foo" "foo" "" "" ""
P "foo" "foo" "foo" "foo" "foo"
DP "" "" "" "" ""
DP A "" "" "foo" "foo" "foo"
P "" "" "foo" "foo" "foo"
DP "foo" "foo" "foo" "foo" "foo"
option A A true "selected" "selected" "selected" "selected"
P true true true true true
DP false false true true true
P A true "selected" null null null
P true true true true true
DP false false false false false
DP A true "selected" "" "" "true"
P true true true true true
DP true true true true true

IE 7 以下での getAttribute / setAttribute メソッドはプロパティの取得・設定と基本的に同じです。ファイル選択コントロールの value プロパティに値を代入しても単に無視されるだけですが、Firefox では例外が発生します。このことと、デフォルトの状態を表すプロパティを true に設定したときの論理型属性の値、そして IE を除けば、各ブラウザとも同じ結果になっています。

まとめ

フォームコントロールのデフォルトの値または状態を用いると、スクリプトが簡単に書けるようになることがあります。

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