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.


Comments:
This comment has been removed by a blog administrator.
 
Hi Brad,

Great article! I got it working great in Firefox, and then moved over to IE6 and am not having any luck. When I add a new location to the history, I don't seem to get any trigger from the history listener to go the new location.

There is one main difference between your implementation and mine - I do not use the X library. Would that make a difference? From my href I call a javascript function - do I need to return anything from it to make this work? The add function does not throw an exception either - checked that already.

I will keep looking into it, but any additional guidance would be greatly appreciated.

Thanks,
Ron
 
Hi Ron! One common mistake is to forget to include the blank.html file in the same directory as your web page. Make sure that you did that, or else Internet Explorer will fail.
 
I was missing the blank.html file, but even after putting that in place I am still having problems. It gets to the iframeLoaded function, but only once (per click) with ignoreLocationChange=true, so it just returns and does nothing. Is that function supposed to be called twice per click so that the second time ignoreLocationChange is false?

Thanks again for any help,
Ron
 
Did you call dhtmlHistory.initialize() when the page is finished loading (add an onload handler)?

Are you calling dhtmlHistory.add("someNewUrl", null) when adding history events? Make sure they don't match the ID of any elements in the document, or you will have IE bugs.
 
I believe I am doing everything you suggest, but still have the problem. For now it seems to work if I comment out the first if statement in iframeLoaded, although I figure this is not the right solution.

Could you describe why that if statement is there? How does iframeLoaded get called twice? Maybe that will help me investigate further.
 
Hi Brad..
This is ALMOST the functionality I've been looking for -- BUT instead of using multiple html files for the content - is it possible to use a single XML file for the source of the content and only select by node ID the content needed?

A demo of this would be great!!
 
Ron, it looks like you re-rolled the implementation of RSH in your own library, is that right? Were you sure that you have all of the fixes that are inside of there?
 
Brad,
I've implemented your code and it works a treat, the only problem I'm having is understanding bookmarks. Should bookmarks work after closing the browser or going to another site?
I downloaded your code from the www.onjava.com article.
Thanks!
Kieran
 
Hi,
I've noticed that in IE you see the loading bar in the status bar each time you select a topic in your example. Is this what you would expect to happen?
Thanks
Kieran
 
Hi Brad,

This is gr8 stuff. However i'm facing a problem with the same. I'm using this in the paging while displaying results. Now the case is that if I have clicked on the buttons in the following flow:
2
3
4
5
And when i'm on page 5, and i press the back button, then instead of going on page 4, i'm being getting page 3 from the history. I'm adding the information on every click though but still the immediate last page is not getting saved i guess. Hope u can help with this.

regards,
Rishab
 
Great stuff - now how to get it working with Flash? :)
 
I have a problem!
Example:
When I type an hash string to your exmpale code to change the hash.Then the hash changed and the page loaded.
But, when I press back or forward button, or press on your link (Title 1, 2 and 3). The hash from the URL on the address bar (I am using Firefox 2.0.0.4, and IE7 Final version), doesn't change to new or last hash. But when I bookmark this page, the hash changes to the hash I want.
Say it easily, when i type "#1", the page load to "title 1, string 1", then I press back button to go to page #2, the browser load to the page before, but the hash still #1.
Now I bookmark the page, then the hash on Bookmark URL is #2. Can anbody solve this problem?
 
Hi, Brad

I am using dhtmlHistory to build a search ajax page. Everytime the results are returned, they will be saved and refreshed in the result division. It works perfectly for firefox, however, to IE it stops working. When I click return, it still shows the new anchor and nothing happens. I have tried to fix it but fails. Do you have any suggestions? The page is on test.websemanticsjournal.org/openacademia/find.jsp

Thanks a lot
 
Hi Brad,

It is so incredible this one, but i have two questions about AJAX:

1- I am trying to put another symbol instead # (i want to put ?) and i want to know if you can help me, because, and this is the another question...

2- I want to use # to set ancores in my web, and i am not sure how would works with AJAX and ancores at the same time,

Thanks a lot
 
Post a Comment

Subscribe to Post Comments [Atom]



Links to this post:

Create a Link



<< Home

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

Subscribe to Posts [Atom]