This is my personal blog. The views expressed on these pages are mine alone and not those of my employer.

Thursday, September 15, 2005

AJAX: How to Handle Bookmarks and Back Buttons, Advanced Example

This blog post walks users through advanced usage of the Really Simple History framework, providing ancillary information for the soon to be published O'Reilly Network article "AJAX: How to Handle Bookmarks and Back Buttons." The Really Simple History framework consists of two open source JavaScript classes, DhtmlHistory and HistoryStorage. These two classes make it possible for AJAX applications to support bookmarking and the back and forward buttons.

To begin, let's see what our application will look like when finished. We will create an AJAX web page with a series of topic links; when topics are selected, their contents will be remotely loaded and shown on the right-hand side of the page without performing a full page refresh:

Advanced Example Screenshot, showing menu of topics on the left and selected topic content on the right

Figure 1. Advanced Example Screenshot (Click for Screen cast of User Interacting With Example)

The example is somewhat arbitrary and could be created with other, simpler technologies, such as using an iframe and hyperlinks that target the iframe. This example, however, is straightforward enough to illustrate how to use the Really Simple History API in an advanced way for controlling history, providing bookmarking, and caching state that is expensive to reload from the server. It is meant to provide cut and paste code for more advanced AJAX applications.

The example application consists of four major files: advanced.html, advanced.css, advanced.js, and topics.xml. We also use three frameworks to accelerate development: the DhtmlHistory and HistoryStorage APIs; the X DHTML Library, an open source toolkit that eases cross-browser DHTML; and Sarissa, an open source API for working with XmlHttpRequest and XML.

As users select topics in the left hand menu of our sample application, we fetch the topic's contents in the background and use the DhtmlHistory API to record bookmarkable history. When the page is initially loaded, we retrieve the list of available topics from an XML file and cache them locally using the HistoryStorage API, using this XML file to build up our user interface dynamically.

Let's begin with the XML file that holds our list of available sidebar topics, named topics.xml; we will use this file to create our initial user interface:

<?xml version="1.0" encoding="ISO-8859-1"?>

<topics>
<topic id="topic1"
title="Topic 1"
src="topic1.html"
default="true"/>

<topic id="topic2"
title="Topic 2"
src="topic2.html"/>

<topic id="topic3"
title="Topic 3"
src="topic3.html"/>
</topics>

This is a simple XML file that records each available topic. We assign each topic several attributes, such as id and title, that we will later extract using JavaScript to build up the left-hand menu sidebar. Using an XML file in our application is meant to simulate more sophisticated AJAX pages that need to cache complex resources.

Next, we create the scaffolding for advanced.html. We declare our HTML doctype and link in our JavaScript libraries:

<!DOCTYPE HTML PUBLIC
"-//W3C//DTD HTML 4.0 Strict//EN">

<html>
<head>
<!-- The X Cross-Browser DHTML Library -->
<script language="JavaScript"
src="../lib/x/x_core.js">
</script>
<script language="JavaScript"
src="../lib/x/x_dom.js">
</script>
<script language="JavaScript"
src="../lib/x/x_event.js">
</script>

<!-- Load the DhtmlHistory and
HistoryStorage APIs -->
<script type="text/javascript"
src="../lib/history/serializer.js">
</script>
<script type="text/javascript"
src="../lib/history/historyStorage.js">
</script>
<script type="text/javascript"
src="../lib/history/dhtmlHistory.js">
</script>

<!-- The Sarissa Library -->
<script type="text/javascript"
src="../lib/sarissa/sarissa.js">
</script>

<!-- Our application's JavaScript -->
<script type="text/javascript"
src="advanced.js"></script>

We create our basic HTML markup and style it using an open source, two-column layout, Cascading Style Sheets (CSS) template. We store the CSS separate from the HTML in advanced.css (view source) to ease maintenence. The complete advanced.html:

<html>
<head>
<!-- The X Cross-Browser DHTML Library -->
<script language="JavaScript"
src="../lib/x/x_core.js">
</script>
<script language="JavaScript"
src="../lib/x/x_dom.js">
</script>
<script language="JavaScript"
src="../lib/x/x_event.js">
</script>

<!-- Load the DhtmlHistory and
HistoryStorage APIs -->
<script type="text/javascript"
src="../lib/history/serializer.js">
</script>
<script type="text/javascript"
src="../lib/history/historyStorage.js">
</script>
<script type="text/javascript"
src="../lib/history/dhtmlHistory.js">
</script>

<!-- The Sarissa Library -->
<script type="text/javascript"
src="../lib/sarissa/sarissa.js">
</script>

<!-- Our application's JavaScript -->
<script type="text/javascript"
src="advanced.js"></script>

<!-- Link in style sheets -->
<link rel="stylesheet"
type="text/css"
href="advanced.css">
</link>
</head>

<body>
<div id="content">
<h1 id="topicTitle">
Topic Title
</h1>

<div id="topicContent">
Topic Content Goes Here
</div>
</div>

<div id="menu">
</div>
</body>
</html>

Let's move on to advanced.js. First, we add an onload listener to the window, calling a method named initialize() when the page has finished loading:

// initialize ourselves when the page is finished
// loading
window.onload = initialize;

Much of the bulk of our application is in initialize(). Let's see the full initialize() method, which we will break down and go over piece by piece:

// an array of our topics
var topics = new Array();

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();

// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}
else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();

// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);

// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);

// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);
}

In initialize(), we first create and instantiate the DhtmlHistory library by calling dhtmlHistory.initialize():

// an array of our topics
var topics = new Array();

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();


// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}
else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();

// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);

// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);

// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);
}

In initialize(), we must differentiate between the first time this page has loaded versus the user navigating back to it in their history; initialize() is called in both instances. We use the dhtmlHistory.isFirstLoad() method to check for this.

If this is the first time the page has loaded, we call a method named loadTopics() to retrieve the list of available topics. loadTopics() uses the XmlHttpRequest object to remotely fetch the topics.xml file, parse out its values, and then uses them to create a JavaScript array containing each topic value. loadTopics() returns an array of available topics, which we persist into the History Storage API under the hashtable key "topics". If the user navigates away from this page and then back, the value will still be available inside the historyStorage class:

// an array of our topics
var topics = new Array();

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();

// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}

else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();

// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);

// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);

// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);
}

Let's break out the loadTopics() method. This method first fetches the remote topics.xml file using the Sarissa utility framework; Sarissa will automatically handle cross-browser differences in terms of fetching the file and rendering it into an XML DOM object that we can work with easily. Next, we parse out the values in our XML file into a JavaScript array that represents all of the available topics. The full loadTopics() method:

function loadTopics() {
// load our remote topics.xml document
// synchronously into an XML DOM object
var topicsXML =
Sarissa.getDomDocument();
topicsXML.async = false;
topicsXML.load("topics.xml");

// parse out our topics from the XML,
// building up a JavaScript array that
// mirrors these values
var topics = new Array();
var topicElements =
topicsXML.getElementsByTagName(
"topic");
for (var i = 0;
i < topicElements.length;
i++) {
var currentTopic = new Object();
currentTopic.id =
topicElements[i].getAttribute(
"id");
currentTopic.title =
topicElements[i].getAttribute(
"title");
currentTopic.src =
topicElements[i].getAttribute(
"src");
currentTopic.isDefault =
topicElements[i].getAttribute(
"default");

if (currentTopic.isDefault == null ||
currentTopic.isDefault
== undefined)
currentTopic.isDefault = false;

// add a toString() method for
// debugging
currentTopic.toString = function() {
return "[id="+this.id
+ ", title="+this.title
+ ", src="+this.src
+ ", isDefault="
+ this.isDefault
+ "]";
};

topics.push(currentTopic);
}

return topics;
}

Once we have retrieved the available topics, either remotely on the first page load or by retrieval from historyStorage on subsequent navigations back to this page, we can display our list of available topics. We use the displayTopicsList() method to do so; this method uses the topics array to simply update the DOM with available topics:

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();

// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}
else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();


// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);

// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);

// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);
}

The displayTopicsList() method:

function displayTopicsList() {
var menu =
document.getElementById("menu");
for (var i = 0; i < topics.length;
i++) {
// use each topic to update
// our user interface
var newTopic =
document.createElement("a");

newTopic.href = topics[i].src;
newTopic.title = topics[i].title;
// note: avoid using the id attribute of
// hyperlinks if they will clash with a
// location you store into history; if these
// values are the same you can run into
// some strange bugs in Internet Explorer;
// to avoid this, we use setAttribute with
// a custom attribute named "topicID"
newTopic.setAttribute("topicID", topics[i].id);
newTopic.innerHTML = topics[i].title;

menu.appendChild(newTopic);
}
}

Our application must support being loaded from bookmarks. To do so, we must determine our initial location, parse out values after the anchor hash, and use these to initialize our application. For example, if the user bookmarks our example page as http://codinginparadise.org/example.html#topic3, then we will display the third topic when this bookmark is selected.

In initialize(), when the first page loads, we must use the dhtmlHistory.getCurrentLocation() method to get the current location after the hash value; if there is none then an empty string is returned. We pass the initial value to the displayTopic() method to show an initial application state on page load:

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();

// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}
else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();

// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);


// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);

// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);
}

The displayTopic() method takes a topic ID as input, such as "topic1", and either loads it from the server remotely or retrieves it from our historyStorage cache if we have loaded it already. If the user does not specify a topic on page load, then we simply display the default topic, which we specified in our topics.xml file with a default="true" attribute. displayTopic() uses the historyStorage.hasKey() method to determine if we have already loaded the HTML content for this topic; if we have, we can simply retrieve it locally without having to do a full server refresh. If not, we use the Sarissa framework to retrieve the page remotely. Once the page contents are loaded, we can swap their values into the user interface. The full displayTopic() method:

function displayTopic(topicID) {
var topic;

// if no topic passed in then get the
// default topic
if (topicID == null ||
topicID == "") {
for (var i = 0; i < topics.length;
i++) {
if (topics[i].isDefault) {
topic = topics[i];
break;
}
}
}
else {
// fetch the topic with this ID
for (var i = 0; i < topics.length;
i++) {
if (topics[i].id == topicID) {
topic = topics[i];
break;
}
}
}

// see if we have cached the contents
// of this topic in our history storage
var content;
if (historyStorage.hasKey(topic.id)) {
content = historyStorage.get(
topic.id);
}
else {
// get the filename to load
var url = topic.src;

// load this file synchronously
var request = new XMLHttpRequest();
request.open("GET", url, false);
request.send(null);
content = request.responseText;

// persist this value into our
// history storage
historyStorage.put(topic.id, content);
}

// now place this content and title
// into the HTML
var topicTitle =
document.getElementById("topicTitle");
topicTitle.innerHTML = topic.title;
var topicContent =
document.getElementById(
"topicContent");
topicContent.innerHTML = content;

return content;
}

In initialize(), we must also register ourselves for onclick events so that we can respond when a user selects a new topic. We do this using xAddEventListener(), a utility method from the X DHTML library for cross-browser event subscription. xAddEventListener takes four arguments:

xAddEventListener(element, eventType, handler, useW3cBubbling)

element is the element we are subscribing to; eventType is a string containing the type of event to listen to, such as "click" or "load" events; handler is a reference to a function that will receive the event when it occurs; and isW3cBubbling is an advanced option that controls whether to use W3C style bubbling. W3C style bubbling is not well supported cross browser, so this value should always be false.

To receive new topic changes, we use the following code:

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();

// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}
else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();

// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);

// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);


// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);
}

We wire mouse clicks on the topics to a handler method named
handleTopicChange(). handleTopicChange()
is called when a user clicks on a topic hyperlink in the
sidebar:

function handleTopicChange(e) {
var evt = new xEvent(e);
var target = evt.target;
var topicID = target.getAttribute("topicID");

// display this topic
var content = displayTopic(topicID);

// add this to our history
dhtmlHistory.add(topicID, content);

// cancel the default behavior of hyperlinks
return evt.cancel();
}

This method first creates a cross-browser event object using the
X DHTML library's xEvent utility object.
xEvent takes a browser specific event object and
normalizes working with events, exposing standard properties such
as 'target' to get the target:

function handleTopicChange(e) {
var evt = new xEvent(e);
var target = evt.target;
var topicID = target.getAttribute("topicID");

// display this topic
var content = displayTopic(topicID);

// add this to our history
dhtmlHistory.add(topicID, content);

// cancel the default behavior of hyperlinks
return evt.cancel();
}

Next, we extract the topic ID the user wants and display this
topic:

function handleTopicChange(e) {
var evt = new xEvent(e);
var target = evt.target;
var topicID = target.getAttribute("topicID");

// display this topic
var content = displayTopic(topicID);


// add this to our history
dhtmlHistory.add(topicID, content);

// cancel the default behavior of hyperlinks
return evt.cancel();
}

We take this topic's content and ID and add it to our DHTML history, which will automatically update the browser location and persist this history data:

function handleTopicChange(e) {
var evt = new xEvent(e);
var target = evt.target;
var topicID = target.getAttribute("topicID");

// display this topic
var content = displayTopic(topicID);

// add this to our history
dhtmlHistory.add(topicID, content);


// cancel the default behavior of hyperlinks
return evt.cancel();
}

Finally, the handleTopicChange() method cancels the
default hyperlink action, which is to navigate to a new page; it
uses the evt.cancel() method to do so, which must be
returned from the method:

function handleTopicChange(e) {
var evt = new xEvent(e);
var target = evt.target;
var topicID = target.getAttribute("topicID");

// display this topic
var content = displayTopic(topicID);

// add this to our history
dhtmlHistory.add(topicID, content);

// cancel the default behavior of hyperlinks
return evt.cancel();

}

We are almost finished fleshing out our page's initialization routine in initialize(). Our final initialization step is to register ourselves to receive history events with the DhtmlHistory framework:

function initialize() {
// initialize the DhtmlHistory
// framework
dhtmlHistory.initialize();

// if this is the first time the page
// has loaded, fetch the list of
// topics remotely
if (dhtmlHistory.isFirstLoad()) {
topics = loadTopics();
historyStorage.put("topics", topics);
}
else {
// else, simply extract it from our
// history storage
topics = historyStorage.get("topics");
}

// display our topics list
displayTopicsList();

// initialize our initial state from
// the browser location after the hash
var currentTopic =
dhtmlHistory.getCurrentLocation();
displayTopic(currentTopic);

// catch when a user clicks on a new
// topic
var menu =
document.getElementById("menu");
xAddEventListener(menu, "click",
handleTopicChange,
false);

// set ourselves up to listen to
// history events
dhtmlHistory.addListener(
handleHistoryEvent);

}

handleHistoryEvent() is a JavaScript method that will be called whenever a history event is fired. History events can happen from a variety of user actions, such as navigating with the back and forward buttons, selecting an AJAX bookmark in their browser, manually modifying the browser location, etc. AJAX pages will only receive history events that pertain to them, due to the browser's security model. If the user navigates away from your AJAX web page, for example, no history event is thrown; if the user navigates back, however, a history event will be returned to your application.

handleHistoryEvent() is straightforward; it simply takes the newLocation and uses this to display the selected topic:

function handleHistoryEvent(newLocation,
historyData) {
var topicID = newLocation;

// display this topic
displayTopic(topicID);
}

Our advanced application is now finished! Demo the advanced application or see the source code: advanced directory, advanced.html, advanced.css, advanced.js, topics.xml.


This page is powered by Blogger. Isn't yours?

Subscribe to Posts [Atom]