HTML5のHistoryAPIとAjaxを使って非同期通信をしながらURLを変えるいわゆるpjaxっぽいことができたので、大まかな流れと気をつけたい点について書いていく。
(2020/08/15追記:本格的なシングルページアプリケーションを作りたい人はフレームワークなしで作るSPAを参考にしてほしい。)
Ajaxを使ってページを遷移する時、URLは変わらないし履歴も追加されない。しかし、HTML5のHistoryAPIと組み合わせると、URLを変え履歴も追加でき、通常どおりのページ移動と同じような動作を非同期通信で実現できる。
HistoryAPIについて
JavaScriptにはhistoryという履歴を管理するオブジェクトがありHTML5から履歴を追加したり変更したり検知したりできるようになった。pjaxの頭文字pであるpushStateはhistoryオブジェクトのメソッドで
history.pushState(state, title, url);
で呼び出せる。pushStateは履歴を追加するメソッドである。
例えば、するとURLが変わる。そして、履歴も追加されたのでブラウザの戻るボタンを押すことができ、押せばURLは元に戻る。ちなみに今実行されたコードはこれ。
history.pushState('testButton', null, '/test');
pushStateの第3引数にURLを指定することによってURLが変わり、履歴が追加される。URLは相対パスでもいい。ちなみに、第1引数は履歴に情報を付加するオブジェクトで、history.stateに値がセットされる。これはどういう時に使うかというと、履歴が移動した時に発生するpopStateイベントからこのstateを元に処理したりする時に使える。後で具体的に使いながら見ていく。第2引数はその履歴のタイトルでページのタイトルを変えるためにあるようだが、実装されているブラウザが殆どないためnullでいい。他にもhistoryオブジェクトにはreplaceStateというメソッドがあり、使い方はpushStateと同じだが、replaceStateメソッドは現在の履歴を書き換えるだけで履歴は追加しない。
ここまで、Historyオブジェクトについて簡単に見てきたが、pushStateで履歴を追加しURLを変えても実際にそのURLにアクセスされるわけではない。そこでAjaxを使い、ページを遷移さるというわけである。
pjaxの流れと気をつける点
今回作ったサンプルを元に流れを見ていく。
大まかな流れとしては以下のような感じである。
- ユーザーがリンクからページを移動しようとする。
- pushStateを使って移動先のURLに変え、AjaxでそのURLのサーバーへリクエストする。
- サーバーから返ってきたデータを表示する。
流れは殆どAjaxで遷移する時と変わらない。しかし、pjaxにはいくらかの気をつけるべき点がある。気をつける点は以下。
- ユーザーが戻る進むボタンなどで履歴を移動した時もサーバーにリクエストする。
- pjaxを使わない通常のアクセスでも問題なくページを表示できるようにする。
- URLが変わるため、遷移しない部分のa要素のhref属性は絶対パスで指定する。
- ページ毎のscript要素を動的に実行する。
1については、popStateイベントを使う。2についてはそのままの意味で、リンク先に直接アクセスしたらテキストしか表示されないというのではユーザービリティに欠けるので、全部HTMLを用意する。3もそのままの意味で、もし非同期遷移しない部分のa要素を相対パスで指定しているとpjaxでURLが変わるので想定していないリンクを指すことになる。2、3については設計の問題で、pjaxを想定したHTMLを書かなければいけないためプログラム自体の汎用性が低くなる。今回は自分の力量不足もあってHTMLに頼っている部分も多い。サーバーサイドと組み合わせたりしてプログラムで制御するような設計にすればある程度汎用性は高くなると思う。4については、非同期遷移する際の問題点で、非同期遷移で得たscript要素は自動的に発火しないので、自分で実行する必要がある。これら流れと気をつける点を以下コードを見ながら具体的に書いていく。
実際のコード
ひとまず、サンプルのpjax.jsのコードを以下に表示する。
(function() { var id = 'main'; //非同期遷移する要素のid; var transitionElement = document.getElementById(id); var display = new Display(transitionElement); var xhr = new XMLHttpRequest(); //履歴を上書き; (function() { var url = location.href.replace(/index\..+/, ''); history.replaceState({url: url}, null, url); })(); //対象のアンカー要素に非同期通信するよう設定; function setRequest() { var anchors = document.querySelectorAll('a[class *= "async"]'); for(var i = 0; i < anchors.length; i++) { anchors[i].onclick = (function(i){ return function(e) { e.preventDefault(); var url = anchors[i].href; url = url.replace(/index\..+/, ''); display.hide(function() { //リンク先と現在のURLが一緒じゃないなら履歴を追加する if(url !== location.href) { history.pushState({url: url}, null, url); } request(url); }); } })(i); } } setRequest(); //履歴を移動した時の処理; window.addEventListener('popstate', function(e) { if(e.state) { display.hide(function() { request(e.state.url); }); } }); function request(url) { xhr.abort(); xhr.open('GET', url); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.responseType = 'document'; xhr.overrideMimeType('text/html; charset=utf-8'); //文字化け対策; xhr.timeout = '10000'; xhr.send(null); } xhr.addEventListener('loadend', function() { if(xhr.status === 200) { var response = xhr.response; var responseElement = response.getElementById(id); //必要な要素を書き換える; document.title = response.title; document.body.id = response.body.id; transitionElement.innerHTML = responseElement.innerHTML; //実行したいスクリプト要素を取得して実行; var scripts = response.querySelectorAll('script[class *= "async"]'); for(var i = 0; i < scripts.length; i++) { var script = cloneScript(scripts[i]); transitionElement.appendChild(script); } setRequest(); display.show(); return; } //読み込みできなかった時の処理; transitionElement.innerHTML = '読み込みに失敗しました。'; display.show(); }); //script要素をコピー; function cloneScript(element) { var script = document.createElement('script'); var attrs = element.attributes; for(var i = 0, len = attrs.length; i < len; i++) { var attr = attrs[i]; script.setAttribute(attr.name, attr.value); } script.innerHTML = element.innerHTML; return script; } //アニメーション; function Display(element) { this.element = element; this.opacity = Number(document.defaultView.getComputedStyle(element, null).opacity); this.hideTimer = null; this.showTimer = null; this.hide = function(func) { clearTimeout(this.showTimer); var style = this.element.style; var css = document.defaultView.getComputedStyle(this.element, null); style.opacity = css.opacity; var that = this; (function recursion() { if(style.opacity <= 0) { style.display = 'none'; if(typeof func === 'function') { func(); } return; } style.opacity = (Number(style.opacity) - 0.1).toFixed(1); that.hideTimer = setTimeout(recursion, 15); })(); }; this.show = function(func) { clearTimeout(this.hideTimer); var style = this.element.style; style.opacity = 0; style.display = 'block'; var that = this; (function recursion() { if(style.opacity >= Number(that.opacity.toFixed(1)) || style.opacity >= 1) { style.opacity = that.opacity; if(typeof func === 'function') { func(); } return; } style.opacity = (Number(style.opacity) + 0.1).toFixed(1); that.showTimer = setTimeout(recursion, 15); })(); }; } })();
始めにHTMLの設計段階で非同期遷移する要素を決める。ここでは、「var id = ‘main’」とあるように「id=”main”」の要素を非同期遷移する。つまり、「id=”main”」の要素とレスポンスで返ってきた「id=”main”」の要素のinnerHTMLを交換するというわけである。基本的に現在のHTMLとレスポンスで返ってくるHTMLの構造は同じだと想定している。では、処理を見ていく。
var url = location.href.replace(/index\..+/, ''); history.replaceState({url: url}, null, url);
まず、初回アクセス時の履歴を書き換える必要がある。これはpopStateイベントの処理の時に詳しく説明するが、後々、ユーザーが戻るボタンを押して現在のページに戻ってきた時にサーバーに適切なリクエストを送るためである。ここでは、index.htmlなど「index.~」の文字は表示しないよう文字列処理を施している。
次に非同期通信したいa要素にクリックイベントをセットする。
//対象のアンカー要素に非同期通信するよう設定; function setRequest() { var anchors = document.querySelectorAll('a[class *= "async"]'); for(var i = 0; i < anchors.length; i++) { anchors[i].onclick = (function(i){ return function(e) { e.preventDefault(); var url = anchors[i].href; url = url.replace(/index\..+/, ''); display.hide(function() { //リンク先と現在のURLが一緒じゃないなら履歴を追加する if(url !== location.href) { history.pushState({url: url}, null, url); } request(url); }); } })(i); } } setRequest(); function request(url) { xhr.abort(); xhr.open('GET', url); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.responseType = 'document'; xhr.overrideMimeType('text/html; charset=utf-8'); //文字化け対策; xhr.timeout = '10000'; xhr.send(null); }
ここではclass属性に「async」というclass名が含まれているa要素にイベントをセットしている。あらかじめHTML側で非同期通信したいa要素にasyncというclass属性を付加している。a要素がクリックされるとpushStateでそのa要素のリンク先のURLに変えて履歴を追加し、リンク先にXMLHttpRequestでリクエストを送る。もし、リンク先が現在のURLと同じであれば履歴は追加しない。ここでは、簡単なアニメーションを使っているのでdisplay.hideというメソッドの中に処理を書いているが、pjaxの流れとはあまり関係がないので無視していい。XMLHttpRequestでリクエストする時は文字化け対策としてxhr.overrideMimeType('text/html; charset=utf-8')
でレスポンスで返ってくるHTMLのMIMEタイプを上書きする処理を書く。このsetRequest関数は非同期遷移後、毎回実行する。そのためonclickでイベントを定義しているが、addEventListenerでイベントを定義したい場合「addEventListenerの無名関数をremoveEventListenerで消す方法」を参照。
次にレスポンス時の処理を見る前に履歴移動を検知するpopStateイベントの処理を見る。
window.addEventListener('popstate', function(e) { if(e.state) { display.hide(function() { request(e.state.url); }); } });
このpopStateイベントのイベントオブジェクトにはstateというプロパティがある。これはhistory.stateと同じものを指している。このstateというのはpushStateやreplaceStateの第一引数にセットしたもので、履歴に付加している情報である。先ほど見たpushStateやreplaceStateの第一引数には「{url: url}」というオブジェクトをセットした。つまり、stateはurlというプロパティを持っていて値はその履歴のURLであるので、このURLにリクエストを送る。そうすれば、そのURLに相応しいドキュメントを非同期で得ることが出来る。pushStateやreplaceStateの第一引数にurl情報を付加すれば、popStateイベントでその情報を元にサーバーにリクエストできるというわけである。
最後にレスポンスが返ってきた時の処理を見る。
xhr.addEventListener('loadend', function() { if(xhr.status === 200) { var response = xhr.response; var responseElement = response.getElementById(id); //必要な要素を書き換える; document.title = response.title; document.body.id = response.body.id; transitionElement.innerHTML = responseElement.innerHTML; //実行したいスクリプト要素を取得して実行; var scripts = response.querySelectorAll('script[class *= "async"]'); for(var i = 0; i < scripts.length; i++) { var script = cloneScript(scripts[i]); transitionElement.appendChild(script); } setRequest(); display.show(); return; } //読み込みできなかった時の処理; transitionElement.innerHTML = '読み込みに失敗しました。'; display.show(); }); //script要素をコピー; function cloneScript(element) { var script = document.createElement('script'); var attrs = element.attributes; for(var i = 0, len = attrs.length; i < len; i++) { var attr = attrs[i]; script.setAttribute(attr.name, attr.value); } script.innerHTML = element.innerHTML; return script; }
ここでは、レスポンスをdocumentで受け取っている。そして、そのdocumentの中から「id="main"」の要素を取得して、innerHTMLで書き換えている。innerHTMLではなく要素をそのまま代入することを試したところ、その要素内にscript要素があった時、chromeではscriptが実行され、IEやFireFoxではscriptは実行されないという結果になった(仕様的にはどちらが正しいんだろう・・・)。個人的には自動的にscript要素を実行してくれたほうが有り難いのだが、innerHTMLで書き換えて実行したいscriptは自分で実行する。ここでは、title要素やbodyのidも書き換えている。そして、実行したいscript要素をcloneScriptで複製して実行する。ここでは、実行したいscript要素はclass名で判別している。あらかじめHTMLで動的に実行したいscript要素のclass属性に「async」というclass名を書いておく。cloneScriptについては、「script要素<script>を動的に実行する」を参照。そして要素の書き換えが完了したら、再びsetRequest関数を実行して、対象のa要素にクリックイベントを定義する。これは、書き換えた要素内に非同期通信したいa要素が含まれているかもしれないからである。
最後に
今回はpjaxの流れを知るためにプログラムを書いてみたが、思った以上に気をつけないといけない点が多い。ここでは見てこなかったが、ソーシャルボタンの再読み込みやAdsenseやAnalyticsなどなど主にscript要素が絡んでいる処理を個別に扱わないといけなかったりする。【1】だからあまり実用的ではないかもしれないが、画面遷移しない、簡単に高速化できるなどメリットは大きい。正直自分はpjaxをバリバリ使っているサイトを見てJavaScriptを始めたほどpjaxの技術に感動した(というかAjaxが9割)。今後はAjaxよりかWebSocketを使って通信する手法が確立されそうだが、いずれにせよHistoryAPIを使った流れを知っておくのはいいかもしれない。pjaxの流れ自体は難しくないので、設計をしっかり考えてサーバーサイドと連携するとこの技術をもっと面白く使えると思う。
とりあえずまたまたGitHubに上げてみた。