Ajax Hacks: Tips & Tools for Creating Responsive Web Sites

Use an open source JavaScript library that brings bookmarking and back button support to Ajax applications.

This hack introduces the Really Simple History (RSH) library, an open source framework that provides bookmarking and back-button solutions for Ajax, and shows several working examples. It uses a hidden HTML form to initiate 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 [Hack #68] can 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 multipage web applications. As users surf web sites, 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.

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 in completely fresh pages on every mouse click.

In Ajax applications such as Gmail, the URL in the browser's location bar stays exactly the same as users select functions and change the application's state. Creating bookmarks for specific application views is impossible. Further, if users press their back buttons to "undo" previous actions, they will find to their surprise that the web pages they were looking at disappear, to be replaced with the last-visited (and completely different) pages.

The Solution

The Really Simple History framework 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 the weblog entry "Coding in Paradise: Safari: No DHTML History Possible" at the following: http://codinginparadise.org/weblog/2005/09/safari-no-dhtml-history-possible.html).

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" http://codinginparadise.org/weblog/2005/09/ajax-history-libraries.html for details). Further, many Ajax history frameworks are monolithically bundled into larger libraries, such as Backbase (http://www.backbase.com) and the Dojo Toolkit (http://www.dojotoolkit.org). 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 bundled with existing Ajax systems. Further, the Really Simple History library uses techniques to avoid the bugs that affect other history frameworks.

History Abstraction

The RSH 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 web site 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. You internally achieve this using a hidden form field, taking advantage of the fact that browsers auto-save the values in form fields even after a user has left the web page.

Example 1: Basic History

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="/books/4/254/1/html/2/../../framework/dhtmlHistory.js"> </script>

DHTML History applications must also include a special file named blank.html in the same directory as the Ajax web page; this file is bundled with the RSH framework, available at http://codinginparadise.org/projects/dhtml_history/latest.zip, and is needed by IE. As a side note, RSH uses a hidden iframe to track and add history changes in Internet Explorer. This iframe requires that you 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 to initialize the object after the page has finished loading:

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

dhtmlHistory is the global variable name; DhtmlHistory is the object name.

Next, you can use the dhtmlHistory.addListener( ) method to subscribe to history change events:

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

This method takes a single JavaScript callback function that receives 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.

The historyChange( ) method is straightforward. It 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, which is bundled with the full example download for this hack at the following: http://www.onjava.com/onjava/2005/10/26/examples/downloads/examples.zip. 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.

As described earlier, you can add 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 is stored with this event.

Browsers allow JavaScript to change the URL in the location bar only by appending an anchor to the end of the current location; for example, if I was at http://codinginparadise.org/test.html, the JavaScript could append #someAnchor to the end of the URL in the location bar, resulting in http://codinginparadise.org/test.html#someAnchor. We use this capability in the RSH library to save bookmarkable state; the edit:SomePage location above is what is added to the end of the URL: http://codinginparadise.org/test.html#edit:SomePage.

Here's the code:

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 is shown to the user in the browser's location bar 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 sees the following in the browser's location bar: http://codinginparadise.org/my_ajax_app#helloworld.

The user can then bookmark this page; if the bookmark is used later, your Ajax application can read the #helloworld value and use it to initialize the web page, based on the meaning the application attributes to the hash-marked value. Location values after the hash are URL encoded and decoded transparently by the RSH framework.

historyData is useful for saving more complicated state with an Ajax location change than what can easily fit on the end of a URL. It is an optional value that can be any JavaScript type, such as a number, string, or object. One example use of historyData is to save all of the text in a rich text editor, for example, if the user navigates away from the page. When the user navigates back to this location, the browser returns 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 JavaScript Object Notation allows is allowed in the history data, including simple data types and the null type. References to DOM objects and scriptable browser objects such as 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 browser's history.

The last step in working with dhtmlHistory is using 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 completely reloads and fires 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. The isFirstLoad( ) method makes it possible to differentiate between the very first time a web page is loaded versus a false load event fired if the user navigates back to a saved web page in the browser's history.

In the following example code, we want to add history events only the first time a page loads. If the user presses the back button to return to the page after browsing to a different site, we do not want to re-add all 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, available at http://www.onjava.com/onjava/2005/10/26/examples/downloads/examples.zip; this function prepares the simple XML to be displayed to the web page for debugging.

Note that data is persisted only 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 persisted only in terms of the back and forward buttons and disappears when the user closes the browser or clears the cache.

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, and so on. When a user selects a menu item, such as Inbox, we update the right side of the page is updated 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 different views in the application and to jump to previous menu changes using the browser's back and forward buttons.

We'll 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 data 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. The following source calls handleHistoryChange( ) when the user presses 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; }

Resources

You can download all the sample code for this hack from http://www.onjava.com/onjava/2005/10/26/examples/downloads/examples.zip, and you can download the RSH framework from http://codinginparadise.org/projects/dhtml_history/latest.zip.

Also, you can follow new developments in the RSH library at the framework author's web site, http://codinginparadise.org.

Brad Neuberg

Категории