SpiderMonkey の判別とブラウザ判別2009年02月01日 18時30分

1 行でブラウザ判別を行うスクリプト (IE 用の日本語紹介記事Firefox、Safari 用の日本語紹介記事) が注目を集めています。それによると Firefox の判別は次の 15 文字で行えるそうです。

//Firefox detector 2/3 by DoctorDan
FF=/a/[-1]=='a'

いったいなぜこれで Firefox が判別できるのか、少し探ってみました。

  1. 何を判別しているのか
  2. SpiderMonkey のソースコードを探る
  3. より短い SpiderMonkey の判別法
  4. 「ブラウザ判別」としての不備
  5. まとめ
  6. 参考文献

何を判別しているのか

実際に探っていく前にひとつ注意しなければいけないことがあります。それは、上記のコードが Firefox を判別するものではないということです。このコードには Web ブラウザ固有のオブジェクトモデルに関する情報が含まれておらず、正確には Firefox 等で使われている JavaScript エンジン「SpiderMonkey」を判別するためのものであるというべきでしょう。

SpiderMonkey のソースコードを探る

上記コードを見るに、SpiderMonkey では正規表現オブジェクトのインデックス -1 が source プロパティに対応するようです。しかしそうなる原因を探るにはどうすればよいのでしょうか。とりあえず -1 プロパティを取得するという JavaScript コードに対して、SpiderMonkey が生成する中間コードを見てみることにします。中間コードは、単体の SpiderMonkey に付属する JavaScript shell で使える dis 関数で確認できます。なお、都合によりここで使用する SpiderMonkey 及び参照する SpiderMonkey のソースコードは、Firefox 3.0 系列に含まれるものと同等のバージョン (SpiderMonkey 1.8) のものとします。

$ js
js> dis(function () /a/[-1]);
flags: LAMBDA EXPR_CLOSURE INTERPRETED
main:
00000:  regexp null
00003:  int8 -1
00005:  getelem
00006:  return
00007:  stop

Source notes:
  0:     5 [   5] pcbase   offset 5

これを見ると、数値的プロパティの取得には getelem 命令が使われることがわかります。そこで、SpiderMoneky のソースコードから、getelem 命令を処理している部分を見ると次のようになっています (一部改変)。

/* jsinterp.c */
4534: BEGIN_CASE(JSOP_GETELEM)
4535:   /* Open-coded ELEMENT_OP optimized for strings and dense arrays. */
4536:   lval = FETCH_OPND(-2);
4537:   rval = FETCH_OPND(-1);
4538:   if (JSVAL_IS_STRING(lval) && JSVAL_IS_INT(rval)) {
            /* 文字列の数値的プロパティに関する処理 */
4548:   }
4549: 
4550:   VALUE_TO_OBJECT(cx, -2, lval, obj);
4551:   if (JSVAL_IS_INT(rval)) {
4552:       if (OBJ_IS_DENSE_ARRAY(cx, obj)) {
                /* 密な配列の数値的プロパティに関する処理 */
4566:       }
4567:       id = INT_JSVAL_TO_JSID(rval);
4568:   } else {
4569:       if (!js_InternNonIntElementId(cx, obj, rval, &id))
4570:           goto error;
4571:   }
4572: 
4573:   if (!OBJ_GET_PROPERTY(cx, obj, id, &rval))
4574:       goto error;
4575: end_getelem:
4576:   regs.sp--;
4577:   STORE_OPND(-1, rval);
4578: END_CASE(JSOP_GETELEM)

ひとつのブロックが BEGIN で始まって END で終わっているような気がしますが、これは C 言語のソースコードです。今の場合、lval には正規表現オブジェクト (へのポインタ)、rval には整数値 -1 が含まれることになります。lval、rval はともに jsval 型ですが、プロパティの操作に当たっては JSObject へのポインタ型と jsid 型の値を使うので、4550 行目で正規表現オブジェクト (へのポインタ) を JSObject へのポインタ型に、4567 行目で整数値を jsid 型に変換しています。

4573 行目のいざプロパティを取得する段では、取得結果を収める変数として rval を再利用しています。正規表現オブジェクトの場合、OBJ_GET_PROPERTY マクロによるプロパティの取得は js_GetProperty 関数 (JS_GetProperty 関数とは別物。大文字の JS から始まる関数は外部 API) を経て js_GetPropertyHelper 関数の呼び出しにつながります。

/* jsobj.c */
3644: JSBool
3645: js_GetPropertyHelper(JSContext *cx, JSObject *obj, jsid id, jsval *vp,
3646:                      JSPropCacheEntry **entryp)
3647: {
3648:     uint32 type;
3649:     int protoIndex;
3650:     JSObject *obj2;
3651:     JSProperty *prop;
3652:     JSScopeProperty *sprop;
3653: 
3654:     /* Convert string indices to integers if appropriate. */
3655:     CHECK_FOR_STRING_INDEX(id);
3656:     JS_COUNT_OPERATION(cx, JSOW_GET_PROPERTY);
3657: 
3658:     type = OBJ_SCOPE(obj)->shape;
3659:     protoIndex = js_LookupPropertyWithFlags(cx, obj, id, 0, &obj2, &prop);
3660:     if (protoIndex < 0)
3661:         return JS_FALSE;
3662:     if (!prop) {
3663:         jsbytecode *pc;
3664: 
3665:         *vp = JSVAL_VOID;
3666: 
3667:         if (!OBJ_GET_CLASS(cx, obj)->getProperty(cx, obj, ID_TO_VALUE(id), vp))
3668:             return JS_FALSE;
3669: 
              /* プロパティキャッシュに関する処理 */
              /* 最終的にプロパティが存在しなかった場合の処理 */
3716:         return JS_TRUE;
3717:     }
3718: 
          /* すでにプロパティが存在する場合の処理 */
          /* プロパティキャッシュに関する処理 */
3733:     return JS_TRUE;
3734: }

3659 行目でプロパティの探索を行いますが、-1 という名前のプロパティは本来正規表現オブジェクトに存在しないので見つからず、変数 prop の値は NULL になります。すると、3667 行目で、正規表現オブジェクトに共通するプロパティ取得時用フック関数 (regexp_getProperty 関数) が、整数 -1 (を表す jsval 型の値) とともに呼び出されるのです。

/* jsregexp.c */
3595: enum regexp_tinyid {
3596:     REGEXP_SOURCE       = -1,
3597:     REGEXP_GLOBAL       = -2,
3598:     REGEXP_IGNORE_CASE  = -3,
3599:     REGEXP_LAST_INDEX   = -4,
3600:     REGEXP_MULTILINE    = -5,
3601:     REGEXP_STICKY       = -6
3602: };

3607: static JSPropertySpec regexp_props[] = {
3608:     {"source",     REGEXP_SOURCE,      RO_REGEXP_PROP_ATTRS,0,0},
3609:     {"global",     REGEXP_GLOBAL,      RO_REGEXP_PROP_ATTRS,0,0},
3610:     {"ignoreCase", REGEXP_IGNORE_CASE, RO_REGEXP_PROP_ATTRS,0,0},
3611:     {"lastIndex",  REGEXP_LAST_INDEX,  REGEXP_PROP_ATTRS,0,0},
3612:     {"multiline",  REGEXP_MULTILINE,   RO_REGEXP_PROP_ATTRS,0,0},
3613:     {"sticky",     REGEXP_STICKY,      RO_REGEXP_PROP_ATTRS,0,0},
3614:     {0,0,0,0,0}
3615: };
3616: 
3617: static JSBool
3618: regexp_getProperty(JSContext *cx, JSObject *obj, jsval id, jsval *vp)
3619: {
3620:     jsint slot;
3621:     JSRegExp *re;
3622: 
          /* 前処理 */
3630:     slot = JSVAL_TO_INT(id);
3631:     if (slot == REGEXP_LAST_INDEX)
3632:         return JS_GetReservedSlot(cx, obj, 0, vp);
3633: 
3634:     JS_LOCK_OBJ(cx, obj);
3635:     re = (JSRegExp *) JS_GetPrivate(cx, obj);
3636:     if (re) {
3637:         switch (slot) {
3638:           case REGEXP_SOURCE:
3639:             *vp = STRING_TO_JSVAL(re->source);
3640:             break;
3641:           case REGEXP_GLOBAL:
3642:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_GLOB) != 0);
3643:             break;
3644:           case REGEXP_IGNORE_CASE:
3645:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_FOLD) != 0);
3646:             break;
3647:           case REGEXP_MULTILINE:
3648:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_MULTILINE) != 0);
3649:             break;
3650:           case REGEXP_STICKY:
3651:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_STICKY) != 0);
3652:             break;
3653:         }
3654:     }
3655:     JS_UNLOCK_OBJ(cx, obj);
3656:     return JS_TRUE;
3657: }

regexp_getProperty 関数内では、3630 行目で変数 slot に id の値である -1 が収められます。3638 行目でそれが定数 REGEXP_SOURCE と一致することで、-1 というプロパティ名に対し source プロパティと同じ値が返ってくるのです。

source に対する -1 のような、プロパティを表す整数を tiny id と呼びます。SpiderMonkey では、プロパティごとにゲッタ / セッタ用フック関数を設定するのではなく、特定のオブジェクトの全プロパティに共通するゲッタ / セッタ用フック関数を使うと、JavaScript コードからの tiny id によるアクセスがプロパティ名によるアクセスと同一視されてしまうそうです。

より短い SpiderMonkey の判別法

外部から tiny id を使ってプロパティにアクセスできるのは正規表現オブジェクトに限りません。文字列の場合は length プロパティに対する tiny id として -1 が割り当てられ、それにより外部から参照できます。これを使えば、結果が真偽値となるような SpiderMonkey の判別は次の 12 文字で可能です。

SM=""[-1]==0

「ブラウザ判別」としての不備

最初に紹介したページに掲載されている判別法は、いずれもブラウザの JavaScript エンジンの差異を用いたものです。これらは JavaScript エンジンの内部実装に大きく依存するものであり、エンジンのちょっとした変更で期待した動作をしなくなる可能性も否定できません。実際、そこで Firefox 3 の判別用と紹介されている以下のコードの結果は、Firefox 3.1 では false となります。SpiderMonkey は現在、関数オブジェクトでは古びて非効率的な方法を捨て、プロパティごとにゲッタ用フック関数を指定するようにしたからです。

//Firefox 3 by me:-
FF3=(function x(){})[-5]=='x'

また、一般にブラウザによってスクリプトの動作が違うといったとき、その原因がスクリプトエンジンであることはあまりありません。多くの場合はレイアウトエンジン (ここでは DOM 等オブジェクトモデルの提供もレイアウトエンジンの役割に含めます) の動作の違いによるものです。Safari と Google Chrome はともにレイアウトエンジンに WebCore を用いていますが、スクリプトエンジンはそれぞれ JavaScriptCore と V8 と異なっています。WebCore に起因する Safari の問題を回避するのに JavaScriptCore の判別を使っていれば、Google Chrome が同じ問題を持っていたときに対処できません。

特定ブラウザでの問題に対処するときは、プロパティやメソッドの存在確認や動作確認により処理を切り分けるべきです。それで判定できないときはブラウザ判別を行うことになりますが、そこで調べるのはブラウザではなくブラウザのレイアウトエンジンです。navigator.userAgent プロパティから調べるのなら、"Firefox" や "Safari" ではなく "Gecko/" や "WebKit" といった文字列の有無で判断しましょう。いずれにしても、問題がレイアウトエンジンにあるのなら、上記ページで紹介されているようなスクリプトエンジンに基づくブラウザ判別を使ってはいけません

まとめ

  • SpiderMonkey では、プロパティを表す tiny id によりプロパティにアクセスできることがあります。
  • 問題解決のためのブラウザ判別にはプロパティ / メソッドの存在確認や動作確認を用いるか、それが無理ならレイアウトエンジンを調べましょう。
  • スクリプトエンジンの判別はレイアウトエンジンの判別になりません。

参考文献

フォームコントロールのデフォルト値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 さんの力によるところが大きいでしょう。これまで本当にありがとうございました。