pushState + Ajaxで非同期通信を行う

pushState + Ajaxで非同期通信を行う

Ajaxは、WEB2.0という言葉が使われ始めた頃から盛んに使われるようになった技術で JavaScriptと XMLHttpRequestを利用して非同期通信を行い表示されているページ全体を更新することなくページの一部を更新する事ができます。非常に便利なのですがリクエストされた内容にあたるページの URLがブラウザの履歴に追加されない等の問題点もありました。この問題を解決するのが pushStateです。

pushStateについて

pushStateは、HTML5に新たに実装された History APIで JavaScriptから URLをブラウザの履歴に追加、変更、そしてブラウザの戻る、進むのイベントを検知する事が出来ます。これにより Ajaxの非同期通信によるページの内容を変更した際にユーザビリティを損なう事なくシームレスなページの移動が可能になります。

pushStateの使い方

pushStateはブラウザの履歴に URLを追加します。

JavaScript
history.pushState(state, title, url);
引数説明
state 任意のオブジェクトを渡すことができ、popstateイベントを検知した際にイベントハンドラから参照する事ができます。
title タイトルを指定できるようですが現在のところブラウザが未対応なので nullを指定します。
url 読み込みたいページの URLを指定します。相対パス、絶対パスの指定が可能です。

popStateの使い方

popStateはブラウザの戻る、進むのイベントを検知するために使用します。イベントハンドラ内の処理が実行されるタイミングは、ブラウザの戻る、進むボタンが押されてロケーションバーの URLが変更された直後になります。

JavaScript
$(window).on('popstate', function(e){
  
  if(!e.originalEvent.state) return;//サイトへの初回アクセス時に popstateの検知を止める。
});

e.originalEvent.stateが falseの場合に returnする処理は、サイトへの初回アクセス時に popstateの検知を止めるためのものです。これがない場合 一部ブラウザがサイトへの初回アクセス時に popstateを検知してしまい return以降の処理を実行してしまいます。

replaceStateの使い方

replaceStateはブラウザの履歴の置き換えを行います。

JavaScript
history.replaceState(state, title, url);
引数説明
state 履歴に関連するオブジェクトを置き換える事ができます。
title タイトルを指定できるようですが現在のところブラウザが未対応なので nullを指定します。
url 置き換えたい URLを指定します。相対パス、絶対パスの指定が可能です。stateの内容のみを置き換えたい場合は urlは省略可能です。

pushStateと Ajaxを組み合わせてページの内容を変更する

pushStateと Ajaxを組み合わせてページの内容を変更する方法を紹介します。pushStateは履歴の変更、追加を行いページの内容の変更は Ajaxで行います。ページの構成は以下のとおりです。

ページの構成

#siteHeaderのメニューをクリックしてアンカーに新規ウィンドウで開くや、ハッシュでのページ内移動の指定がなければ Ajaxでリンク先のページを読み込ます。読み込みに成功したら #contentsの内容を変更して最後に JavaScriptの初期化(ページ内のアンカー要素を DOMオブジェクトとして再登録)を行います。

JavaScript
$(function(){
  
  
  
/*////////////////////////////////////////////////////////////////////////////////
pushState & Ajax
////////////////////////////////////////////////////////////////////////////////*/
  
  var _pjax = (function(){
    
    'use strict';
    
    if(!window.history || !window.history.pushState) return;
    
    var d = {
      
      action: 'beforeSend',
      content: null,
      dataAttr: {progress: 'data-progress'},
      delay: 500,
      events: null,
      exclude: null,
      fadeDuration: 500,
      hideInLoading: 'hideInLoading',
      progress: 'beforeSend',
      scrollPositionY: null,
      target: '#contents',
      url: null
    };
    
    function _init(){
      
      d.$head = $('head');
      d.$body = $('body');
      d.$target = $(d.target);
      
      $.ajaxPrefilter(function(options, originalOptions, jqXHR){
        
        options.async = true;
      });
      
      history.replaceState({}, null, location.href);
      d.$body.attr('data-ispushstate', 'true');
      
      $(window).on('popstate', function(e){
        
        e.originalEvent.target.history.scrollRestoration = 'manual';
        
        _popState(e);
      });
      
      _addEvents();
    };//_init()
    
    function  _popState(e){
      
      if(!e.originalEvent.state) return;
      
      d.action = 'popstate';
      d.events = e.type;
      d.url = location.href;
      
      _loading(e);
    };//_popState()
    
    function _addEvents(){
      
      $('a').off('click._pjax').on('click._pjax', function(e){
        
        if($(this).attr('href') &&
        $(this).attr('href').match(RegExp(location.origin)) &&
        !$(this).attr('href').match(/^#|\.(png|jpe?g|gif|svg|pdf)/i) &&
        !$(this).is('[target=_blank]') &&
        !$(this).is(d.exclude)){
          
          e.preventDefault();
          
          d.action = 'move';
          d.events = e.type;
          d.url = $(this).attr('href');
          d.scrollPositionY = $(window).scrollTop();
          
          _loading(e);
        }
      });
    };//_addEvents()
    
    function _loading(e){
      
      if(d.events != 'popstate' && d.progress != 'complete' && d.progress != 'beforeSend') return;
      
      _ajaxLoadContents({
        
        type: 'POST',
        url: d.url,
        dataType: 'html',
        timeout: 20000
      });
    }//_loading()
    
    $(document).ajaxSend(function(e, jqXHR, settings){
      
      d.progress = e.type;
             
      if(window.navigator.userAgent.toLowerCase().indexOf('safari') != -1) jqXHR.setRequestHeader('If-Modified-Since', new Date().toUTCString());
    });//ajaxSend()
    
    function _ajaxLoadContents(arg){
      
      var opt = $.extend({}, $.ajaxSettings, arg);
      var jqXHR = $.ajax(opt);
      var defer = $.Deferred();
      
      jqXHR.done(function(content, status, jqXHR){
        
        d.progress = 'done';
        
        if(!content){
          
          _pjaxError(jqXHR, status, {status: 'content is empty'});
          
          return;
        }
        
        d.content = content;
        
        if(d.events != 'popstate'){
          
          history.replaceState({'scrollPositionY': d.scrollPositionY}, null);
          history.pushState({}, null, d.url);
        }
        
        defer.resolveWith(this, arg);
      });//done()
      
      jqXHR.fail(function(jqXHR, status, errorThrown){
        
        _pjaxError(jqXHR, status, errorThrown);
        defer.rejectWith(this, arg);        
      });//fail()
      
      jqXHR.always(function(){});
    };//_ajaxLoadContents()
    
    $(document).ajaxStop(function(e){
      
      d.progress = 'ajaxStop';
        
      d.$target.imagesLoaded(function(e){
        
        d.$target.addClass(d.hideInLoading);
        
        _delay(d.delay).then(function(){
          
          _replacePageBody();
          _replacePageHead();
          
          if(d.action == 'move') $(window).scrollTop(0);
          if(d.action == 'popstate') $(window).scrollTop(history.state.scrollPositionY);
                    
          _addEvents();
          
          return _delay(d.delay);
          
        }).then(function(){
          
          d.$target.removeClass(d.hideInLoading);
          
          return _delay(d.delay);
          
        }).then(function(){
            
          d.progress = 'complete';
          d.content = d.$newContent = null;
        });
      });
    });//ajaxStop()
    
    function _replacePageBody(){
      
      var $newContent = $(d.content).find(d.target);
      
      d.$body.removeClass().addClass(d.content.match(/<body.*class=['"](.*?)['"].*>>/) ? RegExp.$1 : '');
      d.$target.children().remove();
      d.$target.prepend($newContent.children());
    };//_replacePageBody()
    
    function _replacePageHead(){
      
      var $title = $('title');
      var $newHead = $(d.content.match(/<head.*>[\s\S]*<\/head>/)[0]);
      var newTitleText = $newHead.filter($title.selector).text();
      
      $title.html(newTitleText);
      d.$head.children().slice($title.index() + 1).remove();
      d.$head.append($newHead.filter('*').slice($title.index() + 1));
    };//_replacePageHead()
        
    function _pjaxError(jqXHR, status, errorThrown){
      
      console.log('jqXHR', jqXHR);
      console.log('errorThrown', errorThrown);
      
      alert('Error. ページを更新してください。');
    };//_pjaxError()
    
    function _delay(s){
      
      var d = $.Deferred();
      
      setTimeout(function(){
        
        d.resolve();
        
      }, s);
      
      return d.promise();
    };//_delay();
    
    _init();
  }());//_pjax()
});
  • 13行目

    ブラウザが pushStateに対応しているかを判定します。対応している場合は、以降の処理が実行され対応していない場合はここで処理が停止します。window.historyはブラウザの履歴を扱うための historyオブジェクトへの参照を返します。

  • 31行目

    ページへの初回アクセス時に実行します。Ajaxで取得したデータを置き換える場所を jQueryオブジェクトとして定義しています。また、popStateイベントを検知した際に実行する関数の定義や、Ajaxを実行するための関数である _addEvents()の定義を行っています。

  • 44行目

    popStateイベントを検知した際に実行します。e.originalEvent.target.history.scrollRestorationは履歴にあるページ先へ移動した直後に、記憶されていたスクロール位置まで移動するかを決めます。初期値は autoで自動的にスクロール位置まで移動します。manualを指定した場合は、スクロール位置までの移動が行われません。今回は manualに設定していますが目的のページへ移動してページの内容が置き換えられたタイミングでスクロール位置まで移動させるようにします。

  • 55行目

    popStateを検知した際に実行する処理です。!e.originalEvent.stateは一部のブラウザが初回アクセス時に popStateを検知してしまう場合の対策です。

  • 65行目

    Ajaxを実行するためのトリガーを設定しています。anchor要素をクリックして条件が一致する場合に e.preventDefault()でクリックイベントをキャンセルして Ajaxによる通信を行うための関数 _loading()を実行します。条件は、ドメイン内のページ移動である場合、ハッシュや画像または PDFファイルではない場合、新規タブ(ウィンドウ)で開いて表示しない場合に実行させます。

  • 88行目

    _loading()内の _ajaxLoadContents()は Ajax通信の開始から終了後に行うページ内容の変更までをまとめた関数です。popState以外で既に Ajax通信が行われている場合は、_ajaxLoadContents()は実行されません。

  • 101行目

    ajaxSendはリクエストの送信の直前に実行されます。リクエストの送信が POSTで行われ、ブラウザがSafariの場合に「Failed to load resource: the server responded with a status of 412 (Precondition Failed)」というエラーが出る場合があるのでそのための対策です。

  • 108行目

    Ajax通信が成功した場合、処理は done()へ移行します。取得したデータは d.contentに代入されますがこの時点では URLやページの内容の変更は行いません。ここでは変更前にページのスクロール位置を replaceState()で記憶させ、その後に pushState()で URLの変更を行います。fail()はAjax通信が失敗した場合に実行され、always()は Ajax通信が成功、失敗のどちらの場合でも実行されます。

  • 145行目

    ajaxStop()はページ内で行われている全ての Ajax通信が完了した時に実行されます。今回は Ajax通信によるページの内容の変更は1か所なので連続で通信が行われた場合、最後の通信結果をページへ反映させるため ajaxStop()内で _replacePageBody()と_replacePageHead()を実行して head要素内、body要素内の HTMLを変更してページの内容の変更が完了します。次に通常のページ移動の場合はページの最上部まで移動させ popStateの場合は history.stateからスクロール位置を取得して移動させます。最後に _addEvents()を実行して Ajax通信の実行のトリガーになる要素を DOMオブジェクトとして登録します。

  • 179行目

    body要素の class属性の変更と body要素内の #contentsが指定された要素内の全てを変更します。

  • 188行目

    head要素内の HTMLを変更します。Ajax通信で取得したデータから title要素のテキストを拾い変更します。さらに title要素より下に記述しておいた新しいページに必要な CSSや JavaScriptのファイル、関数、変数などを拾い全て変更します。

デモ pushStateと Ajaxの組み合わせでページの一部を変更する

ヘッダーメニューをクリックすると新しいページを読み込み、ページ内の一部内容を変更してブラウザの履歴に登録します。

ページの先頭へ