ONJava.com    
 Published on ONJava.com (http://www.onjava.com/)
 See this if you're having trouble printing code examples


AJAX: How to Handle Bookmarks and Back Buttons

by Brad Neuberg
10/26/2005

This article presents an open source JavaScript library that finally brings bookmarking and back button support to AJAX applications. By the end of this tutorial, developers will have a solution to an AJAX problem that not even Google Maps or Gmail possesses: robust, usable bookmarking and back and forward behavior that works exactly like the rest of the Web.

"AJAX: How to Handle Bookmarks and Back Buttons" explains the significant issues that AJAX applications currently face with bookmarks and the back button; presents the Really Simple History library, an open source framework that solves these problems; and provides several working examples.

The principal discoveries of the framework presented in this article are twofold. First, a hidden HTML form is used to allow for a large transient session cache of client-side information; this cache is robust against navigation to and away from the page. Second, a combination of hyperlink anchors and hidden iframes is used to intercept and record browser history events, tying into the back and forward buttons. Both techniques are wrapped with a simple JavaScript library to ease development.

The Problem

Bookmarks and the back button work great for traditional, multi-page web applications. As users surf websites, their browsers' location bars update with new URLs that can be pasted into emails or bookmarked for later use. The back and forward buttons also function correctly and shuffle users between the pages they have visited.

Related Reading

XML Hacks
100 Industrial-Strength Tips and Tools
By Michael Fitzgerald

AJAX applications are unusual, however, in that they are sophisticated programs that live within a single web page. Browsers were not built for such beasts--they are trapped in the past, when web applications involved pulling completely fresh pages on every mouse click.

In such AJAX software as Gmail, the browser's location bar stays exactly the same as users select functions and change the application's state, making bookmarking into specific application views impossible. Further, if users press their back buttons to "undo" a previous action, they will find to their surprise that the browser completely leaves the application's web page.

The Solution

The open source Really Simply History framework (RSH) solves these issues, bringing bookmarking and control over the back and forward buttons to AJAX applications. RSH is currently in beta and works with Firefox 1.0, Netscape 7+, and Internet Explorer 6+; Safari is not currently supported (for an explanation, see my weblog entry " Coding in Paradise: Safari: No DHTML History Possible").

Several AJAX frameworks currently exist to help with bookmarking and history issues; all of these frameworks, however, suffer from several important bugs due to their implementations (see " Coding in Paradise: AJAX History Libraries" for details). Further, many AJAX history frameworks are monolithically bundled into larger libraries, such as Backbase and Dojo; these frameworks introduce significantly different programming models for AJAX applications, forcing developers to adopt entirely new approaches to gain history functionality.

In contrast, RSH is a simple module that can be included into existing AJAX systems. Further, the Really Simple History library uses techniques to avoid the bugs that affect other history frameworks.

The Really Simple History framework consists of two JavaScript classes, named DhtmlHistory and HistoryStorage.

The DhtmlHistory class provides a history abstraction for AJAX applications. AJAX pages add() history events to the browser, specifying new locations and associated history data. The DhtmlHistory class updates the browser's current URL using an anchor hash, such as #new-location, and associates history data with this new URL. AJAX applications register themselves as history listeners, and as the user navigates with the back and forward buttons, history events are fired that provide the browser's new location and any history data that was persisted with an add() call.

The second class, named HistoryStorage, allows developers to store an arbitrary amount of saved history data. In normal pages, when a user navigates to a new website, the browser unloads and clears out all application and JavaScript state on the web page; if the user returns using the back button, all data is lost. The HistoryStorage class solves this problem through an API containing simple hash table methods such as put(), get(), and hasKey(). These methods allow developers to store an arbitrary amount of data after the user has left a web page; when the user returns using the back button, the data can be accessed through the HistoryStorage class. We internally achieve this using a hidden form field, taking advantage of the fact that browsers autosave the values in form fields even after a user has left a web page.

Example

Let's jump right in with a simple example.

First, any page that wishes to use the Really Simple History framework must include the dhtmlHistory.js script:

<!-- Load the Really Simple 
     History framework -->
<script type="text/javascript"
        src="../../framework/dhtmlHistory.js">
</script>

DHTML History applications must also include a special file named blank.html in the same directory as their AJAX web page; this file is bundled with the Really Simple History framework and is needed by Internet Explorer. As a side note, RSH uses a hidden iframe to track and add history changes in Internet Explorer; this iframe requires that we point to a real location for the functionality to work correctly, hence blank.html.

The RSH framework creates a global object named dhtmlHistory that is the entry point for manipulating the browser's history. The first step in working with dhtmlHistory is initializing the object after the page has finished loading:

window.onload = initialize;
    
function initialize() {
  // initialize the DHTML History
  // framework
  dhtmlHistory.initialize();

Next, developers use the dhtmlHistory.addListener() method to subscribe to history change events. This method takes a single JavaScript callback function that will receive two arguments when a DHTML history change event occurs: the new location of the page, and any optional history data that might be associated with this event:

window.onload = initialize;
    
function initialize() {
  // initialize the DHTML History
  // framework
  dhtmlHistory.initialize();
  
  // subscribe to DHTML history change
  // events
  dhtmlHistory.addListener(historyChange);

The historyChange() method is straightforward, and consists of a function that receives the newLocation after a user has navigated to a new location, as well as any optional historyData that was associated with the event:

/** Our callback to receive history change
     events. */
function historyChange(newLocation, 
                       historyData) {
  debug("A history change has occurred: "
        + "newLocation="+newLocation
        + ", historyData="+historyData, 
        true);
}

The debug() method used above is a utility function defined in the example's source file, bundled with the full example download. debug() simply prints a message into the web page; the second Boolean argument, true in the code above, controls whether all pre-existing messages are cleared before the new debug message is printed.

A developer adds history events using the add() method. Adding a history event involves specifying a new location for the history change, such as "edit:SomePage", as well as providing an optional historyData value that will be stored with this event:

window.onload = initialize;
    
function initialize() {
  // initialize the DHTML History
  // framework
  dhtmlHistory.initialize();
  
  // subscribe to DHTML history change
  // events
  dhtmlHistory.addListener(historyChange);
      
  // if this is the first time we have
  // loaded the page...
  if (dhtmlHistory.isFirstLoad()) {
    debug("Adding values to browser "
          + "history", false);
    // start adding history
    dhtmlHistory.add("helloworld", 
                     "Hello World Data");
    dhtmlHistory.add("foobar", 33);
    dhtmlHistory.add("boobah", true);
      
    var complexObject = new Object();
    complexObject.value1 = 
                  "This is the first value";
    complexObject.value2 = 
                  "This is the second data";
    complexObject.value3 = new Array();
    complexObject.value3[0] = "array 1";
    complexObject.value3[1] = "array 2";
      
    dhtmlHistory.add("complexObject", 
                     complexObject);

Immediately after add() is called, the new location will be shown to the user in the browser's URL toolbar as an anchor value. For example, after calling dhtmlHistory.add("helloworld", "Hello World Data") for an AJAX web page that lives at http://codinginparadise.org/my_ajax_app, the user would see the following in their browser's URL toolbar:

http://codinginparadise.org/my_ajax_app#helloworld

They can then bookmark this page; later, if they use this bookmark, your AJAX application can read the #helloworld value and use it to initialize the web page. Location values after the hash are URL encoded and decoded transparently by the Really Simple History framework.

historyData is useful for saving more complicated state with an AJAX location change than what can easily fit on a URL. It is an optional value that can be any JavaScript type, such as a Number, String, or Object. One example use of this is saving all of the text in a rich text editor, for example, if the user navigates away from the page. When a user navigates back to this location, the browser will return the object to the history change listener.

Developers can provide a full JavaScript object for historyData, with nested objects and arrays representing complex state; whatever is allowed by JSON (JavaScript Object Notation) is allowed in the history data, including simple data types and the null type. References to DOM objects and scriptable browser objects like XMLHttpRequest, however, are not saved. Note that historyData is not persisted with bookmarks, and disappears if the browser is closed, if the browser's cache is cleared, or if the user erases the history.

The last step in working with dhtmlHistory is the isFirstLoad() method. In some browsers, if you navigate to a web page, jump to a different page, and then press the back button to return to the initial site, the first page will completely reload and fire an onload event. This can create havoc with code that wants to initialize the page in a certain way the first time it loads, but not on subsequent reloads of the page. The isFirstLoad() method makes it possible to differentiate between the very first time a web page has loaded versus a false load event fired if the user navigates back to a web page saved in his or her browser's history.

In the example code, we only want to add history events the first time a page has loaded; if the user presses the back button to return to the page after it has loaded, we do not want to re-add all of the history events:

window.onload = initialize;
    
function initialize() {
  // initialize the DHTML History
  // framework
  dhtmlHistory.initialize();
  
  // subscribe to DHTML history change
  // events
  dhtmlHistory.addListener(historyChange);
      
  // if this is the first time we have
  // loaded the page...
  if (dhtmlHistory.isFirstLoad()) {
    debug("Adding values to browser "
          + "history", false);
    // start adding history
    dhtmlHistory.add("helloworld", 
                     "Hello World Data");
    dhtmlHistory.add("foobar", 33);
    dhtmlHistory.add("boobah", true);
      
    var complexObject = new Object();
    complexObject.value1 = 
                  "This is the first value";
    complexObject.value2 = 
                  "This is the second data";
    complexObject.value3 = new Array();
    complexObject.value3[0] = "array 1";
    complexObject.value3[1] = "array 2";
      
    dhtmlHistory.add("complexObject", 
                     complexObject);

Let's move on to using the historyStorage class. Like dhtmlHistory, historyStorage exposes its functionality through a single, global object named historyStorage. This object has several methods that simulate a hash table, such as put(keyName, keyValue), get(keyName), and hasKey(keyName). Key names must be strings, while key values can be sophisticated JavaScript objects or even strings filled with XML. In our example source code, we put() simple XML into historyStorage the first time the page is loaded:

window.onload = initialize;
    
function initialize() {
  // initialize the DHTML History
  // framework
  dhtmlHistory.initialize();
  
  // subscribe to DHTML history change
  // events
  dhtmlHistory.addListener(historyChange);
      
  // if this is the first time we have
  // loaded the page...
  if (dhtmlHistory.isFirstLoad()) {
    debug("Adding values to browser "
          + "history", false);
    // start adding history
    dhtmlHistory.add("helloworld", 
                     "Hello World Data");
    dhtmlHistory.add("foobar", 33);
    dhtmlHistory.add("boobah", true);
      
    var complexObject = new Object();
    complexObject.value1 = 
                  "This is the first value";
    complexObject.value2 = 
                  "This is the second data";
    complexObject.value3 = new Array();
    complexObject.value3[0] = "array 1";
    complexObject.value3[1] = "array 2";
      
    dhtmlHistory.add("complexObject", 
                     complexObject);
                     
    // cache some values in the history
    // storage
    debug("Storing key 'fakeXML' into " 
          + "history storage", false);
    var fakeXML = 
      '<?xml version="1.0" '
      +      'encoding="ISO-8859-1"?>'
      +      '<foobar>'
      +         '<foo-entry/>'
      +      '</foobar>';
    historyStorage.put("fakeXML", fakeXML);
  } 

Afterwards, if the user navigates away from the page and then returns via the back button, we can extract our stored value using the get() method or check for its existence using hasKey():

window.onload = initialize;
    
function initialize() {
  // initialize the DHTML History
  // framework
  dhtmlHistory.initialize();
  
  // subscribe to DHTML history change
  // events
  dhtmlHistory.addListener(historyChange);
      
  // if this is the first time we have
  // loaded the page...
  if (dhtmlHistory.isFirstLoad()) {
    debug("Adding values to browser "
          + "history", false);
    // start adding history
    dhtmlHistory.add("helloworld", 
                     "Hello World Data");
    dhtmlHistory.add("foobar", 33);
    dhtmlHistory.add("boobah", true);
      
    var complexObject = new Object();
    complexObject.value1 = 
                  "This is the first value";
    complexObject.value2 = 
                  "This is the second data";
    complexObject.value3 = new Array();
    complexObject.value3[0] = "array 1";
    complexObject.value3[1] = "array 2";
      
    dhtmlHistory.add("complexObject", 
                     complexObject);
                     
    // cache some values in the history
    // storage
    debug("Storing key 'fakeXML' into " 
          + "history storage", false);
    var fakeXML = 
      '<?xml version="1.0" '
      +      'encoding="ISO-8859-1"?>'
      +      '<foobar>'
      +         '<foo-entry/>'
      +      '</foobar>';
    historyStorage.put("fakeXML", fakeXML);
  } 
  
  // retrieve our values from the history
  // storage
  var savedXML = 
              historyStorage.get("fakeXML");
  savedXML = prettyPrintXml(savedXML);
  var hasKey = 
           historyStorage.hasKey("fakeXML");
  var message =
    "historyStorage.hasKey('fakeXML')="
    + hasKey + "<br>"
    + "historyStorage.get('fakeXML')=<br>"
    + savedXML;
  debug(message, false);
}

prettyPrintXml() is a utility method defined in the full example source code; this function prepares the simple XML to be displayed to the web page for debugging.

Note that data is only persisted in terms of this page's history; if the browser is closed, or if the user opens a new window and types in the AJAX application's address again, this history data is not available to the new web page. History data is only persisted in terms of the back and forward buttons, and disappears when the user closes the browser or clears the cache. For true, long-term persistence, see the Ajax MAssive Storage System (AMASS).

Our simple example is now finished. Demo it or download the full source code.

Example 2: O'Reilly Mail

Our second example is a simple, fake AJAX email application named O'Reilly Mail, similar to Gmail. O'Reilly Mail illustrates how to control the browser's history using the dhtmlHistory class, and how to cache history data using the historyStorage object.

The O'Reilly Mail user interface has two pieces. On the left side of the page is a menu with different email folders and options, such as Inbox, Drafts, etc. When a user selects a menu item, such as the Inbox, we update the right side of the page with this menu item's contents. In a real application, we would remotely fetch and display the selected mailbox's contents; in O'Reilly Mail, however, we simply display the option that was selected.

O'Reilly Mail uses the Really Simple History framework to add menu changes to the browser's history and update the location bar, allowing users to bookmark the application and to jump to previous menu changes using the browser's back and forward buttons.

We add one special menu option, Address Book, to illustrate how historyStorage might be used. The address book is a JavaScript array of contact names and email addresses; in a real application we would fetch this from a remote server. In O'Reilly Mail, however, we create this array locally, add a few names and email addresses, and then store it into the historyStorage object. If the user leaves the web page and then returns, the O'Reilly Mail application retrieves the address book from the cache, rather than having to contact the remote server again.

The address book is stored and retrieved in our initialize() method:

/** Our function that initializes when the page
    is finished loading. */
function initialize() {
   // initialize the DHTML History framework
   dhtmlHistory.initialize();
   
   // add ourselves as a DHTML History listener
   dhtmlHistory.addListener(handleHistoryChange);

   // if we haven't retrieved the address book
   // yet, grab it and then cache it into our
   // history storage
   if (window.addressBook == undefined) {
      // Store the address book as a global
      // object.
      // In a real application we would remotely
      // fetch this from a server in the
      // background.
      window.addressBook =
         ["Brad Neuberg 'bkn3@columbia.edu'",
          "John Doe 'johndoe@example.com'",
          "Deanna Neuberg 'mom@mom.com'"];
          
      // cache the address book so it exists
      // even if the user leaves the page and
      // then returns with the back button
      historyStorage.put("addressBook",
                         addressBook);
   }
   else {
      // fetch the cached address book from
      // the history storage
      window.addressBook = 
               historyStorage.get("addressBook");
   }

The code to handle history changes is also straightforward. In the source below, handleHistoryChange is called when the user presses either the back or forward button. We take the newLocation and use it to update our user interface to the correct state, using a utility method O'Reilly Mail defines named displayLocation.

/** Handles history change events. */
function handleHistoryChange(newLocation, 
                             historyData) {
   // if there is no location then display
   // the default, which is the inbox
   if (newLocation == "") {
      newLocation = "section:inbox";
   }
   
   // extract the section to display from
   // the location change; newLocation will
   // begin with the word "section:" 
   newLocation = 
         newLocation.replace(/section\:/, "");
   
   // update the browser to respond to this
   // DHTML history change
   displayLocation(newLocation, historyData);
}

/** Displays the given location in the 
    right-hand side content area. */
function displayLocation(newLocation,
                         sectionData) {
   // get the menu element that was selected
   var selectedElement = 
            document.getElementById(newLocation);
            
   // clear out the old selected menu item
   var menu = document.getElementById("menu");
   for (var i = 0; i < menu.childNodes.length;
                                          i++) {
      var currentElement = menu.childNodes[i];
      // see if this is a DOM Element node
      if (currentElement.nodeType == 1) {
         // clear any class name
         currentElement.className = "";
      }                                       
   } 
   
   // cause the new selected menu item to
   // appear differently in the UI
   selectedElement.className = "selected";
   
   // display the new section in the right-hand
   // side of the screen; determine what 
   // our sectionData is
   
   // display the address book differently by
   // using our local address data we cached
   // earlier
   if (newLocation == "addressbook") {
      // format and display the address book
      sectionData = "<p>Your addressbook:</p>";
      sectionData += "<ul>";
      
      // fetch the address book from the cache
      // if we don't have it yet
      if (window.addressBook == undefined) {
         window.addressBook = 
               historyStorage.get("addressBook");
      }
      
      // format the address book for display
      for (var i = 0; 
               i < window.addressBook.length;
                     i++) {
         sectionData += "<li>"
                        + window.addressBook[i]
                        + "</li>";                  
      }
      
      sectionData += "</ul>";
   }
   
   // If there is no sectionData, then 
   // remotely retrieve it; in this example
   // we use fake data for everything but the
   // address book
   if (sectionData == null) {
      // in a real application we would remotely
      // fetch this section's content
      sectionData = "<p>This is section: " 
         + selectedElement.innerHTML + "</p>";  
   }
   
   // update the content's title and main text
   var contentTitle = 
         document.getElementById("content-title");
   var contentValue =
         document.getElementById("content-value");
   contentTitle.innerHTML = 
                        selectedElement.innerHTML;
   contentValue.innerHTML = sectionData;
}

Demo O'Reilly Mail or download the O'Reilly Mail source code.

Conclusion

You have now learned to use the Really Simple History API to make your AJAX applications respect bookmarks and the back and forward buttons, and have example code that can be used as scaffolding for creating your own applications. I look forward to seeing your AJAX inventions out in the wild, complete with bookmarks and history support.

Acknowledgements

Special thanks to everyone who reviewed this article and the Really Simple History framework: Michael Eakes, Jeremy Sevareid, David Barrett, Brendon Wilson, Dylan Parker, Erik Arvidsson, Alex Russell, Adam Fisk, Alex Lynch, Joseph Hoang Do, Richard MacManus, Garret Wilson, Ray Baxter, Chris Messina, and David Weekly.

Resources

Brad Neuberg has done extensive work in the open source community, contributing code to Mozilla, JXTA, the Jakarta Feed Parser, and more.


Return to ONJava.com.

Copyright © 2009 O'Reilly Media, Inc.