jQuery のバグを見つけてから修正されるまで2013年01月28日 02時17分

1 月 24 日に開催された Kyoto.js meetup 4 で「jQuery のバグを見つけてから修正されるまで」と題した発表を行いました。

jQuery へのコミットに関して 2 行でまとめるとすれば次のようになるでしょうか。

  1. jQuery のソースコードはショートコーディングの嵐なので心してかかる
  2. Contributing to jQueryjQuery Core Style Guidelines は必読

発表の筋書きは以下の通りです。


jQuery のバグを見つけてから修正されるまで


フォロー・ミー

  • nanto_vi (TOYAMA Nao)
  • 株式会社はてな アプリケーションエンジニア
    • クライアントサイド (JavaScript)
    • サーバーサイド (Perl)
  • jQuery をバリバリ使っている
    • Deferred
    • イベント
    • DOM 操作

ある日どこかで

街中にクリスマスの装飾が灯り始めるころ

うんうん、変わったよね

……


そういえば Deferred を使ったとき this の値はどうなるんだったっけ?

$.Deferred().done(function () {
    this // ← ココとか
}).then().done(function () {
    this // ← ココの値は?
});

jQuery 1.8.3 と jQuery 1.9 Beta 1 で結果が違う!

汚れなき悪戯

ふむふむ…… (コードを読む)

ふむふむ…… (コードを読む)

ふむふむ…… あれ?


これ、自分の書いたコードが動かなくなる?

$.Deferred().then(function () {
    // ここでの this が
    return $.Deferred().resolveWith(this, arguments);
}).done(function () {
    // ここにも引き継がれてほしい
    this
});

……


まずい!

バック・トゥ・ザ・フューチャー

どこを直せば自分のコードが動くのか

コードとにらめっこ

にらめっこ

にらめっこ

……


問題なのはここ、でもここを変えると先の変更の意味が失われるから……


こことここか!

羊たちの沈黙

直すならテストを書かないと

テストを書くならテストを走らせないと

テストを走らせるなら jQuery のビルド環境を作らないと

grunt? Node.js なら入ってるし楽勝でしょ

……


うわあ、この Node.js バージョンが古い!

うわあ、この OS バージョンが古い!

うわあ、この Python (ry

……


沈黙


そしてクリスマスが過ぎ、正月が過ぎた

風と共に去りぬ

やっぱり正月松の内は休まないとね

ってなになに……


げげー、1.9-stable ブランチ!


もはや一刻の猶予も許されない

禁じられた遊び

ビルド環境がないならテスト環境を作ればいいじゃない

俺にはこの SpiderMonkey JavaScript Shell がある!


秘技、テストのコピペ改変!

荒野の用心棒

nanto_vi 「これ直さへんとあかんちゃう?」

J 「プルリクエストにしてーな」

nanto_viプルレクったでー

D 「ここ冗長やろ。もっと削れるやろ

nanto_vi (そない言われても…… これ以上どう切り詰めろいうんや……)

nanto_vi (しゃあないな、この条件分岐をもっと前に持ってきて……)

nanto_vi (あれ、この変数、フラグに流用できるちゃうか……)

nanto_viどや!

D 「まあええんちゃうか」

素晴らしき哉、人生!

D 「修正取り込んだで。おーきに」

J 「僕と契約して貢献者になってよ!


めでたしめでたし


※ この物語は実話を基にしたフィクションです。

jQuery で HTTP 接続するときの書き方2012年12月16日 19時41分

12 月 13 日に Kyoto.js の第 3 回 meetup で、「jQuery で HTTP 接続するときの書き方」と題した 5 分間のライトニングトークを行いました。以下にその内容を一部再構成して収録します。


こんにちは、nanto_vi です。今日は jQuery で HTTP 接続をするときの書き方について話します。

皆さん jQuery を使うことも多いかと思います。jQuery で HTTP 接続をするとき、古いサンプルだと次のような書き方が載っています。

$.ajax({
    url: '/foo/bar',
    data: { baz: 'qux' },
    success: function (data) {
        console.log(data);
    },
});

接続完了時の処理をコールバック関数として $.ajax() に渡してやる形ですね。しかし、現在この書き方は非推奨となっており、替わりに次のように書きます。

$.ajax({
    url: '/foo/bar',
    data: { baz: 'qux' },
}).done(function (data) {
    console.log(data);
});

$.ajax() の返り値に対して、done メソッドで接続完了後の処理を登録する形です。この書き方の何がいいかといえば、返り値を使いまわしたり done メソッドを複数回呼び出したりして、完了後の処理を後から追加できるところです。昔の書き方では完了後の処理がコールバック関数として $.ajax() の内部に格納されていましたが、現在は「完了後の処理」だけを $.ajax() の外部に (「プロミス」として) 取り出すことが可能になったわけですね。

今「内部の処理を外部に取り出す」と言いましたが、この言葉はどこかで聞き覚えがありませんか。そう、内部イテレータと外部イテレータです。

内部イテレータは、オブジェクトが個々の要素に対する処理を受け取り、オブジェクト内部で自身の各要素に適用させる形、

// 内部イテレータの使用例
$('p').each(function () {
    doSomethingWith(this);
});

外部イテレータは、オブジェクトから「各要素を列挙する」という機能だけをオブジェクト外部に取り出す形です。

// 外部イテレータの実装例と使用例
$.fn.iterator = function () {
    var i = 0, n = this.length, self = this;
    return {
        hasNext: function () { return i < n; },
        next: function () { return self[i++]; }
    };
};

var iterator = $('p').iterator();
while (iterator.hasNext()) {
    var element = iterator.next();
    doSomethingWith(element);
}

JavaScript 1.7 以降ではジェネレータという機能により、内部イテレータのような書き方で外部イテレータを生成することができます。

さて、「処理を内部に持ってしまっている」というのは、イベントハンドラの登録も同じですね。

$(document).on('click', function (event) {
    ...
});

イベント発生時の処理をコールバック関数として jQuery オブジェクト内部に渡していますが、これを外部に持ち出すことはできないのでしょうか。

実はそれを可能にするものとして Reactive Extensions (Rx) があります。Rx 入門記事の図にもあるように、Rx を使うと空間にまたがる要素 (配列など) の列挙と時間にまたがる要素 (イベントなど) の列挙を統一的に扱うことができます。

Reactive Extensions は主に .NET Framework 上で利用されていますが、JavaScript での実装として RxJS が公開されています。

ObserverパターンとIteratorパターンは同じだったんだよ!なんだってー!

neue cc - .NET Reactive Framework メソッド探訪第一回:FromEvent

そう、ObserverパターンとIteratorパターンは同じなのだよ、ナンダッテー!

neue cc - Reactive Extensions for JavaScript

(このあたりで時間切れ)

IE 6/7 で文書間通信を実現するための一案2011年12月08日 23時32分

HTML5-WEST.jp 飲み会 UST というのがあるそうで、「参戦希望者募集」とお誘いを受けたのですが、「参戦」というからには何かしら戦の準備を整えねばなるまいと、以前から考えていたことを夜なべして実装しました。

いわゆる HTML5 の文書間通信、window.postMessage() を IE 6/7 でどう再現するかという話で、about:blank を指す隠しフレームを二つ用い、window.name を介することで双方向の通信を実現しています。

どうも IE での about:blank は、その空白ページを読み込ませた文書の生成元を継承するらしく、たとえば http://example.org/ から location.href = 'about:blank'; を実行すれば http://example.org を生成元とする about:blank になります (少なくともそのように見えます)。これを利用し、一つのフレームに二つの文書から交互に about:blank を読み込ませあうことで、そのフレームの window.name を両文書で共有できるという仕組みです。

about:blank を読み込ませるのに location.reload() を使えば履歴に余分な項目を残すこともありませんし、フラグメント識別子を使った場合と違ってデータ量にもだいぶ余裕が出ます。

chrome URL からファイル一覧を取得する2011年01月12日 01時41分

Firefox にて、ディレクトリを指す chrome URL から、そのディレクトリ以下の全ファイルの URL を返すサンプルコードを書きました。

ChromeFiles.get("chrome://browser/content/");
/* => [ "chrome://browser/content/NetworkPanel.xhtml",
 *      "chrome://browser/content/aboutDialog.css",
 *      ...,
 *      "chrome://browser/content/browser.css",
 *      "chrome://browser/content/browser.js",
 *      "chrome://browser/content/browser.xul",
 *      ... ]
 */

特徴として、実際のファイルが (.jar または .xpi に) パッケージ化されているかどうかに関わらず、ファイル一覧を取得できることが挙げられます。ソースコード全体は上記 Gist へのリンクを参照してもらうとして、以下は各関数の解説です。

var ChromeFiles = {
    get: function CF_get(spec) {
        const ios = Cc['@mozilla.org/network/io-service;1'].
                    getService(Ci.nsIIOService);
        let uri = ios.newURI(spec, null, null);
        return this.getByURI(uri);
    },

    ...
};

単に、文字列として受け取った URL を nsIURI のインスタンスにして、getByURI() へ処理を委譲しているだけです。

getByURI: function CF_getByURI(uri) {
    // 1. ディレクトリを指す URL にする
    let baseURI = uri.clone().QueryInterface(Ci.nsIURL);
    baseURI.path = baseURI.directory;

    // 2. chrome URL からローカルファイルシステムでの URL へ変換
    const registry = Cc['@mozilla.org/chrome/chrome-registry;1'].
                     getService(Ci.nsIChromeRegistry);
    let localURI = registry.convertChromeURL(baseURI);

    // 3. ディレクトリ中のファイル名を取得
    let leafNames = null;
    if (localURI instanceof Ci.nsIFileURL) {
        leafNames = this.getLeafNamesByDirectory(localURI.file);
    } else if (localURI instanceof Ci.nsIJARURI) {
        leafNames = this.getLeafNamesByJARURI(localURI);
    } else {
        throw new Error('Unknown URI: ' + localURI.spec);
    }

    // 4. ディレクトリのパスとファイル名を結合
    let baseSpec = baseURI.spec;
    return leafNames.sort().map(function (leafName) baseSpec + leafName);
},
  1. chrome URL において、chrome://{package}/content/chrome://{package}/content/{package}.xul同じリソースを表しnsIIOService#newURI() の引数に前者を渡しても返ってくる URI オブジェクトの spec は後者になります。確実にディレクトリを指す URI オブジェクトを得るためには、自分で URI オブジェクトのプロパティを変更しなければなりません。

    ところが、newURI() で作られたオブジェクトは可変でない (immutable な) ことがあり、このときプロパティに値を設定しようとすると例外が発生します。chrome URL の場合 clone() で生成した URI オブジェクトは可変になるので、まずは URI オブジェクトを複製します。

    パスからディレクトリ部分だけを抜き出すのは、nsIURL インターフェースの directory プロパティを使うのが簡単です。nsIURL インターフェースを経由すれば、ディレクトリ以外にもファイル名や拡張子などをすぐに取得できます。

  2. chrome URL からローカルファイルシステム上でのファイル位置をあらわす URL への変換は、nsIChromeRegistry#convertChromeURL() で一発です。これにより得られる URL は大抵の場合 file URL か jar URL かのいずれかです。

  3. ディレクトリ中のファイル名一覧を配列として取得します。処理本体は file URL の場合と jar URL の場合で別になります。

    なお、QueryInterface() を使わなくとも、instanceof 演算子で nsIFileURL インターフェースを実装していることを確認できたなら、それ以降は nsIFileURLfile プロパティから nsIFile オブジェクトを取得できます。

  4. 得られたファイル名一覧の順序はわからないので、辞書順で並べ替えます。その後にディレクトリ部分を表す chrome URL と結合すれば、ディレクトリ直下のファイルを指す chrome URL の一覧が得られます。

getLeafNamesByDirectory: function CF_getLeafNamesByDirectory(dir) {
    let files = dir.directoryEntries;
    let leafNames = [];
    while (files.hasMoreElements()) {
        let file = files.getNext().QueryInterface(Ci.nsIFile);
        if (file.isFile())
            leafNames.push(file.leafName);
    }
    return leafNames;
},

ディレクトリを表す nsIFile オブジェクトから、その子ファイルの名前一覧を取得します。単に子ファイルを列挙していき、それがディレクトリなどでないときにファイル名を取得するだけです。

getLeafNamesByJARURI: function CF_getLeafNamesByJARURI(jarURI) {
    // 1. ZipReader を作成
    let zip = this.openZipReader(jarURI.JARFile);

    try {
        // 2. ディレクトリ直下のファイルのパスを取得
        let baseEntry = jarURI.JAREntry;
        let pattern = baseEntry + '?*~' + baseEntry + '?*/*';
        let entries = zip.findEntries(pattern);

        // 3. ファイル名部分だけを抜き出し、返す
        let leafNames = [];
        while (entries.hasMore())
            leafNames.push(entries.getNext().substring(baseEntry.length));
        return leafNames;
    } finally {
        zip.close();
    }
},

jar URI は jar:file://path/to/file.jar!/path/to/entry/ のような形で表されます。nsIJARURI インターフェースは、file://path/to/file.jar の部分を示す JARFile プロパティ (返ってくるのは nsIURI オブジェクト) と、/path/to/entry/ の部分を指す JAREntry プロパティ (返ってくるのは文字列) を持っています。

  1. JAR ファイル (または XPI ファイル) は ZIP 書庫なので、内部のファイル情報を読み取るためには nsIZipReader のインスタンスを作成し書庫を開く必要があります。

  2. 書庫内部のファイル名一覧を取得するには findEntries() を使います。ここで指定するファイル名のパターンにおいて、"?" は任意の1文字を、"*" は任意の文字列を、"pattern1~pattern2"pattern1 にマッチするが pattern2 にはマッチしないものを表します。

    "/path/to/entry/*" というパターンでは /path/to/entry/ 自信も含まれてしまうので、ディレクトリではないファイルだけを抽出するために "/path/to/entry/?*" と指定します。また、それだけだと子孫ディレクトリ中のファイルも含まれるので、"/path/to/entry/?*/*" を除外してやります。

  3. findEntries() で得られた値にはディレクトリ部分も含まれるので、その部分は切り取ってファイル名だけにします。

openZipReader: function CF_openZipReader(uri) {
    let zip = Cc['@mozilla.org/libjar/zip-reader;1'].
              createInstance(Ci.nsIZipReader);
    if (uri instanceof Ci.nsIFileURL) {
        // 1. file URL なら単にそのファイルを開く
        zip.open(uri.file);
    } else if (uri instanceof Ci.nsIJARURI) {
        // 2. jar URL なら JAR ファイル内部のファイルを開く
        let innerZip = this.openZipReader(uri.JARFile);
        zip.openInner(innerZip, uri.JAREntry);
    } else {
        throw new Error('Unknown URI: ' + uri.spec);
    }
    return zip;
},
  1. JAR ファイルがローカルファイルシステム上に直接存在するなら、単に open() メソッドにファイルオブジェクトを渡して開くだけです。

  2. 開こうとする JAR ファイルが別の書庫内に存在することもあります。その場合は openInner() に、JAR ファイルが含まれる書庫と、その書庫内での JAR ファイルのパスを指定してやります。

    nsIZipReader#openInner() は Firfox 4 で追加されたものですが、Firefox 4 より前では jar URL がネストすることはないといっていいので、ここで使っても問題ないでしょう。

たとえば Firefox 4 Beta でツリー型タブを使うと、chrome://treestyletab/content/ の実体は jar:jar:file://{profile}/extensions/treestyletab@piro.sakura.ne.jp.xpi!/chrome/treestyletab.jar!/content/treestyletab/ といった URL になりますが、上記のようにすればその内部のファイル構成を知ることができます。

また、resource://gre/modules/XPCOMUtils.jsm といった resource URL に関しても、nsIResProtocolHandler#resolveURI() を使えばローカルファイルシステム上の URL へ変換でき、上と同様にファイル一覧の取得などが可能になります。

リンクのようなボタンを作る2010年12月15日 23時52分

こんばんは、JavaScript Advent Calendar 2010、15 日目担当の nanto_vi (なんと) です。12 月 15 日が何の日か調べてみると東北本線が宮城県に到達した日とのこと。当時は上野から仙台まで 12 時間 20 分かかったそうです。それから 123 年を経た現在では同じ時間で鹿児島中央から新青森まで行けるようになり、鉄道の速度にも JavaScript の実行速度にも日進月歩を感じる今日この頃です。

さて、アプリケーションを作っていると、見た目はリンクのようだがリンクでない UI 部品を使いたくなるときがあります。ここで「リンクでない」とは、クリックしてもページ遷移が発生しないということです。このような UI 部品は、ページ遷移の代わりにメニューの表示といった何らかのアクションを引き起こす、すなわちボタンとして振舞います。

ユーザーインターフェース記述言語として HTML を使っているとき、この「リンクのようなボタン」をどのように実現すればいいのでしょうか。

input、button 要素

ボタンとして振舞うものはボタンとして記述すべきです。HTML では汎用的なボタンとして <input type="button"> 及び <button type="button"> が用意されています。スタイルシートを使えば見た目をリンクのようにもできるでしょうし、画像を使いたければ <input type="image"> もあります。

<input type="button" value="リンクのようなボタン" onclick="...">
<button type="button" onclick="...">リンクのようなボタン</button>

しかし、フォームコントロールに対してはスタイルシートが期待通り適用されないこともあり、ボタンをどうしてもインライン要素にしたいときにこの方法は取れません。

a 要素

リンクのように見えるならリンクにすればいいということで a 要素が使われることもあります。click イベントを処理するときにデフォルトアクションをキャンセルすれば、もともと指定してあったリンク先に飛ぶことはありません。あるいはリンク先に javascript:void(0); と指定することでページ遷移が発生しないようにします。

<a href="#" onclick="...; return false;">リンクのようなボタン</a>

しかし、a 要素はあくまでハイパーリンク、すなわち他のリソースへ移動するためのものです。外見がどうあろうとリンクでないものの記述に使うの好ましくありません。

span 要素

click イベントに対する処理はどんな要素にも付加できるので、a 要素にこだわる必要はありません。「リンクのような」の部分はスタイルシートで実現できます。

<style>
.trigger {
  color: #00f;
  text-decoration: underline;
  cursor: pointer;
}
</style>

<span class="trigger" onclick="...">リンクのようなボタン</span>

しかし、これではキーボードを用いてボタンにアクセスすることができません。多くのブラウザでは Tab キーでリンクやフォームコントロールへ移動できますが、span 要素はその対象から外れています。

span 要素 + tabindex 属性

tabindex 属性はフォーカス順を変更するものだと思っていたあなた、それはこの属性が持つパワーのほんの一部でしかありません。HTML5 において tabindex 属性は要素をフォーカス可能にする属性として生まれ変わったのです。この属性はどんな要素にもつけられ、値に 0 を指定すればその要素がキーボードアクセス可能になります。

<span class="trigger" tabindex="0" onclick="...">
  リンクのようなボタン
</span>

しかし、Tab キーでこのボタンにフォーカスし、Enter キーを押しても何もおきません。a要素によるリンクなら Enter キーを押すと click イベントが発生するのにも関わらずです。なお、Opera なら span 要素でも Enter キーにより click イベントが発生し、指定したアクションが実行されます。

span 要素 + tabindex 属性 + onkeypress 属性

click イベントが発生しないなら自分で click イベントに対する処理を呼び出せばいいのです。onclick 属性に指定したコードは onclick プロパティから関数として取得できます。Enter キーを表すキーコードは 13 なので、そのときのみ処理を実行します。

<span class="trigger" tabindex="0" onclick="..."
      onkeypress="if (event.keyCode === 13) this.onclick(event);">
  リンクのようなボタン
</span>

しかし、click イベントに対する処理が onclick 属性に書かれているとは限りません。addEventListener や attachEvent メソッドでイベントリスナが追加されていることもあれば、祖先要素、文書ノードで click イベントが処理されていることもあります。また、Opera ではこの場合 Enter キーを押すと onclick 属性の内容が2回実行されてしまいます。

span 要素 + tabindex 属性 + キーイベント処理

click イベントに対する処理を直接実行できなくとも、click イベントを発生させれば自然とそれらが実行されます。イベントを発生させるのに、DOM イベントモデルでは dispatchEvent、IE のイベントモデルでは fireEvent メソッドを用います。Opera 対策に keypress イベントのデフォルトアクションをキャンセルしておきましょう。

<script>
function activate(event) {
    event = event || window.event;
    if (event.keyCode !== 13) return;
    if (document.createEvent) {
        var e = document.createEvent('MouseEvent');
        e.initMouseEvent(
            'click', true, true, event.view, 1,
            event.screenX, event.screenY, event.clientX, event.clientY,
            event.ctrlKey, event.altKey, event.shiftKey, event.metaKey, 0, null
        );
        event.target.dispatchEvent(e);
        event.preventDefault();
    } else if (document.createEventObject) {
        var e = document.createEventObject(event);
        event.srcElement.fireEvent('onclick', e);
        event.returnValue = false;
    }
}
</script>

<span class="trigger" tabindex="0"
      onclick="..." onkeypress="activate(event);">
  リンクのようなボタン
</span>

しかし、視覚的なブラウザであれば見た目からこの要素が何らかのアクションを引き起こすことがわかりますが、環境によってもそもそもこれが「押せる」ということすら伝わらないかもしれません。

span 要素 + tabindex 属性 + キーイベント処理 + role 属性

視覚によらず、機械的に UI 部品を認識するための仕様として WAI-ARIA があります。要素がボタンの「役割」を果たすことを表すには、role 属性の値に "button" を指定します。これで人の目からだけでなく、機械から見たときもボタンとして認識・操作できるようになりました。

<span class="trigger" role="button" tabindex="0"
      onclick="..." onkeypress="activate(event);">
  リンクのようなボタン
</span>

サンプル

リンクのようなボタンのサンプルで実際の挙動を確認できます。

終わりに

以上はあくまで JavaScript の使える環境が前提です。スクリプトが動かなくとも最低限の機能は利用できるよう気をつけましょう。スクリプトが動く場合でも、本当にリンクのようなボタンでなければいけないのか、通常のボタン、またはハイパーリンクでは実現できないのかよく検討した上で使うようにしてください。

参考資料