Manipulating History
for Fun & Profit

 

Diving In

ブラウザのロケーションバーは世にある UI のなかで最もギークなもののひとつだろう。URL はビルボードや電車の車体、はたまたグラフィティにまで現れる。戻るボタン (ブラウザでもっとも重要なものだ) と組み合わせれば、Web と呼ばれる複雑に絡みあったリソースの集合を行き来するとても強力な手段となる。

HTML5 の History API はブラウザのヒストリ情報をスクリプトから操作する機能だ。この API の一部にはヒストリを行き来する機能があるが、これは以前の HTML の頃より存在していた。HTML5 では、ブラウザのヒストリにエントリを追加する機能、ページの更新なしにロケーションバーの URL を書きかえる機能、ユーザーが戻るボタンを押しそのエントリがスタックから削除される際に発火されるイベントなどが新しく追加された。これによって、ロケーションバー中の URL は、ページの全部を更新しないようなスクリプトごりごりのアプリケーションにおいても、現在のリソースに結び付けられた一意な識別子という性質を維持できるのだ。

The Why

本読みデーモン

一体なぜ、ブラウザのロケーションバーを操作する必要があるのだろうか。リンクから別の URL へ飛ぶ。これはその始まりから今日までの 20 年間ずっと続く、Web のもっとも基本的な仕組みだ。そして、これからも変わることはない。この API は Web を破壊するものなんかではないのだ。むしろその逆だ。近年、Web 開発者は Web をひっくり返す面白い方法を、標準の助けなしに見つけてしまった。HTML5 の History API は、スクリプトごりごりの Web アプリケーションでも URL が変わらず機能し続けることを保証するために用意されているのだ。

最初の原則に立ち返ってみよう。URL はなにをするものだろうか。リソースを一意に識別するものだ。この役割のおかげで、リソースを直接リンクすることやブックマークすることができる。検索エンジンはリソースをインデックスできる。URL をコピーしメールに貼りつけ誰かに送れば、送り相手はそれをクリックしてあなたが見たリソースと同じものを見られる。そんなすばらしい役割があるのだ。URL は偉大だ。

一意なリソースには一意な URL があってほしいものだ。しかし、ブラウザには根本的な欠陥がある。URL を変更すると、たとえスクリプトを使ったとしても、変更したことで Web サーバへの接続が行われ、ページが更新されてしまうのだ。これは時間やリソースを使うし、なにより現在のページが、移動先のページとほとんど同じである場合はとても無駄になる。移動先のページにあるすべてがダウンロードされてしまう。URL を変えて、でもページの半分しかダウンロードするなとブラウザに伝える、そんな方法は存在しなかった。

HTML5 の History API を使えば、それを実現できる。ページを完全に更新するのではなく、基本的にはスクリプトを使って部分的にページをダウンロードするのだ。このイリュージョンを見破るのは難しいし、ページにタネを仕込む必要もある。ではご覧いただこう。見逃さないように。

カードトリックを披露するマジシャン

ここに A と B という2つのページがある。2つのページの90%は同じ内容だが、残りの10%はそれぞれ異なっている。ユーザーはページ A にアクセスした後、ページ B にアクセスする。このとき、ページを更新するのではなく、そのページ遷移を妨害し次に記すステップを実行する。

  1. ページの10%をロードする。多分 XMLHttpRequest を使って、ページ A とページ B の異なる部分だけをロードする。これをするにはサーバーサイドに多少の変更が必要になるだろう。ページ A とページ B の異なる10%の部分だけを返すコードを書くのだ。実装は隠れた URL やクエリパラメータなど、エンドユーザーが普段見ないものでよいだろう。
  2. ダウンロードした異なる部分を入れ替える。(innerHTML や他の DOM メソッドを利用するだろう。) 入れ替わった内容のイベントハンドラをリセットしないといけない場合が出てくるかもしれない。
  3. ブラウザのロケーションバーをページ B の URL に更新する。これには HTML5 の History API を利用する。

このイリュージョンの終わりには、ブラウザはページ B に直接移動したかのように、そのページと同じ DOM を持つことになる。ロケーションバーも同じくページ B の URL を持つ。しかし、実際にはページ B にアクセスしてはおらず、そしてページは更新されもしない。これがマジックなのだ。しかし「合成した」そのページはページ B と同じに見えるし、URL もページ B と同じだ。ユーザーがその違いに気づくことはないだろう (その体験を提供するためにあなたがしたことに感謝することもないだろう)。

The How

HTML5 の History APIwindow.history オブジェクトにいくつか用意されたメソッドと window オブジェクトに追加された1つのイベントから構成される。これらを利用すれば History API のサポートを検出 できる。サポートはいくつかのブラウザの最新バージョンに限定されるため、“Progressive Enhancement” な使い方が前提となる。

history.pushState support
IEFirefoxSafariChromeOperaiPhoneAndroid
10+4.0+5.0+8.0+11.50+4.2.1+2.2+*
* Android ブラウザは Android 2.2 で History API をサポートしたが、Android 3.x, Android 4.0 ではサポートが無効にされている。

dive into dogs は何の変哲もないが、作るのはそこまで簡単でない、HTML5 の History API を利用したサンプルだ。長めの記事と、インラインのフォトギャラリーというよくあるパターンで構成されている。サポートされたブラウザでは、フォトギャラリー中の Next と Previous というリンクをクリックすると、写真が置き換えられ、さらにブラウザのロケーションバーにある URL も更新される。しかし、ページ全体が更新されるわけではない。サポートされていないブラウザや、スクリプトを無効にしたブラウザにおいて、これらのリンクは通常のリンクとして扱われるため、新しいページに移動することになる。もちろん、新しいページなので、全体が更新される。

最後の2文が、とくに重要だ。

Professor Markup Says

スクリプトを無効にしたブラウザで君の Web アプリケーションが動作しなかったら、どこからともなくニールセン博士の犬が君のところにやってきて、カーペットに粗相でもするだろうね。

The pledge

<aside id="gallery">
  <p class="photonav">
    <a id="photonext" href="casey.html">Next &gt;</a>
    <a id="photoprev" href="adagio.html">&lt; Previous</a>
  </p>
  <figure id="photo">
    <img id="photoimg" src="gallery/1972-fer-500.jpg"
            alt="Fer" width="500" height="375">
    <figcaption>Fer, 1972</figcaption>
  </figure>
</aside>

何らおかしなところは見当たらない。写真は <figure> 内の <img> で、リンクは普通の <a> 要素、写真やそのコントロールは <aside> で囲まれている。リンクが普通に機能することは重要だ。次に記すすべてのコードは 検出スクリプト のもと機能する。サポートしないブラウザでは、History API のコードは実行されない。スクリプトを無効にしたユーザーは、かならず一定数存在するのだ。

スクリプトのメイン関数は、上記のリンクを取得し addClickr() という click ハンドラを独自に設定する関数に渡す作業を行う。

function setupHistoryClicks() {
  addClicker(document.getElementById("photonext"));
  addClicker(document.getElementById("photoprev"));
}

これが addClicker() 関数だ。この関数は <a> 要素を受け取り、それに click ハンドラを与える。この click ハンドラの中身から、スクリプトが面白くなってくる。

function addClicker(link) {
  link.addEventListener("click", function(e) {
    swapPhoto(link.href);
    history.pushState(null, null, link.href);
    e.preventDefault();
  }, false);
}

 ↜ Interesting

swapPhoto() 関数が、先ほど説明した 3ステップのイリュージョン のうち、1番目と2番目を担当する。swapPhoto() の前半分は、ナビゲーションリンクの URL の一部 (casey.htmladagio.html など) を受け取り、次の写真を表示するために必要なマークアップのみを持ったページの URL を作成する。

function swapPhoto(href) {
  var req = new XMLHttpRequest();
  req.open("GET",
           "http://diveintohtml5.info/examples/history/gallery/" +
             href.split("/").pop(),
           false);
  req.send(null);

次に示すのは http://diveintohtml5.info/examples/history/gallery/casey.html が返すマークアップだ。(URL に直接アクセスすれば、ちゃんと同じものが帰ってくるかを確かめることができる。)

<p class="photonav">
  <a id="photonext" href="brandy.html">Next &gt;</a>
  <a id="photoprev" href="fer.html">&lt; Previous</a>
</p>
<figure id="photo">
  <img id="photoimg" src="gallery/1984-casey-500.jpg"
          alt="Casey" width="500" height="375">
  <figcaption>Casey, 1984</figcaption>
</figure>

見覚えがあるのではないだろうか。そう、このマークアップは1枚目の写真を表示する もとのページのマークアップと基本的に同じもの だ。

swapPhoto() 関数の後半が、イリュージョンのうち2番目のステップを実行する。新しくダウンロードしたマークアップを現在のページに挿入するのだ。先ほどのコードを覚えているだろうか。ナビゲーション、写真、キャプションは <aside> に囲まれている。なので、新しい写真の挿入は XMLHttpRequest から返ってきた responseText プロパティを、<aside>innerHTML プロパティに設定するだけで済む。

  if (req.status == 200) {
    document.getElementById("gallery").innerHTML = req.responseText;
    setupHistoryClicks();
    return true;
  }
  return false;
}

(setupHistoryClicks() を呼び出していることにも注目だ。これは新しく挿入されたナビゲーションリンクの click イベントハンドラをリセットするものだ。innerHTML をセットすると、古いリンクの痕跡やそのリンクに与えられたイベントハンドラが消去される。)

addClicker() 関数に戻ろう。さて、これまで説明したのは 3ステップのイリュージョン なのを覚えているだろうか。つまり、写真を入れ替えることに成功したあと、もうひとつ実行しなければならないステップがあるのだ。ページを更新せずに、ロケーションバーの URL をセットするのだ。

The turn

history.pushState(null, null, link.href);

history.pushState() 関数は3つの引数をとる。

  1. stateJSON データ構造であるものであれば何でもよい。オブジェクトはこの後すぐ説明する popstate イベントハンドラに渡される。このデモでは何の状態も追跡する必要がないので、ここでは null としている。
  2. title ― どんな文字列でもよい。この引数は現時点でどのブラウザでも利用されていない。ページタイトルをセットしたい場合は、state 引数に格納し、popstate のコールバックから手動で指定するしかない。
  3. url ― どんな URL でもよい。ここに指定したものが、ロケーションバーに現れる URL となる。

history.pushState を呼ぶとすぐさまロケーションバーの URL が変更される。では、これでイリュージョンは終わりだろうか。残念ながらそうではない。ユーザーがあの重要な戻るボタンを押した時の挙動について考えなくてはならないからだ。

ユーザーが新しいページへ移動した時 (ページも更新されたとき)、ブラウザはヒストリのスタックに URL をプッシュし、ページをダウンロードし、そして描画する。ユーザーが戻るボタンを押すと、ヒストリスタックからページをポップし、前のページを再描画する。しかし、ページの更新なしにページを入れ替えた今の場合において、ユーザーが戻るボタンを押した際にはどうなるのだろうか。つまり、サンプルでは「進む」挙動を偽証し、新しい URL に移動している。つまり、「戻る」についても同様に、前の URL へ移動する際に挙動を偽証しなければいけないのだ。そしてその鍵は、popstate イベントが握っている。

The prestige

window.addEventListener("popstate", function(e) {
    swapPhoto(location.pathname);
}

history.pushState() 関数を使い、ブラウザのヒストリスタックに偽の URL をプッシュした後、ユーザーが戻るボタンを押すと、ブラウザは popstate イベントを window オブジェクトから発火する。ここでちゃんとするかしないかで、イリュージョンの成功が決まってしまう。何かを消すだけでは成功とは言えない、それをもとに戻してこそイリュージョンだろう。

今回のデモにおいて「もとに戻す」ことは簡単だ。swapPhoto() にもとの URL を与え、もとの写真を呼び出せばよいだけだ。popstate コールバックが呼び出されるころには、ロケーションバーの URL は前の URL に変わっているだろう。また、location プロパティもすでに前の URL に更新しているだろう。

では、イリュージョンがどう起こったのかを、その最初から最後までまとめて説明しよう。

イリュージョンは成功だ。ページ内容とロケーションバーの URL を見ると、まるでユーザーはあるページから別のページを行き来しているようだ。しかし、ページ全体が更新されているわけではない。これは細かいトリックを組み合わせたイリュージョンなのだ。

Further Reading

訳註:このページは Dive Into HTML5“Manipulating History for Fun & Profit” の日本語訳です。

Did You Know?

原文が公開されている Dive Into HTML5 というサイトは “HTML5: Up & Running” という名前で Google Press より出版、O’Reilly Media より発売されています。(ePub, Mobi, DRM-free PDF など紙以外の媒体でも販売されています。)

原著を購入したい方はぜひ 著者のアフィリエイトリンク から、もしくは O’Reilly より電子版を購入 してください。

訳註:“HTML5: Up & Running” の日本語訳はオライリー・ジャパンより「入門 HTML5」として発売されています。(本ページの訳者が監訳として関わっていますが、この翻訳文書は書籍と無関係です。)

訳註:翻訳元の内容は “HTML5: Up & Running” に含まれていません。同様に、この日本語訳も「入門 HTML5」に含まれていません。

Copyright MMIX–MMXI Mark Pilgrim

翻訳: