How to enhance a static site with pjax and the responseType XHR2 attribute

In this post, we'll study how to progressively enhance a static website (e.g. generated using a SSG like jekyll or wintersmith) by using pjax (HTML5 pushState + AJAX) and responseType (an attribute introduced by XHR2) in 40 lines of vanilla JavaScript.

Introduction

Pjax allows you to progressively enhance a site by loading new content via AJAX requests and updating the browser's URL and history when a user clicks on a link.

The responseType attribute lets you specify the response format of a XHR request. It can be one of the following:

  • arrayBuffer
  • blob
  • document
  • json

The responseType attribute

We'll focus on the document responseType. This will set the response type to be a document object rather than just plain text:

var xhr = new XMLHttpRequest();
xhr.responseType = 'document';

xhr.open('GET', '/');
xhr.onload = function() {
  var title = this.response.querySelector('title');

  console.log(title.textContent);
};

xhr.send();

Copy this example in your dev tools console. This should output:

Alex Normand - Adventures in HTML5, CSS3, JavaScript and the open Web.

You can use this.response the exact same way you would use window.document This is really powerful as you can basically use the xhr's response to replace any DOM node.

var xhr = new XMLHttpRequest();
xhr.responseType = 'document';

xhr.open('GET', '/');
xhr.onload = function() {
  var responseMain = this.response.querySelector('#main');
  var currentMain  = document.querySelector('#main');

  currentMain.parentNode.replaceChild(responseMain, currentMain);
};

xhr.send();

Copy this example in your dev tools, and see what happens then hit your browser's refresh button. The content of the webpage was replaced by the response content. However the browser's URL remained unchanged, this is where history.pushState comes in.

history.pushState

var xhr = new XMLHttpRequest();
xhr.responseType = 'document';

xhr.open('GET', '/');
xhr.onload = function() {
  var responseMain = this.response.querySelector('#main');
  var currentMain  = document.querySelector('#main');

  currentMain.parentNode.replaceChild(responseMain, currentMain);

  history.pushState(null, null, '/');
};

xhr.send();

history.pushState(null, null, '/') pushes '/' onto the session history and updates the browser's URL. We also need to add a popstate event handler to deal with the browser's back button click or a call to history.back(). (see demo code below for an example).

Browser support

ResponseType and history.pushState seem really cool, but are they well supported?

Yes definitely! This technique works in all modern browsers and degrades gracefully in older browsers (i.e. IE9 and old android versions).

Demo

To demonstrate how powerful this is, I've written a simple Moby Dick example. The demo uses the combination of responseType="document" and history.pushState as shown above:

(function(){
  var MAIN_CONTENT_SELECTOR = '#main';

  var find = function(selector, context) {
    return (context || document).querySelector(selector);
  };

  var loadChapter = function(url) {
    var xhr = new XMLHttpRequest();

    xhr.open('GET', url);
    xhr.responseType = 'document';

    xhr.onload = function() {
      var newChapter = find(MAIN_CONTENT_SELECTOR, this.response);
      var newTitle = find('title', this.response).textContent;
      var currentChapter = find(MAIN_CONTENT_SELECTOR);

      find('title').textContent = newTitle;

      currentChapter.parentNode.replaceChild(newChapter, currentChapter);
      find('.chapter').classList.add('animated', 'fadeInUpBig');
      window.scrollTo(0, 0);
    };

    xhr.send();
  };

  if (history && history.pushState) {

    find('body').addEventListener('click', function(e) {

      if (e.target.tagName.toLowerCase() === 'a') {
        e.preventDefault();
        loadChapter(e.target.href);
        history.pushState(null, null, e.target.href);
      }
    });

    setTimeout(function() {
      window.onpopstate = function() {
        loadChapter(window.location.href);
      };
    }, 1000);
  }
}())