選択範囲のリンクを取得する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 さんに深く感謝します。

第 13 回 Admintech.jp 勉強会2008年09月30日 21時12分

ブラウザに関する話が聞けるということで第 13 回 Admintech.jp 勉強会に行ってきました。他の勉強会 (といっても私の場合は Kanasan.JS がほとんどですが) と比べると、Admintech.jp 自体が ITプロ(システム管理者やネットワークエンジニア)向けのコミュニティということもあってか、学生が少ない印象を受けました。また、参加者のノートパソコンを見渡すと、会場 (マイクロソフト大阪オフィスセミナールーム) のせいではないでしょうが近年ではまれに見る Mac 率の低さだったようです。

IE に依存した Web アプリケーションセキュリティ

はせがわようすけさんによる、IE 以外のブラウザでは問題ないが、IE では XSS につながるパターンの紹介でした。発表資料ははせがわさんの記事から参照できます。

文書の文字符号化方式を UTF-7 や US-ASCII と誤認識させることによる XSS の話が出ていましたが、HTML 4 で文字符号化方式の判定がどのように規定されているかについては「文字符号化方式判定の優先順位」@水無月ばけらのえび日記に解説があります。「常に valid な HTML を出力するようにしておけば XSS の 9 割は防げる」と聞いたこともありますし、HTTP、HTML といった標準を抑えておくことはセキュリティの面からも重要なのではないかと思います。発表にもあった IE の Content-Type 無視のように、標準に従っていてもブラウザ側の動作によって問題がおきることもありますが。

IE 8 では HTML 文字列からスクリプトなどを取り除く toStaticHTML メソッドが導入される予定ですが、IE 8 Beta 2 時点ではダイナミックプロパティ (CSS expression) による XSS を防ぎきれないといった問題があるそうです。

「レジストリに登録されていない MIME タイプ」という話がありましたが、このレジストリとは HKCR\Mime\Database\Content Type のことでしょうか? だとすると私の XP SP3 環境では application/javascript が登録されておらず、application/rss+xml は登録されているのですが、安全だ安全でないというにはまた別の基準があるのでしょうか?

WebKit の可能性

Google の及川卓也さんによる発表ですが、Google Chrome には特に触れず、WebKit の特徴と Windows でのアプリケーションへの組み込み方が主な内容でした。IE はコンポーネントが簡単に利用できますし、Firefox も Mozilla ActiveX ControlGeckoFX が公開されていますが、WebKit にはいまだそういう手軽に組み込む仕組みがほとんど存在しないそうです。

JavaScriptCore が、もともとは AST をたどって実行していたのが、SquirrelFish の導入で中間言語を用いるようになり、さらに SquirrelFish Extreme で機械語を出力するようになるなど、JavaScript エンジンがどんどんハードウェアに依存するレイヤーに近づいているというのが印象に残りました。

ここが変わった! IE 8 Beta 2

Microsoft の五寶匡郎さんによる IE 8 の紹介です。管理者向けということで IEAK の説明もありました。

IE 8 の文書表示モード

IE 8 ではブラウザモードと文書モードという二つの機構が導入されます。ブラウザモードによってユーザーエージェント文字列とバージョンベクタ (条件付きコメントで用いられる) が変化し、文書モードによって CSS や DOM の解釈が変化するようです。ブラウザモードには次の三つがあります。

ブラウザモード ユーザーエージェント文字列 バージョンベクタ
IE 8 モード Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; ...) 8.0
IE 8 互換表示モード Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0; ...) 7.0
IE 7 モード Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; ...) 7.0

文書モードには IE 8 標準モード、IE 7 標準モード、IE 5 モード (Quirks モード) の三つがあります。X-UA-Compatible の指定によるブラウザモード、文書モードの変化は次のようになると私は理解しました。

X-UA-Compatible ブラウザモード 文書モード
指定なし IE 8 モード 文書型宣言によって IE 8 標準モードまたは IE 5 モード
IE=8 IE 8 モード IE 8 標準モード
IE=7 IE 8 互換表示モード IE 7 標準モード
IE=5 IE 8 モード IE 5 モード
IE=EmulateIE8 IE 8 モード 文書型宣言によって IE 8 標準モードまたは IE 5 モード
IE=EmulateIE7 IE 8 互換表示モード 文書型宣言によって IE 7 標準モードまたは IE 5 モード

ただし、以下のサンプルで実際に試してみると、開発者ツールから明示的に選択しない限り、IE 8 互換表示モードでもユーザーエージェント文字列は MSIE 8.0 となります。hxxk.jp - DOCTYPE スイッチについてのまとめと一覧表も参考にしてください。なお、ブラウザモードのうち IE 7 モードは、開発者ツールから選択した場合のみ実行できます。

  1. X-UA-Compatible の指定なし
  2. X-UA-Compatible の指定なし、文書型宣言なし
  3. X-UA-Compatible: IE=8
  4. X-UA-Compatible: IE=8、文書型宣言なし
  5. X-UA-Compatible: IE=7
  6. X-UA-Compatible: IE=7、文書型宣言なし
  7. X-UA-Compatible: IE=5
  8. X-UA-Compatible: IE=5、文書型宣言なし
  9. X-UA-Compatible: IE=EmulateIE8
  10. X-UA-Compatible: IE=EmulateIE8、文書型宣言なし
  11. X-UA-Compatible: IE=EmulateIE7
  12. X-UA-Compatible: IE=EmulateIE7、文書型宣言なし

また、文書モードが IE 7 標準モードや IE 5 モードだからといって、動作が完全に IE 7 や IE 5 と同じになるわけではありません。ActiveX コントロールのサポート状況や JScript エンジンなどは IE 8 と同じままです。

その他 IE 8 に関する話

「まず標準準拠で作って Firefox や Safari で確認し、それから IE でおかしいところを CSS ハックで直す」という開発者の言葉に衝撃を受け、開発者の支持がないブラウザは廃れるとの思いで IE 8 での標準準拠路線を進めてこられたそうです。利用者のニーズと開発者のニーズ、過去の IE との互換性とほかのブラウザとの相互運用性など、異なる方向性を両立させるべく苦労を重ねているようでした。

インストールやアンインストールではまった人が多かったようです。インストール/アンインストール時の問題点についてはリリースノートに書かれているので、ちゃんとリリースノートを読みましょうとのことでした。

ツールバー上で右クリックして "Customize" → "Show Stop and Refresh Buttons before Address Bar" (英語版の場合) を選択すると、中止ボタンと更新ボタンが戻る / 進むボタンの右側、アドレスバーの左側に移動します。これで IE 6 以前のボタン配置になれている人も安心ですね。

Microsoft TechNetMicrosoft Connect などを通じてどんどんフィードバックしてほしいとのことでした。

ユーザーエージェント文字列に Trident/4.0 というのが含まれていますが、この 4.0 という数字はどこから来たのでしょう? Wikipedia の Trident の項によると IE 8 に使われているのは Trident VI だそうですが。

ある機能を追加するかどうかは、それがどのように使われるかを熟考して決めるようです。SVG も開発者からの要望は強く、いったんは IE 7 の追加機能リストに入ったものの、それが本当に必要かという議論になり見送られたとか。逆に言うと、Microsoft は想定外の使われ方をすること自体を想定していないのかなと思いました。

Mozilla からみて IE 8 に期待すること ~ 標準化とセキュリティー

もじら組の himorin さんによる IE 8 への要望。せっかくアドレスバーでドメイン名を強調表示するようになったのに、ステータスバーのリンク先表示でドメイン名が強調表示されないのは片手落ちだといった意見を出していました。各種プラグインを一括アップデートできるようにしてほしいという話から議論が膨らんで、Windows Update に各種ブラウザの更新通知も含めるべきだとかいった話題に。

勉強会のレポート集

ところで、こういった勉強会のレポートをまとめて読みたいと思うのは私だけでしょうか。Kanasan.JS の場合は参加者のブログ記事一覧に各自が登録していくことになっているので手軽に読めますが、一般にはそうでもありません。独立したページではなくても、主催者のブログにトラックバックすることを呼びかけるなどしてもらえるといいなと思います。Admintech.jp に関してははてなブックマークの「admintech.jp」タグを含む新着エントリーを追いかけるという手もありそうですが。第 13 回 Admintech.jp 勉強会アンケート結果の最後にまとめられていました。

ユーザースクリプトと製作者スクリプト2008年08月02日 02時25分

あと、動作の細かな話ですが、オリジナルのAutoPagerizeとかぶってしまう(二重に読み込まれてしまう)ため、あえてa要素のrel属性(rel=”next”)を外して対処しています。

そろそろAutoPagerizeを標準的に導入していったらどうか - Liner Note

同じ機能を提供するユーザースクリプト (Greasemonkey スクリプト、Web ページに変更を加えるブラウザの拡張機能など) と製作者スクリプトがあった場合、その競合をどう解決するかということについて。最初は単にユーザースクリプト (ここでは特に AutoPagerize) を製作者スクリプトから無効化できるようにすればいいのかなと思いましたが、「3:14 - UserAgentの使い方について」を読んで考えを改めました。

ユーザースクリプトはブラウザの機能を補うものであり、ブラウザがもともと持っている機能と同一視できます。ある機能を (一般の Web ページを対象とした) ユーザースクリプトで実現するということは、それをブラウザの機能として、すなわち統一された操作感の下で提供するということです。同じ機能は同じ操作で実現できることを第一とするなら、ユーザースクリプトと製作者スクリプトとではユーザースクリプトのほうを優先させるべきです。

ではそのためにはユーザースクリプト、製作者スクリプトをどのように書けばいいのでしょうか。とりあえず考えてみたのが以下です。(本当に考えただけです。動かしてもいません。) 本題とは関係ありませんが、AutoPagerize のソースコードには文末にセミコロンがつけられていないんですね。

--- autopagerize.original.user.js	2008-08-01 17:16:41.531250000 +0900
+++ autopagerize.user.js	2008-08-02 01:09:41.500000000 +0900
@@ -28,6 +28,7 @@ var CACHE_EXPIRE = 24 * 60 * 60 * 1000
 var BASE_REMAIN_HEIGHT = 400
 var FORCE_TARGET_WINDOW = true
 var USE_COUNTER = true
+var FORCE_AUTO_PAGER = true
 var SITEINFO_IMPORT_URLS = [
     'http://wedata.net/databases/AutoPagerize/items.json',
 ]
@@ -575,7 +576,11 @@ var launchAutoPager = function(list) {
                 }
             }
             else {
-                ap = new AutoPager(list[i])
+                var event = document.createEvent('Event')
+                event.initEvent('AutoPagerizeReady', true, !FORCE_AUTO_PAGER)
+                if (document.dispatchEvent(event) || FORCE_AUTO_PAGER) {
+                    ap = new AutoPager(list[i])
+                }
                 return
             }
         }
// 製作者スクリプト

if (document.addEventListener) {
  document.addEventListener("AutoPagerizeReady", function (event) {
    if (event.cancelable) {
      // イベントがキャンセル可能 (ユーザースクリプト中の
      // FORCE_AUTO_PAGER の値が偽) ならキャンセルする。
      // このとき、製作者スクリプトの MyAutoPager が使われる。
      event.preventDefault();
    } else {
      // イベントがキャンセル可能でないなら
      // MyAutoPager を無効化して AutoPagerize を使う。
      disableMyAutoPager();
    }
  }, false);
  // AutoPagerize を使っていない環境ではそもそも
  // AutoPagerizeReady イベントが発生しないので
  // MyAutoPager がそのまま使われる。
}

initMyAutoPager();

このようにすれば、機能が衝突したときに、ユーザースクリプトを使うか製作者スクリプトを使うかをユーザー側で決定できます。常にユーザースクリプトを優先したいが、製作者スクリプトに無効化機能が公開されていない場合は、そのサイトの製作者スクリプトを無効化するユーザースクリプトを作る必要があるかもしれません (そのようなスクリプトを作るのが難しいことも多いでしょうが)。イベントを使うというアイデアは「Greasemonkeyスクリプトとウインドウ間で安全に通信する - ロックスターになりたい」からいただきました。

ある機能に関するスクリプトにも CSS のように、重要指定付きユーザースクリプト、製作者スクリプト、通常のユーザースクリプトと排他的な優先順位を設定する仕組みが整っていればいいんですけどね。排他的というより多層的といったほうがいいような気がしてきました。

Kanasan.JS JavaScript 第 5 版読書会 #52008年07月25日 11時09分

Kanasan.JS JavaScript 第 5 版読書会 #5 に行ってきました (当日のチャットログ参加者のブログ記事一覧)。今回からはいよいよ第 2 部、クライアントサイドスクリプトということで、13 章全体を読みました。

控えめな JavaScript

JavaScript をどのように使うかの指針として、「控えめな JavaScript (unobtrusive JavaScript)」(WaSP DOM Scripting Task Force の声明文) というキーワードが挙げられています。

  1. HTML マークアップと JavaScript コードを分離する。
  2. 正常に機能を停止する。ブラウザがある機能を持たない、もしくは JavaScript 自体が動作しない場合であっても、コンテンツが利用可能であるようにする。
  3. アクセシビリティを低下させるのではなく、向上させる。

これは非常に重要な指針であり、通常の Web ページでスクリプトを使う際にはぜひ従うべきものだと思います。とはいえ、文書ではなくアプリケーションを提供するときにはスクリプトの使用を必須とせざるを得ない場合もあり、悩むところです。

document.write の挿入位置

document の write メソッドで出力された内容は、多くのブラウザで script 要素の終了直後に挿入されます。仕様ではどうなっているかというと、HTML 4.01 の文書の動的変更に関する箇所 (原文) ではややあいまいながらも script 要素を出力内容で置換するように読み取れます。DOM HTML の write メソッドの定義では読み込み中の文書に対する動作が記されていません。HTML 5 のwrite メソッドの説明ではそのあたりの詳細も定義される予定です。

JavaScript の MIME タイプ

RFC 4329 Scripting Media Types では text/javascript は非推奨となっていますが、IE が application/javascript に対応していないので、当分は text/javascript を使わざるを得ないでしょう (cf. オレオレ言語の MIME タイプ)。

script 要素の defer 属性

スクリプトの実行を後回しにして、先に文書の解析を続行できることを示す属性です。実行を延期されたスクリプトは、実行を延期できないスクリプトに出くわした時点で実行されますとありますが、HTML 4.01 での defer 属性の定義 (原文) にそのようなことは書かれていません。文書の動的変更に関して、すべての SCRIPT 要素は、文書が読み込まれる際の順番で評価されるという文と組み合わせての解釈のようです。HTML 5 の defer 属性の定義では IE の実装に合わせる形で動作が再定義されています。

noscript 要素

「控えめな JavaScript」の考え方に従うなら、noscript 要素を使わなくてもスクリプト無効時の閲覧に支障が出ない文書の作成を目指すべきだと思います。

document.domain

通常、ドメインの異なるページ間ではオブジェクトにアクセスできませんが、アクセスする側される側とも document の domain プロパティに同じ値を設定することで、アクセスできるようになるそうです。ただし、domain プロパティに設定できるのは範囲を広げる方向のみ、つまり、www.example.org のページでは example.org には設定できるけど example.com はだめとのこと。使ったことのないプロパティなので動作もよく知りませんでした。

Same Origin Policy

異なるリソース間でのアクセスで重要となってくる same origin policy、サイ本や竹迫さんのプレゼンでは「同一出身ポリシー」と訳してましたが、私は「同一生成元ポリシー」という訳語を使っています。ちなみにこれを書いている時点でググってみると、「同一出身ポリシー」203 件に対して「同一生成元ポリシー」359 件でした。

プライベート変数の実現方法

KIMOTO さんによる LT。JavaScript で、外部からは見えないが、同じクラス (便宜的にこの語を使います) のインスタンス間では見えるプライベート変数を実現する試みです。コードは次のような感じでした (一部改変)。

var namespace = {};

(function () {
  var seed = 0;
  var p = {};

  namespace.Foo = function (x) {
    var id = "" + seed++;
    this.toString = function () { return id; };

    p[this] = {};
    p[this].x = x;
  };

  namespace.Foo.prototype.getX = function () {
    return p[this].x;
  };

  namespace.Foo.prototype.setX = function (x) {
    p[this].x = x;
  };

  namespace.Foo.prototype.add = function (that) {
    p[this].x += p[that].x;
  };
})();

これなら p は外部から見えないので、アクセサを通さないとメンバ変数にアクセスできないというわけですね。コンストラクタ内でメソッドごとにクロージャを作る方法と違って、メソッドが増えてもインスタンス生成の時間はそれほど変わらないし、ほかのインスタンスのメンバ変数にも直接アクセスできます。この仕組みを考えた後に調べて、これが Perl では inside-out object と呼ばれる手法だと知ったとのことですが、それを自分で考え付いてJavaScriptで実現したというのはすごいです。

Perl の inside-out object との違いとして、JavaScript にはデストラクタがないため、メンバ変数の値がずっと残りっぱなしになってしまうという問題があります。対策としては、自分で後片付け用のメソッドを呼んでやるか、利用者のマシンの性能を信じるかといったところでしょうか。toString メソッドを本来の目的で使えないという声もありましたが、そこは代わりに valueOf メソッドを使って p[+this].x とするのもありかもしれません。

ゆの in ECMAScript 解説

いつまでそのネタを引っ張るんだといわれそうですが、私も LT の時間をもらってゆの in ECMAScript の解説をしてきました。資料は次に挙げますが、これはソースコードそのものの解説というより、ソースを読むに当たっての基礎知識です。

識別子に使える Unicode 文字の「文字」というのは letter であり character ではありません。つまり、記号や空白文字は使えません。正規表現オブジェクトの exec メソッドについては取説 正規表現を参照してください。また、SpiderMonkey では関数を文字列化したときに一部の文字がエスケープされます。

(function () { return "日本語"; }).toString();
// => function () {
// =>     return "\u65E5\u672C\u8A9E";
// => }

以上を踏まえて処理の流れを追っていくと以下のようになります。

  1. ゆの in languageが流行ってるってを評価する。
  2. ゆの.toString() が呼び出される。
  3. グローバル変数 _ を作り、値 "ひだまり" を設定する。
  4. ゆの() を呼び出す。
  5. X.valueOf() が呼び出される。
  6. グローバル変数 _ の値に "ひだまりスケッチ" を設定する。
  7. X / _undefined / "ひだまり" より NaN / NaN となり NaN に評価される。
  8. X.valueOf() が呼び出される。X 自身はもはや valueOf メソッドを持たないので、Object.prototype.valueOfX 上で呼び出され X が返される。
  9. X はプリミティブ値ではないので、X.toString() が呼び出される。
  10. グローバル変数 _ の値に "ひだまりスケッチ×365" を設定する。
  11. NaN / XNaN / undefined より NaN / NaN となり NaN に評価される。
  12. NaN < "来週も見てくださいね"NaN < NaN となり false に評価される。
  13. ゆの.toString() の結果としてゆのを返す。
  14. ゆのはプリミティブ値ではないので、ゆの.valueOf() が呼び出される。
  15. exec メソッドの引数としてゆのを渡したので、ゆの.toString() が呼び出される。ゆの自身はもはや toString メソッドを持たないので、Function.prototype.toStringゆの上で呼び出されゆののソース文字列が返される。
  16. ソース文字列がエスケープされる実装のために、引用符ごと抜き出して eval 関数で目的の文字列に戻す。
  17. グローバル変数そんなことよりヱヴァンゲリヲン新劇場版を作り、値 "ひだまりスケッチ×365 来週も見てくださいね!" を設定する。
  18. ゆの in languageが流行ってるってundefined in { undefined: true } より "undefined" in { "undefined": true } となり true に評価される。
  19. true ? そんなことよりヱヴァンゲリヲン新劇場版 : 破は...そんなことよりヱヴァンゲリヲン新劇場版となり "ひだまりスケッチ×365 来週も見てくださいね!" に評価される。式破は... は評価されない。
  20. プログラム全体の結果として "ひだまりスケッチ×365 来週も見てくださいね!" が返される。

「ゆの in language」という言葉を式中に含めたかったので in 演算子を使ったのですが、in 演算子は真偽値しか返せません。そこで、文字列を返すために条件演算子を使い、in 演算子はその条件式に含めることにしました。破は... (以下省略) の部分は評価されませんが全体としては条件演算子になっており、早くカヲル君が見たいです. ヱヴァンゲリヲン新劇場版はプロパティアクセスとして、序でカヲル君が出てきたときは (以下省略) は関数呼び出しとして解析されます。

条件演算子を使う以上コロンが必要なのですが、タイトルにコロンの入ったアニメ / マンガ / ライトノベルがヱヴァくらいしか思い浮かばなかったのでそれを使いました。後から FF:U があると教えてもらったりJINKI:EXTEND なんてものもあったなと思い出したりしたのですが、両方とも見てないのでそのままです。

雑感

個人的な反省点として、LT の時間を超過しない、発言するときは大きな声でというのが挙げられます。LT に関しては OSC Kansai 2008 閉会式の LT のように全員にわかるタイマーがあるといいのですが、それはスクリーンが二つ以上ないと難しいので、せめて秒読みをつけたほうがいいかもしれません。時間が不定なら減算式ではなく加算式の秒読みでもあるとしまりが出るかなと思います。気軽に話せるという点からすると逆効果になってしまうかもしれませんが。

全体では前日の Shibuya.js in Kyoto からの流れで竹迫さんamachang さん、それに名古屋の JavaScript 勉強会 DeLLa.JS の主催者 issm さんといった豪華メンバーが駆けつけてくださり、読書会、LT、懇親会いずれも大いに盛り上がりました。Kanasan さんはじめスタッフ、参加者の皆さんありがとうございました。ちなみに DeLLa.JS 次回は 8 月 2 日に開催予定とのことなので、名古屋圏の方もそうでない方もぜひ参加してみるといいと思います。