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.
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 |
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 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.
|
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.
|
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.
|
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.
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.
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.