修补AJAX应用中Back/Forward Button和Bookmark失效的问题
2016-07-29 00:00
218 查看
想法与目标
从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上下载):
HistoryControl是一个Server控件,必须配合UpdatePanel使用,并没有对于Atlas的客户端应用甚至普通的AJAX应用提供基本的支持。
不支持FireFox(不知为何,我在自己尝试之后觉得支持FireFox比IE容易实现)。
在IE里使用时,从Back和Forward的下拉框里可以看出,那些Title都成为了“Empty Page”。
不支持在Back和Forward下拉框中选择一项History跳转。
如果访问了别的站点再Back,则在IE下不支持多次回退。
部署麻烦。事实上我觉得很奇怪,我除了直接在他的项目中成功运行之外。部署到别的项目或者是我的空间都有问题,怎么也找不出原因,估计是文件路径问题,需要仔细读一下他的代码。
总之,这个解决方案还很不成熟,但是我们要对Nikhil,Atlas和微软有信心,对于Back/Forward的内置支持应该会出现在Atlas的后续版本中。
于是我想,不如我来实现一个自己的吧,虽然我一直提倡软件复用,但是如果找不到成熟的解决方案,那么就该发挥程序员的主观能动性了。对于我最后的实现,它有以下特点:
一个轻量级的JS解决方案。虽然我是在Atlas的基础上写的,但是只是使用了Atlas中的Sys.Timer类,很容易修改成独立于任何库的JS代码。
支持IE和FireFox。
简单的支持Back和Forward的下拉框里的Title文字,在大多数情况下不会出错。产生Nikhil的这个问题的原因在阐述我的实现时会提及。我简单地解决了这个问题,但是没有设计出完整支持title问题的完美实现。我有一些想法,似乎十分复杂,在尝试时都宣告失败。
支持在Back和Forword下拉框中选择一项History跳转。
支持Bookmark,用户可以轻松将页面加入收藏夹。
易于使用,部署简单。
对于我列出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的时候也改变页面内容呢?我们将这个问题分为两部分考虑,依次解决:
在用户点击浏览器的Back/Forward或者选择History中某一项时改变浏览器的地址栏信息。
根据地址栏信息的改变得到信息,然后修改页面的内容。
对于第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
3
using
System;
4
using
System.Web;
5
6
public
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
1
Jeffz.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
1
Jeffz.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
>
6
<
body
>
7
<
script
language
="javascript"
>
8
window.parent.location.hash = location.hash;
9
10
var queryString = location.search;
11
var index = queryString.indexOf("&");
12
document.title = queryString.substring(index + 1);
13
</
script
>
14
</
body
>
15
</
html
>
把__reloadHelperIFrame函数和NavFixHelper.htm页面同时分析,是因为它们两个有密不可分的关系。它们都是仅仅为了IE服务的。
在前面的解释里,我已经谈到了改变iframe的Query String(search)可以将状态添加至浏览器的History中。于是__reloadHelperIFrame函数保证了新加载的Query String,和当前IFrame里页面的Query String不同(如果当前src包含了“&true”字符,那么就加载包含“&false”的地址,否则就加载包含“&false”的地址)。接着在新加载的NavFixHelper.htm页面中,会将父窗口的hash变成自己的hash。这样,浏览器地址栏里的值就会改变了。这样,当浏览器的Back/Forward Button被点击,或者用户直接从History列表里选择一项时,IFrame里的页面会被重新加载,并且会使用特定的地址,页面的JS代码会被重新执行,最终又将影响浏览器地址栏里的hash值。不过由于如果访问了其他页面,History只能记住最后一次IFrame里的地址,所以无法使用Back多次回退了。
每次加入History项,或者重新访问History时,都须要重新加载一次NavFixHelper.htm,那么会不会影响性能?答案是否定的。首先在加入History时,使用的href+search值只会用两种,因此浏览器会帮助cache文件内容,第二次访问具有相同href+search的页面时浏览器将不再访问Server。而且在服务器端,对于这样的静态文件,也会尽可能的进行cache,所以即使访问了Server也会获得304(未改变,表示从缓存读取)信息,而不会从服务器端重新下载。再退一步,重新下载一个如此之小的文件,也不会对性能有多少影响。
可以发现,在加载IFrame的页面时,还将当前页面的Title传入了Query String。其作用就是避免Nikhil的解决方案中我举出的第三个问题。NavFixHelper.htm会根据地址栏信息修改自身的titile,这样在IE添加History项时,会纪录IFrame里页面的title,这样就保证了浏览器History列表里的title与浏览器页面相同。可是这样还没有完美的解决Title问题,如果用户想要修改页面的Title,IE里我有了解决方案,却始终还没有找出在FireFox下正确纪录的方法,总是在Back/Forward之中History里的title就混乱了,似乎在FireFox里Forword/Back记录title的行为和IE不一样,让我百思不得其解。
History.js - addHistory
1
Jeffz.Framework.History.prototype.addHistory
=
function
(context)
2
{
3
historyEntry =
{ __p__ : context };
4
5
if (Sys.Runtime.get_hostType() == Sys.HostType.InternetExplorer)
6
{
7
this.__reloadHelperIFrame("#" + encodeURI(Sys.Serialization.JSON.serialize(historyEntry)));
8
}
9
else
10
{
11
location.hash = encodeURI(Sys.Serialization.JSON.serialize(historyEntry));
12
}
13
}
虽然可以说addHistory方法是关键,但是其实却非常的简单,在IE下则使用this.__reloadHelperIFrame来加载页面,否则就直接修改hash。hash的值使用的就是用户传入的context,序列化后,并经过encodeURI处理。
History.js - start & stop
1
Jeffz.Framework.History.prototype.start
=
function
()
2
{
3
if (!this.__runtimeTimer)
4
{
5
var timer = new Sys.Timer();
6
timer.set_interval(300);
7
timer.tick.add(this.__onTimerTick);
8
timer.historyObj = this;
9
this.__runtimeTimer = timer;
10
}
11
12
if (this.__runtimeTimer.get_enabled())
13
{
14
throw new Error("The history object has been started.");
15
}
16
else
17
{
18
this.__runtimeTimer.set_enabled(true);
19
}
20
}
21
22
Jeffz.Framework.History.prototype.stop
=
function
()
23
{
24
if (!this.__runtimeTimer || !this.__runtimerTimer.get_enabled())
25
{
26
throw new Error("The history object has not been started.");
27
}
28
else
29
{
30
this.__runtimeTimer.set_enabled(false);
31
}
32
}
这两个函数非常简单,本来不用解释。唯一需要注意的是代码的第8行,由于Sys.Timer使用的是使用setTimerout来回调执行this.__onTimerTick,因此在this.__onTimerTick函数执行时,this并不是History对象本身,而是window!还好Sys.Timer会通过sender传递自身给回调函数,因此我在构造Sys.Timer时将自身对象放进了Timer对象的historyObj属性中,则在this.__onTimerTick函数中可以从sender.historyObj中得到History对象。
History.js - __onTimerTick
1
Jeffz.Framework.History.prototype.__onTimerTick
=
function
(sender, eventArgs)
2
{
3
try
4
{
5
if (location.hash != sender.historyObj.__currentHash)
6
{
7
sender.historyObj.__currentHash = location.hash;
8
sender.historyObj.__execute(location.hash);
9
}
10
}
11
catch(e)
12
{
13
sender.set_enabled(false);
14
sender.set_enabled(true);
15
throw e;
16
}
17
}
其实这个方法就是比较hash值有没有改变,如果改变了,则保留新的hash,并加以执行回调函数(在__execute函数中)。可以发现,事实上回调函数this.__setDataCallbak总是在在onTimerTick里被执行的(除了在FireFox下构造History对象时),这样保证了this.__currentHash和当前页面hash的统一。
值得注意的是,我使用了try...catch来保护了代码,因为__execute函数会调用用户提供的回调函数,所以可能会抛出异常。如果__onTimerTick函数仅仅只使用了现在的第5到第9行,那么等异常抛出时,Timer将会被终止,将不会继续监视hash的改变。现在的做法是在遇到异常时停止Timer并重新启动,然后重新抛出异常。这样既保证了Timer的正常执行,又能让用户发现自己代码中的异常情况。
History.js - __execute
1
Jeffz.Framework.History.prototype.__execute
=
function
(hash)
2
{
3
var historyEntry = null;
4
5
try
6
{
7
var historyEntry = Sys.Serialization.JSON.deserialize(decodeURI(hash.substring(1)));
8
}
9
catch(e)
10
{
11
this.__setDataCallback(null);
12
return;
13
}
14
15
if (historyEntry)
16
{
17
this.__setDataCallback(historyEntry.__p__);
18
}
19
else
20
{
21
this.__setDataCallback(null);
22
}
23
}
这段我想就不用多加解释了,似乎将一个简单的调用写得有些复杂。这样做的目的是在hash值是错误的情况下,会将null作为参数,保证了用户提供的回调函数被正确的使用。
问题与其他
就此,整个类分析完了,总共只有短短一百多行代码,是我参考了一些实现,并通过自己的经验与思考得出的解决方案。目前似乎关于解决AJAX这个问题的解决方案都有差不多的模式,也有差不多的障碍。这依旧不是一个完美的解决方案,依旧有改进的余地。其实我这篇东西的作用只是抛砖引玉,希望能引出更多优秀的见解。我仍然在钻研和实践中,如果有什么进展,我会第一时间的更新这里的blog。
从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上下载):
HistoryControl是一个Server控件,必须配合UpdatePanel使用,并没有对于Atlas的客户端应用甚至普通的AJAX应用提供基本的支持。
不支持FireFox(不知为何,我在自己尝试之后觉得支持FireFox比IE容易实现)。
在IE里使用时,从Back和Forward的下拉框里可以看出,那些Title都成为了“Empty Page”。
不支持在Back和Forward下拉框中选择一项History跳转。
如果访问了别的站点再Back,则在IE下不支持多次回退。
部署麻烦。事实上我觉得很奇怪,我除了直接在他的项目中成功运行之外。部署到别的项目或者是我的空间都有问题,怎么也找不出原因,估计是文件路径问题,需要仔细读一下他的代码。
总之,这个解决方案还很不成熟,但是我们要对Nikhil,Atlas和微软有信心,对于Back/Forward的内置支持应该会出现在Atlas的后续版本中。
于是我想,不如我来实现一个自己的吧,虽然我一直提倡软件复用,但是如果找不到成熟的解决方案,那么就该发挥程序员的主观能动性了。对于我最后的实现,它有以下特点:
一个轻量级的JS解决方案。虽然我是在Atlas的基础上写的,但是只是使用了Atlas中的Sys.Timer类,很容易修改成独立于任何库的JS代码。
支持IE和FireFox。
简单的支持Back和Forward的下拉框里的Title文字,在大多数情况下不会出错。产生Nikhil的这个问题的原因在阐述我的实现时会提及。我简单地解决了这个问题,但是没有设计出完整支持title问题的完美实现。我有一些想法,似乎十分复杂,在尝试时都宣告失败。
支持在Back和Forword下拉框中选择一项History跳转。
支持Bookmark,用户可以轻松将页面加入收藏夹。
易于使用,部署简单。
对于我列出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的时候也改变页面内容呢?我们将这个问题分为两部分考虑,依次解决:
在用户点击浏览器的Back/Forward或者选择History中某一项时改变浏览器的地址栏信息。
根据地址栏信息的改变得到信息,然后修改页面的内容。
对于第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
3
using
System;
4
using
System.Web;
5
6
public
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
1
Jeffz.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
1
Jeffz.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
>
6
<
body
>
7
<
script
language
="javascript"
>
8
window.parent.location.hash = location.hash;
9
10
var queryString = location.search;
11
var index = queryString.indexOf("&");
12
document.title = queryString.substring(index + 1);
13
</
script
>
14
</
body
>
15
</
html
>
把__reloadHelperIFrame函数和NavFixHelper.htm页面同时分析,是因为它们两个有密不可分的关系。它们都是仅仅为了IE服务的。
在前面的解释里,我已经谈到了改变iframe的Query String(search)可以将状态添加至浏览器的History中。于是__reloadHelperIFrame函数保证了新加载的Query String,和当前IFrame里页面的Query String不同(如果当前src包含了“&true”字符,那么就加载包含“&false”的地址,否则就加载包含“&false”的地址)。接着在新加载的NavFixHelper.htm页面中,会将父窗口的hash变成自己的hash。这样,浏览器地址栏里的值就会改变了。这样,当浏览器的Back/Forward Button被点击,或者用户直接从History列表里选择一项时,IFrame里的页面会被重新加载,并且会使用特定的地址,页面的JS代码会被重新执行,最终又将影响浏览器地址栏里的hash值。不过由于如果访问了其他页面,History只能记住最后一次IFrame里的地址,所以无法使用Back多次回退了。
每次加入History项,或者重新访问History时,都须要重新加载一次NavFixHelper.htm,那么会不会影响性能?答案是否定的。首先在加入History时,使用的href+search值只会用两种,因此浏览器会帮助cache文件内容,第二次访问具有相同href+search的页面时浏览器将不再访问Server。而且在服务器端,对于这样的静态文件,也会尽可能的进行cache,所以即使访问了Server也会获得304(未改变,表示从缓存读取)信息,而不会从服务器端重新下载。再退一步,重新下载一个如此之小的文件,也不会对性能有多少影响。
可以发现,在加载IFrame的页面时,还将当前页面的Title传入了Query String。其作用就是避免Nikhil的解决方案中我举出的第三个问题。NavFixHelper.htm会根据地址栏信息修改自身的titile,这样在IE添加History项时,会纪录IFrame里页面的title,这样就保证了浏览器History列表里的title与浏览器页面相同。可是这样还没有完美的解决Title问题,如果用户想要修改页面的Title,IE里我有了解决方案,却始终还没有找出在FireFox下正确纪录的方法,总是在Back/Forward之中History里的title就混乱了,似乎在FireFox里Forword/Back记录title的行为和IE不一样,让我百思不得其解。
History.js - addHistory
1
Jeffz.Framework.History.prototype.addHistory
=
function
(context)
2
{
3
historyEntry =
{ __p__ : context };
4
5
if (Sys.Runtime.get_hostType() == Sys.HostType.InternetExplorer)
6
{
7
this.__reloadHelperIFrame("#" + encodeURI(Sys.Serialization.JSON.serialize(historyEntry)));
8
}
9
else
10
{
11
location.hash = encodeURI(Sys.Serialization.JSON.serialize(historyEntry));
12
}
13
}
虽然可以说addHistory方法是关键,但是其实却非常的简单,在IE下则使用this.__reloadHelperIFrame来加载页面,否则就直接修改hash。hash的值使用的就是用户传入的context,序列化后,并经过encodeURI处理。
History.js - start & stop
1
Jeffz.Framework.History.prototype.start
=
function
()
2
{
3
if (!this.__runtimeTimer)
4
{
5
var timer = new Sys.Timer();
6
timer.set_interval(300);
7
timer.tick.add(this.__onTimerTick);
8
timer.historyObj = this;
9
this.__runtimeTimer = timer;
10
}
11
12
if (this.__runtimeTimer.get_enabled())
13
{
14
throw new Error("The history object has been started.");
15
}
16
else
17
{
18
this.__runtimeTimer.set_enabled(true);
19
}
20
}
21
22
Jeffz.Framework.History.prototype.stop
=
function
()
23
{
24
if (!this.__runtimeTimer || !this.__runtimerTimer.get_enabled())
25
{
26
throw new Error("The history object has not been started.");
27
}
28
else
29
{
30
this.__runtimeTimer.set_enabled(false);
31
}
32
}
这两个函数非常简单,本来不用解释。唯一需要注意的是代码的第8行,由于Sys.Timer使用的是使用setTimerout来回调执行this.__onTimerTick,因此在this.__onTimerTick函数执行时,this并不是History对象本身,而是window!还好Sys.Timer会通过sender传递自身给回调函数,因此我在构造Sys.Timer时将自身对象放进了Timer对象的historyObj属性中,则在this.__onTimerTick函数中可以从sender.historyObj中得到History对象。
History.js - __onTimerTick
1
Jeffz.Framework.History.prototype.__onTimerTick
=
function
(sender, eventArgs)
2
{
3
try
4
{
5
if (location.hash != sender.historyObj.__currentHash)
6
{
7
sender.historyObj.__currentHash = location.hash;
8
sender.historyObj.__execute(location.hash);
9
}
10
}
11
catch(e)
12
{
13
sender.set_enabled(false);
14
sender.set_enabled(true);
15
throw e;
16
}
17
}
其实这个方法就是比较hash值有没有改变,如果改变了,则保留新的hash,并加以执行回调函数(在__execute函数中)。可以发现,事实上回调函数this.__setDataCallbak总是在在onTimerTick里被执行的(除了在FireFox下构造History对象时),这样保证了this.__currentHash和当前页面hash的统一。
值得注意的是,我使用了try...catch来保护了代码,因为__execute函数会调用用户提供的回调函数,所以可能会抛出异常。如果__onTimerTick函数仅仅只使用了现在的第5到第9行,那么等异常抛出时,Timer将会被终止,将不会继续监视hash的改变。现在的做法是在遇到异常时停止Timer并重新启动,然后重新抛出异常。这样既保证了Timer的正常执行,又能让用户发现自己代码中的异常情况。
History.js - __execute
1
Jeffz.Framework.History.prototype.__execute
=
function
(hash)
2
{
3
var historyEntry = null;
4
5
try
6
{
7
var historyEntry = Sys.Serialization.JSON.deserialize(decodeURI(hash.substring(1)));
8
}
9
catch(e)
10
{
11
this.__setDataCallback(null);
12
return;
13
}
14
15
if (historyEntry)
16
{
17
this.__setDataCallback(historyEntry.__p__);
18
}
19
else
20
{
21
this.__setDataCallback(null);
22
}
23
}
这段我想就不用多加解释了,似乎将一个简单的调用写得有些复杂。这样做的目的是在hash值是错误的情况下,会将null作为参数,保证了用户提供的回调函数被正确的使用。
问题与其他
就此,整个类分析完了,总共只有短短一百多行代码,是我参考了一些实现,并通过自己的经验与思考得出的解决方案。目前似乎关于解决AJAX这个问题的解决方案都有差不多的模式,也有差不多的障碍。这依旧不是一个完美的解决方案,依旧有改进的余地。其实我这篇东西的作用只是抛砖引玉,希望能引出更多优秀的见解。我仍然在钻研和实践中,如果有什么进展,我会第一时间的更新这里的blog。
相关文章推荐
- UpdatePanel的妙用:Incremental Content
- BlogEngine 1.0中的编码问题及解决方案
- 类中的internal成员可能是一种坏味道
- 让UpdatePanel支持文件上传(4):数据传输与解析机制
- F#中的异步及并行模式(2):反馈进度的事件(包含Twitter示例)
- 深入Atlas系列:客户端网络访问基础结构(上) - WebRequest的工作流程与生命周期
- 更新:让UpdatePanel支持上传文件
- 龙芯一二事(转载)
- 有感Atlas - 优点、缺点、学习
- Windows Live Translator
- MongoDB与Tokyo Tyrant性能比较(2):并发写入操作
- 哥谈的是语言,不是寂寞
- WPF/E CTP Quick Start - 第十一部分:示例控件(翻译)
- 通知:正式迁移至新博客
- 适合C# Actor的消息执行方式(3):中看不中用的解决方案
- 把事件当作对象进行传递
- ScriptPath属性的拙劣设计
- 让UpdatePanel支持文件上传(1):开始
- Go语言三大框架点评
- Go计算运行的时间