口上
historyとAjaxといえば、JavaScriptからある程度任意でhistoryのエントリをpushできるhistory.pushStateとか、history.replaceStateは既に大分有名になった感がある。
素晴らしい未来では、全てのブラウザにpushStateが乗っていて「location.hashを使ったAjax遷移が許されるのは10年前のブラウザまでだよねー」というハッピーな世界が実現するのだろう。が、今現在ではまだpushStateに対応していないブラウザのシェアも多く、Ajaxによる擬似画面遷移をモリモリ行うようなサイトではpushStateのある環境、ない環境の両方を考慮してやる必要がある。
(ちなみに、要件によっては「pushStateがないブラウザは通常の遷移で我慢しろ!」という割り切りも全然ありだと思う)
先に言っておくと、この記事は長いです。
環境の分類
history管理まわりについて、現在のブラウザを分類するとだいたい以下に3つの環境に分けられると思う。
- history.pushStateがある環境
- history.pushStateはないが、window.onhashchangeのある環境
- history.pushStateも、window.onhashchangeもない環境
全てのブラウザをしらみぶしに調べたわけではないので2.のような環境はひょっとしたら無かったりするのかもしれないが、まあ2と3の両対応は難しくないのであってもなくてもとりあえず対応しておいてソンはない。ちなみに、3.の環境では枕を涙で濡らしながらhashの変化をsetIntervalで監視することになる。
pushStateを使う場合とhashを使う場合の遷移フロー
両対応の場合、ここからちょっと話が面倒くさくなる。pushState/hashを使ってAjaxによる遷移を実装する場合それぞれの、リンクを起因とした遷移のフローは以下のようになる。ちなみに、ここでいうリンクとはaタグなり、onclickのついたその他のタグのことを指す。
リンク起因の遷移フロー (with pushState)
ユーザがリンク等をclick
↓
これをJS側でキャッチして、必要があればpreventDefault。
↓
該当するURLにxhr
↓
リクエストが成功した場合、pushStateをhistoryをpush
↓
(この時点でhistoryの状態が変化)
↓
xhrのレスポンス(HTMLなりJSONなり)で、特定のDOM要素を書き換える
リンク起因の遷移フロー (with hash)
ユーザがリンク等をclick
↓
これをJS側でキャッチして、必要があればpreventDefault。
↓
location.href =
↓
(この時点でhistoryの状態が変化)
↓
onhashchange, またはhashのpollingでhashの変化を検知
↓
該当するURLにxhr
↓
xhrのレスポンス(HTMLなりJSONなり)で、特定のDOM要素を書き換える
pushStateの場合にxhrのsuccess/errorを見てからhistoryをpushする必要は必ずしもないのだけど、問答無用でhistoryに突っ込むよりもクリーンな気がしたので。hash遷移の場合でも「先にxhrを投げて、成功だったらURLを変える」という風におそらく出来るが、そうすると後述のブラウザバックによる遷移との整合性が取れなくなる(リンク起因の遷移とブラウザバックによる遷移で変な場合分けをしなくてはならなくなる)ので、あまりうれしくない。
ブラウザバック(back, forward含む)起因のそれぞれの遷移フローは以下。
ブラウザバック起因の遷移フロー (with pushState)
popstate eventが発火
↓
event handler内で該当のURL(stateに入れてもいいし、単にlocation.hrefをみてもいい)にxhr
↓
xhrのレスポンスで、DOM書き換え
popstate eventから連なる遷移の中ではpushStateは行わない。該当のhistoryは、既にpushされているから。
ブラウザバック起因の遷移フロー (with hash)
location.hashが変化
↓
onhashchange, またはhashのpollingでhashの変化を検知
↓
該当するURLにxhr
↓
xhrのレスポンスで、DOM書き換え
まとめると、pushStateとhashに両対応する場合
- ページ内のリンク起因の遷移 with pushState
- ブラウザバック起因の遷移 with pushState
- ページ内のリンク起因の遷移 with hash
- ブラウザバック起因の遷移 with hash
の、4つの場合についてフローを整理して、historyがおかしなことにならないようにケアしてやる必要がある。逆に言えば、この4つについて整理できればコアの部分を書くのはそれほど難しくない。
現実のサイトに適用する場合の考慮すべき点
以上のようなことを踏まえた上で、固定されたヘッダー、フッター、ナビゲーションなどが存在し、それ以外の部分をAjaxでモリモリ書き換えて擬似画面遷移を行うようなサイトへの適用について考える。
通常リンクの書き換え
上記のようなサイトでは、例外を含みつつも基本的にはAjaxで画面遷移を行う。なので、ページ内の通常のリンクをクリックして行われるアクションを何らかの形で差し替えてやる必要がある。ページ毎にいちいちaddEventListenerするのは、サイトの規模にもよるがあまり現実的でない。
ぱっと思いつくのは以下の2つ。
- hrefは用いず、onclick="next('http://example.com/foo')"のように個別に書き換える。aタグ以外にについても同様に。nextはpushState/hash対応などをもろもろよきに計らってくれる遷移用の関数。
- 「特定のclass, attributeなどをもつ要素にnextをbindする」というルールベース。要素へのnextのbindは、Ajaxによるページ遷移時にまとめて行うか、またはevent delegateを用いる。
基本的には2.の方がHTML内にonclick=""とか出てこないし見た目がきれいなんだけど、ルールベース故に細かい例外に対応するのが面倒くさいと思われる。また、遷移時にまとめてbindする場合、ページ内に大量のリンクがあるとよろしくない。event delegateを使う場合のクリックへのレスポンスは、event delegateの実装次第。
1.はonclickが泥臭いが、HTMLをかく人(マークアップ)とJSをかく人(エンジニア)が違う場合、単に「hrefをonclickなんとかにしといて下さい」というだけで済み、マークアップの人が見てもどれがAjax遷移のリンクなのか分りやすい。また個別に切り替えが可能なので、細かい例外に対応しやすいという利点がある。
現状、絶対にどちらがいい、と言い切れないカンジ。もっといい方法ないかな。
URLの正規化
正規化、という言葉がこの場合に用法として正しいのかちょっと微妙だけど。
pushStateによる遷移とhashによる遷移を共存させる場合、クライアント側から見ると同一のリソースに対するURLが2種類ある、という状態になる。つまり、http://example.com/foo/bar というリソースに対して
- http://example.com/foo/bar
- http://example.com/#page=foo/bar (#foo/barでもいいんだけど。お好みで)
前者が当然pushState対応ブラウザから見えるURLで、後者が非対応ブラウザから見えるURL。どちらの場合も、ページの特定のDOM要素が"http://example.com/foo/bar"に対して投げたxhrのレスポンスを使って書き換えられた状態になっている。通常のページを返すか、ページの一部分を返すかはHTTPのrequest headerなりquery parameterなりで判別すればいいだろう。
将来的にはpushStateの載ったブラウザばかりになるだろうし、URLの表現はそちらに倒しておきたい。つまり、next()に渡すURLにはhttp://example.com/foo/barというhashのないきれいなURLを使いたい。また、サイト全体としてもそちらのURLを正としたい。
なので、nextの中では
function next(url) { if ( hasPushState ) { // pushStateがある場合の処理 nextWithPushState(url); } else { // hashを使う場合の処理、ただしasHashUrlで通常のURLから2.のようなURLに変換 nextWithHash(asHashUrl(url)); } }
のように分岐を行い、pushStateのないブラウザでhashを用いた遷移を行う前にURLに対して適切な変換をかけてやる必要がある。
redirect
hashのpollingなんてpushStateのあるブラウザでしたくはないし、逆にhashで遷移を行うブラウザでpopstate eventを拾っても意味はない。要は、pushStateのあるブラウザではずっと正しいURLのみを踏んでpushState + Ajaxで遷移してほしいし、hashを使うブラウザではずっとhash付きURLの世界で暮らしてほしい。
ここで、サイトにリンク共有のような機能がある場合、例えばpushStateのないブラウザを使っているユーザAがサイト内のリソース(http://example.com/#page=foo/bar)へのリンクを貼り、そのリンクをpushStateのあるブラウザを使うユーザBが踏んでしまう、ということは当然あり得る。また、サイト内にそういう機能がない場合でも、外部ブックマークサイトその他を利用してサイト内へのリンクが作られる、ということは当然考えなければならない。
pushStateのあるブラウザを使っているユーザがhttp://example.com/#page=foo/barを踏んでも当然正しい遷移はできないので(しょうがないからその場合だけhashにfallbackしてもいいけど、ちょっと悲しい)、historyの先頭にちょっとゴミが入ることが気にならなければそのブラウザに適したURLの方にリダイレクトしてやればいいと思う。つまり、
- pushStateのあるブラウザでhttp://example.com/#page=foo/bar にアクセス -> http://example.com/foo/bar へリダイレクト
- pushStateのないブラウザでhttp://example.com/foo/bar にアクセス -> http://example.com/#page=foo/bar へリダイレクト
のような感じに。こうしてやれば、それぞれの世界線がクロスすることはなく、お互いに完結してくれる。
このリダイレクトは、pushStateの有無を見なければいけないのでJSレイヤーで行う必要がある。pushStateのあるUAの列挙などを行えばサーバサイドでも理論的には可能だが、それをやる意味は多分ない。
まとめと、hist.jsつくったよーという話
そんなわけで、pushState/hashの両対応を行いつつAjaxでモリモリ遷移を行う場合はまあまあ色々なことを考慮してやらないといけない。
実際にコードにしてみるとそこまでは長くならないのだけど、面倒くさい人が多いだろうとと思うので、だいたい上記のようなことをケアするためのライブラリを書いた。
https://github.com/zentooo/hist.js
外部ライブラリへの依存はなし。使い方はreadmeにも書いてあるけど、以下のような感じ。
hist.configure({ success: function(data) { // xhrが成功した場合のcallback document.getElementById("container").innerHTML = data; }, error: function(xhr) { // xhrが失敗した場合のcallback } });
この設定を行った上で、
<a onclick="hist.next('http://example.com/foo/bar')">foobar</a>
とかそういう風にリンクを書く。またはルールベースでaddEventListenerして、内部でhist.next()を呼んでもよし。
以上のようなほぼデフォルト設定で動かすと、
- JSONを返すことをサーバに知らせるためのrequest headerは X-Hist-XMLHttpRequest: 1
- hashなURLはhttp://example.com/#page=foo/bar形式
- リダイレクトは行わない
という動作になる。これらの挙動はオプションで変更可能。
また、オマケとしてTwitterやFacebookが採用して一時話題になったcrunchbang(#!)なhash URLにも対応している。optionにwithBang: trueを渡すことで
のようなURLが生成されるようになる。
以前に似たようなものを書いて、社内のライブラリのためにスクラッチから書き直し、今の形に切り出してから再度リファクタリング & テストしたので、それなりにちゃんと動くとおもう。
長文乙。
Google API Expertが解説するHTML5ガイドブック
- 作者: 羽田野太巳,白石俊平,古籏一浩,太田昌吾
- 出版社/メーカー: インプレスジャパン
- 発売日: 2010/09/16
- メディア: 単行本(ソフトカバー)
- 購入: 15人 クリック: 461回
- この商品を含むブログ (14件) を見る