html5新特性:利用history的pushState等方法来解决使用ajax导致页面后退和前进的问题
一、背景
使用ajax,可以实现不需要刷新整个页面就可以进行局部页面的更新。这样可以开发交互性很强的富客户端程序,减少网络传输的内容。但长期以来存在一个问题,就是无法利用浏览器本身提供的前进和后退按钮进行操作。比如在页面执行某个动作,该动作利用ajax请求到服务器获取数据,更新了当前页面的某些内容,这时想回到操作前的界面,用户就会习惯点击浏览器的后退按钮,实际这里是无效的(要么页面没反应,要么打开一个前面打开的过的页面),或者想收藏当前页面(以便于重新打开时直接显示当前的信息),也是无法做到的。
这个问题因为html5的新特性而得以可以解决。但不是直接解决了。而是提供了一些新的api,需要程序员编写代码来实现。下面我们将详细的来介绍。
如果你对此问题和html5的这些新特性已经有些了解,可以直接跳到最后的案例章节。
二、history对象分析
浏览器是通过 window对象的 history对象来对浏览器历史访问记录,从而可以实现前进和后退。history对象可以理解其保存了一个有序的列表对象,每个对象都代表了一个页面信息(包括页面的url等信息),注意当前页面也被保存在里面。
这样就可以通过浏览器本身提供的前进和后退按钮来操作,也可以利用javascript调用history对象的back(),forward(),和go()方法来实现页面的切换。
我们先来理解下history的机制。history对象中记录了浏览器窗口访问过的url,但出于安全考虑,无法通过程序获取history对象中的具体信息,只能通过back、forward、go方法进行页面跳转,此外length属性记录了history中的记录(url)条数。
我们设想下,当在浏览器窗口打开第一个地址,比如 url1时, 这时history中就有了url1这个记录,且length属性值为1,history对象中有个当前页面指针(从概念上可以这么理解)指向url1;如果再打开一个url2页面(无论是通过在地址栏直接输入、或通过url1中的链接或js代码打开),这时history中就有了url1和url2这两个记录,是一个有序的列表,这时length属性值为2,history对象中的当前页面指针指向url2,这时url2是最新的页面,页面不可以前进,但可以后退到url1,这时如果点击浏览器本身提供的后退按钮(或用js调用back方法),这时url1页面会被重新加载显示,history对象的length仍然为2,url1和url2组成的列表仍然不变,但history对象中的当前页面指针指向url1了,这时就不能后退但可以前进了。可以理解成一个数据结构中的双向链表机制。
通过上面的描述我们可以看出,我们说的历史记录都是指一个完整的页面请求url,而ajax并不是一个完整的页面请求,因此浏览器无法记录ajax的操作信息。
三、history对象的新特性
HTML5引入了histtory.pushState()和history.replaceState()这两个方法,它们会更新history对象的内容。同时,结合window.onpostate事件,就可以解决文章开头提出的问题。
我们先来看pushState方法的含义,我们通过举例子的方式来更好的说明,先给出一段代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>测试</title> <script type="text/javascript" src="jquery.min.js"></script> </head> <body> <button onclick="doPushState()">pushState</button> <button onclick="count()">count</button> <script> var index=1; $(function(){ alert(location.href); }); function doPushState(){ history.pushState({}, "newtitle","test"+(index++)+".html"); } function count(){ alert(window.history.length); } </script> </body> </html>
上面是一个完整的html文件,文件名为demo.html。 把该文件放到web服务器上,从浏览器访问。如果是直接从本地磁盘打开,文件中的js代码执行会报错误。
在一个新的浏览器窗口访问该demo.html。 首先会执行 $()方法,弹出代码中的location.href信息。 这时执行count按钮,显示为1,注意如果在ie或chrome的新的浏览器窗口打开,值可能为2,因为它们的窗口会加载系统默认的一个页面,不是一个空白的窗口。
这时我们每点击一下pushState按钮,发现浏览器的地址会发生变化,先后变为test1.html , test2.html, test3.html, .......,并且通过点击count按钮发现,弹出的值加1. 这说明每调用一次pushState方法,history中就会新增加一条url记录。
我们先来解释下pushState方法,该方法有三个参数:
1)第一个参数是个js对象,可以放任何的内容,可以在onpostate事件中(后面介绍)获取到便于做相应的处理。
2)第二个参数是个字符串,目前好像没有起作用,可以传个空串。
3)第三个参数是个字符串,就是保存到history中的url。
结合例子的代码和输出可以看出,调用pushState方法的作用,就是相当于打开一个新页面,把当前页面作为历史记录,而当前的地址栏显示的是pushState方法中的url(这里是test.html)。但是与普通的打开一个新页面不同。浏览器将不会在调用pushState()方法后加载这个url,也就是说即使你写一个错误的url,也不会报错。
可以这么理解,当我们在一个新的浏览器窗口打开 demo.html后,点击n次pushState按钮后,history对象中存在这样的一个ulr列表。
demo.html(第1个url)----> index1.html(第2个url).......->index?.html(第n个url)----->indexn.html(当前页面的url)。
这时我们需要点击浏览器上的回退按钮n次,才能将浏览器上的地址退回到 demo.html。而且无论是在点击pushState按钮 或点击回退按钮的过程中发现,$()方法根本没有被触发,也就是说整个过程浏览器的页面内容都没有发生变化,变化的只是地址信息。
这也进一步说明,pushState只是将当前页面保存到history的历史记录中(并作为最近的一个记录),并且将当前浏览器的地址栏改为参数url的指定的值,但并不会加载它。这点与普通的通过链接打开或浏览器地址输入url完全不一样。
到了这里我们可以想象一下文章开头提出的问题了,如果我们在页面中执行一个ajax操作,当操作成功(如更新页面的局部内容)后,我们通过代码调用pushState方法,设置一个新的url,这样看上去就像发起了一个全新的请求,实际上只是个ajax操作。这时回退按钮也能用了,问题仅仅这样,回退按钮点了也没有任何反应。如果我们能通过代码,来响应这个回退按钮触发的事件,在事件中让界面恢复到ajax请求之前的界面,问题不就解决了吗?
得确如此,解决思路就是上面说的。下面我们来通过一个实际的例子看如何实现。在介绍例子之前,我们先来解释下html5中 history新增的另一个方法replaceState方法。
replaceState方法与pushState类似,同样有三个参数。区别在于,replaceState()是用来修改history对象中记录的当前页面的信息,它不是新建一个记录。如果将上面例子中的 代码 history.pushState({}, "newtitle","test"+(index++)+".html"); 中的pushState改为replaceState,其它代码都不动。这时我们点击pushState按钮后,看到的现象是一样的,地址栏的地址不断变化,页面内容不变。但我们点击count按钮,发现history中的记录数不变。这说明replaceState只是改变当前页面在history对象中的记录信息;而pushState是会产生一个新记录作为当前记录,把当前页面作为历史的记录保存。
我们再来看下window对象的popstate事件,当进行页面的前进或回退时,会触发该事件,并且在事件响应函数中通过 history.state 可以获取到 pushState方法和replaceState方法中第一个参数指定的对象。
解释了这几个api后,我们来一个具体的例子。
四、具体案例
我们来设想这样一个应用。一个页面来显示一篇长文章,该文章内容很长,分为很多章节。我们希望页面不会一次把所有章节的内容都加载起来,而是有一个章节导航,点击每个章节链接,通过jax加载具体章节的内容,而其它页面部分不需要要变化。
我们先看下传统的实现代码(注意,这里只注意核心逻辑代码的实现,其它的页面布局等尽量简化):
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test2</title> <style type="text/css"> div { padding-bottom:100px; } </style> <script type="text/javascript" src="jquery.min.js"></script> </head> <body> <div style="float:left;border:1px solid red;margin:20px"> <p><a href="javascript:;" id="section1">第1章</a></p> <p><a href="javascript:;" id="section2">第2章</a></p> <p><a href="javascript:;" id="section3">第3章</a></p> </div> <div style="float:left;border:1px solid red;margin:20px" id="content"> </div> <script> $(function(){ //添加链接的处理事件 $("a").click(ajax); //加载默认的章节,默认显示第1章 $("#section1").trigger("click"); }); function ajax(event){ //实际的流程是发起ajax请求,获取内容并显示。这里为了简化,没有写实际的ajax请求。 //这段代码应该在ajax的请求响应中编写。 $("#content").html(this.id+"的内容"); var title = this.id; document.title = title; } </script> </body> </html>
在浏览器加载该页面,当我们点击不同的章节链接时,内容会跟着变化,浏览器的标题也跟着变化。但是:
1)回退、前进按钮用不了
2)当我们刷新页面时,不管当前在哪个章节,都会重新回到第一个章节。
3)地址栏的url没有变化,也意味着我们没法把某个章节的地址保存下来,以后再次打开直接显示该章节内容。
上面就是传统ajax应用的一些弊端。下面我们就来解决这些问题。
我们先给出解决代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test2</title> <style type="text/css"> div { padding-bottom:100px; } </style> <script type="text/javascript" src="jquery.min.js"></script> </head> <body> <div style="float:left;border:1px solid red;margin:20px"> <p><a href="javascript:;" id="section1">第1章</a></p> <p><a href="javascript:;" id="section2">第2章</a></p> <p><a href="javascript:;" id="section3">第3章</a></p> </div> <div style="float:left;border:1px solid red;margin:20px" id="content"> </div> <script> $(function(){ //添加链接的处理事件 $("a").click(ajax); //加载默认的章节 changeContent(); //添加popstate事件 $(window).on("popstate",function(){ changeContent(); }); }); function changeContent(){ var query = location.href.split("?")[1]; if (!query) { // 如果没有查询条件,则显示默认第1个章节 history.replaceState(null, "", location.href + "?name=" + $("#section1")[0].id); changeContent(); } else { //触发按钮click事件,加载内容, //注意不要漏了true参数,这样可以和用户直接点击触发的页面变化区别开来 $("#"+query.split("=")[1]).trigger("click",true); } } function ajax(event,isPopstate){ $("#content").html(this.id+"的内容"); var title = this.id; document.title = title; if(!isPopstate){ history.pushState(null, "", location.href.split("?")[0] + "?name=" + title); } } </script> </body> </html>
加载上面页面,测试下,所有的问题都解决了。下面我们来解释下上述代码。
我们先看changeContent方法,该方法首先获取页面的url地址,判断该地址是否有查询条件(是否带章节信息),如果没有,认为要显示第一章节。我们利用history的replaceState方法来改变当前的url,加上 name=section1的查询条件,表示是第1章。因为replaceState方法不会改变页面内容,因此还需要接着再调用changeContent方法。如果地址带了查询条件,认为已经指定显示某个章节内容,这时触发章节链接的click事件。
我们再看ajax方法,就是章节链接的click事件响应函数,为了简化,该函数没有发起实际的ajax请求,而是相当于直接处理ajax返回的结果。首先是用得到的结果更新页面(这里是直接写死的),然后更新标题,这与传统的ajax做法一样。关键的区别是,判断该方法如果是用户点击的(不是onpopstate事件处理的),就会调用history对象的pushState方法来将当前页面信息保存到history对象中,并新增一个记录信息代表ajax请求后的页面。
changeContent方法同样是onpopstate事件的处理函数,其功能就是利用获取到的url信息(保存在history记录)中,来通过ajax获取到对应的内容,让页面显示相应的信息。 从用户感知上看,就跟正常的回退、前进导致的页面切换一样。用户感觉不到是ajax请求,还以为就是多个独立的页面在切换。
五、小结
本文详细的介绍了如何利用html5的新特性来解决传统ajax请求导致的一些缺陷。通过上面的介绍可以看出,为了解决问题,还是需要程序员做不少的事情,对于一个实际的项目来说,最好能在框架层面进行封装解决,而不是要让每个具体页面的实现者都来处理。这个可以是下一步要考虑的内容。