注册 | 登录 忘记密码? 51cto首页 | 博客 | 论坛 | 招聘
热点文章 WEB3.0来了,能知道100米..
 帮助

修补AJAX应用中Back/Forward Button和Bookmark失效的问题


2006-09-14 19:48:11
版权声明:原创作品,如需转载,请与作者联系。否则将追究法律责任。
想法与目标

   从AJAX诞生至今,就存在着Back/Forward Button和bookmark失效的问题,我以前一般提倡,一个好的AJAX应用应该不让用户有点击“Back/Forward”的想法,并且使用某种 方式提供给用户一个能够记录直接产生页面的Bookmark。Windows Live Local应该是这种应用最好的典范之一,其灵活的交互,良好的界面让我在初遇时不得不眼前一亮。

   另外,我也曾经见过把后退按钮禁用的做法(其实这样对于解决问题的确不错),不过这些都似乎只是一个workaround,设法避开这个AJAX应用普 遍存在的问题。似乎Gmail能够支持Back按钮,但是我惊奇的发现,在点击Back后,却不能使用Forward,所以这还不算成功的解决这个问题。 那么能否解决?似乎已经有了一定的实现。

  事实上,之所以我会产生实现自己的解决方案的想法,是因为从Nikhil Kothari的Blog上看到了他的解决方案(点击这里查看)。 他实现了一个HistoryControl控件,可以在页面中配合UpdatePanel使用,在一定程度上实现了对Back/Forword Button已经Bookmark的支持。但是正如他在Blog上所说的,这只是他的一个prototype。我在使用了他的演示之后,也的确发现了一些 问题(演示也能从Nikhil的Blog上下载):
  1. HistoryControl是一个Server控件,必须配合UpdatePanel使用,并没有对于Atlas的客户端应用甚至普通的AJAX应用提供基本的支持。
  2. 不支持FireFox(不知为何,我在自己尝试之后觉得支持FireFox比IE容易实现)。
  3. 在IE里使用时,从Back和Forward的下拉框里可以看出,那些Title都成为了“Empty Page”。
  4. 不支持在Back和Forward下拉框中选择一项History跳转。
  5. 如果访问了别的站点再Back,则在IE下不支持多次回退。
  6. 部署麻烦。事实上我觉得很奇怪,我除了直接在他的项目中成功运行之外。部署到别的项目或者是我的空间都有问题,怎么也找不出原因,估计是文件路径问题,需要仔细读一下他的代码。
  总之,这个解决方案还很不成熟,但是我们要对Nikhil,Atlas和微软有信心,对于Back/Forward的内置支持应该会出现在Atlas的后续版本中。

  于是我想,不如我来实现一个自己的吧,虽然我一直提倡软件复用,但是如果找不到成熟的解决方案,那么就该发挥程序员的主观能动性了。对于我最后的实现,它有以下特点:

  1. 一个轻量级的JS解决方案。虽然我是在Atlas的基础上写的,但是只是使用了Atlas中的Sys.Timer类,很容易修改成独立于任何库的JS代码。 
  2. 支持IE和FireFox。
  3. 简 单的支持Back和Forward的下拉框里的Title文字,在大多数情况下不会出错。产生Nikhil的这个问题的原因在阐述我的实现时会提及。我简 单地解决了这个问题,但是没有设计出完整支持title问题的完美实现。我有一些想法,似乎十分复杂,在尝试时都宣告失败。
  4. 支持在Back和Forword下拉框中选择一项History跳转。
  5. 支持Bookmark,用户可以轻松将页面加入收藏夹。 
  6. 易于使用,部署简单。
  对于我列出Nikhil的实现里的第5个问题,我想了一些办法,却依旧没有解决。现在虽然脑子里有想法,但还需要继续尝试。

思路与设计

   AJAX是个神奇的东西。因为有了XMLHttpRequest对象,我们能够“不知不觉”地与服务器端交换数据,在改变页面显示和行为的同时,让用户 感觉不到页面的Reload。虽然在上个世纪微软已经在早期IE里就以ActiveX的形式提供了这个对象,并且在OWA中将其进行大量使用,但是我对于 这个对象的了解却在AJAX大行其道之后。在这之前,为了达到类似AJAX的良好用户体验,往往会在页面中放一个隐藏的IFrame,然后通过Form向 其中POST/GET数据,或者直接修改IFrame的src属性以达到传输数据的效果。如果在IFrame中的页面里写JS代码,就能通过 window.parent.XXX来调用父级页面的对象或方法,并可以访问整个DOM(当然跨Frame操作的话需要两张页面在同一个Domain中, 这个就是IFrame sandbox,如果了解Windows Live Gadget和Windows Live Spaces Gadget的人就能体会到在安全性方便IFrame起的重要作用)。

  当时发现,只要改变IFrame里的地址,不论是 POST/GET还是改变其src属性,大都会在浏览器的History里留下痕迹。这是如果用户点击浏览器的Back按钮,则会从IFrame里的 History里Load以前的页面,当然也会按照那张页面的逻辑解释执行其中的JS代码。善于利用这点的话,就会产生父页面Back/Forward的 效果。可惜当时没有去想这一点,而且当时因为某些问题,用户点击Back/Forward时反而会产生异常的行为,甚是麻烦。

  与 POST/GET相比,改变IFrame的src属性相对简单,也容易操作。但是在IE重要注意的是,并不是任意修改src时都会使IFrame被加入 History。修改src的时候其实改变了IFrame里的location。location是window的一个属性,它分几个部分,这里需要提到 的就是它的href,search和hash。举个例子,对于一个location“http://www.sample.com?a=b&c= d#hello”来说,location.href是“http://www.sample.com”,location.search是“?a= b&c=d”(可以看出,search其实就是Query String),location.hash是“#hello”。在IE中改变location.hash是不会影响History的,因此只有改变 href与search才行。在FireFox中,改变hash值是可以影响浏览器的History,但是点击Back/Forward并不会使浏览器重 新执行页面中的JS代码。

  解决Back/Forward的大致方向有了,那么Bookmark呢?应该很容易将问题变成,如何要改变浏览器地址栏的值,但是不刷新页面。还好我们有hash。所有的标识都要通过hash值来传递。

  到现在为止,应该已经能够实现了在不刷新页面时改变浏览器的History纪录,但是如何在用户点击Back/Forward的时候也改变页面内容呢?我们将这个问题分为两部分考虑,依次解决:

  1. 在用户点击浏览器的Back/Forward或者选择History中某一项时改变浏览器的地址栏信息。 
  2. 根据地址栏信息的改变得到信息,然后修改页面的内容。 
   对于第1个问题,在FireFox下很容易解决,因为其实这是浏览器已经支持的功能。如果是IE,我们只能通过IFrame里的页面来改变父窗口的 hash了。第2个问题比较麻烦,因为改变浏览器的hash值并不会触发一个事件,所以迄今为止似乎所有的解决方案都是使用timer来不停地查询 hash值有没有改变。我的解决方案也不例外。

  解释完了思路与设计,就该转向真正的实现了吧。


实现与分析

  在分析实现之前,可以先点击这里下载我的代码,或者点击这里查看例子。我实现了一个类“Jeffz.Framework.History”。先从使用讲起。

Default.aspx
 1<%@ Page Language="C#" %>
 2
 3<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 4
 5<script runat="server">
 6
 7
</script>
 8
 9<html xmlns="http://www.w3.org/1999/xhtml" >
10<head runat="server">
11    <title>Nav Fix</title>
12    <script language="javascript">
13        var historyObj = null;
14        var previousIndex = 0;
15    
16        function onChange(select)
17        {
18            historyObj.addHistory(select.selectedIndex);
19        }

20        
21        function setPageData(context)
22        {
23            var select = document.getElementById("select");
24            if (context)
25            {
26                select.selectedIndex = context;
27                var request = new Sys.Net.WebRequest();
28                request.set_url("Selection.ashx?s=" + select.selectedIndex);
29                request.completed.add(onComplete);
30                request.invoke();
31            }

32            else
33            {
34                select.selectedIndex = 0;
35                document.getElementById("message").innerHTML = "";
36            }

37        }

38        
39        function onComplete(sender)
40        {
41            document.getElementById("message").innerHTML = sender.get_data();
42        }

43        
44        function init()
45        {
46            historyObj = new Jeffz.Framework.History(setPageData, "NavFixHelper.htm");
47            historyObj.start();
48        }

49    
</script>
50</head>
51<body style="font-family: Arial;">
52    <form id="form1" runat="server">
53    <div>
54        <atlas:ScriptManager ID="ScriptManager1" runat="server" EnableScriptComponents="true">
55            <Scripts>
56                <atlas:ScriptReference Path="js/History.js" />
57            
</Scripts>
58        </atlas:ScriptManager>
59        
60        <script type="text/xml-script">
61            <page xmlns:script="http://schemas.microsoft.com/xml-script/2005">
62                <components>
63                    <application load="init" />
64                </components>
65            </page>
66        
</script>
67
68        <select onchange="onChange(this)" id="select">
69            <option></option>
70            <option value="1">selection 1</option>
71            <option value="2">selection 2</option>
72            <option value="3">selection 3</option>
73        </select>
74        <div id="message" style="font-size: 32px;"></div>
75    </div>
76    </form>
77</body>
78</html>
79

Selection.ashx
 1<%@ WebHandler Language="C#" Class="Selection" %>
 2
 3using System;
 4using System.Web;
 5
 6public class Selection : IHttpHandler {
 7    
 8    public void ProcessRequest (HttpContext context) {
 9        string value = String.Format(
10            "You select: <strong>selection {0}</strong>"
11            context.Request.QueryString["s"]);
12        
13        context.Response.Write(value);
14        context.Response.End();
15    }

16 
17    public bool IsReusable {
18        get {
19            return true;
20        }

21    }

22}
   首先Application在Load之后会立即调用init方法,构造一个Jeffz.Framework.History对象 historyObj,需要传入更新数据的回调函数,还有为了IE单独提供的NavFixHelper.htm文件的路径。然后调用对象的start方法 开启对于hash值的监听。对象还提供了一个stop来停止监听。我提供这两个方法的目的是为了能够在需要时停止timer,方便调试。需要更新页面内容 时,如果要保留History,那么必须通过historyObj的addHistory来提供修改所需要使用的参数context。context可以 为任意对象,将会被序列化之后被放置在地址栏的hash中,然后在构造historyObj时传入的回调函数(setPageData)会被执行, context会被作为参数传入回调函数。使用historyObj时,对于页面的修改都应该放在setPageData中。在用户通过点击 Back/Forward Button或者直接选择History的某一项时,地址栏中的hash会改变,回调函数会获得从当前hash得到的context作为参数,将页面更新 至之前的状态。

  在setPageData被调用时,<select />的选项会被更改,然后会使用Sys.Net.WebRequest向Selection.ashx发送请求。Selection.ashx根据 Query String的值来返回信息。然后Sys.Net.WebRequest对象在收到response后修改message的信息。

  在使用上就是这么简单。


History.js - Constructor
 1Jeffz.Framework.History = function(setDataCallback, helperPageUrl)
 2{
 3    if (!setDataCallback || (typeof setDataCallback != "function"))
 4    {
 5        throw new Error("Please provide a callback function");
 6    }

 7
 8    this.__setDataCallback = setDataCallback;
 9    this.__currentHash = null;
10    this.__helperIFrame = null;
11    this.__helperPageUrl = helperPageUrl;
12    this.__runtimeTimer = null;
13    
14    if (Sys.Runtime.get_hostType() == Sys.HostType.InternetExplorer)
15    {
16        if (!helperPageUrl)
17        {
18            throw new Error("Please provide the helper page for IE.");
19        }

20        else
21        {
22            var helperIFrame = document.createElement("iframe");
23            helperIFrame.style.display = "none";
24            document.body.appendChild(helperIFrame);
25            this.__helperIFrame = helperIFrame;
26            this.__reloadHelperIFrame(location.hash);
27        }

28    }

29    else
30    {
31        this.__currentHash = location.hash;
32        this.__execute(location.hash);
33    }

34}
  对于所有的私有成员,我都使用成员名前加上“__”的方法,加以区分。

   首先,传入的第一个参数setDataCallback必须是一个函数,否则将抛出异常。紧接着判断浏览器类型,如果是IE,则检测必须提供 helperPageUrl,并且构造一个隐藏的IFrame来辅助实现history功能,最后通过 this.__reloadHelperIFrame方法来修改地址栏里的hash。如果不是IE,那么就直接将this.__currentHash设 成当前的hash加以记录,并调用this.__execute函数将hash值构造成context对象,并执行回调函数 this.__setDataCallback。


History.js - __reloadHelperIFrame
 1Jeffz.Framework.History.prototype.__reloadHelperIFrame = function(hash)
 2{
 3    this.__helperIFrame.document.title = document.title;
 4
 5    if (this.__helperIFrame.src && this.__helperIFrame.src.indexOf("?true">= 0)
 6    {
 7        this.__helperIFrame.src = this.__helperPageUrl + "?false&" + document.title + hash;
 8    }

 9    else
10    {
11        this.__helperIFrame.src = this.__helperPageUrl + "?true&" + document.title + hash;
12    }

13}
NavFixHelper.htm
 1<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 2<html xmlns="http://www.w3.org/1999/xhtml" >
 3<head>
 4    <title>Untitled Page</title>
 5</head>