浅析Web开发中前端路由实现的几种方式

故事从名叫Oliver的绿箭虾`提及,这位大虾酷爱社交网站,一天他打开了 Twitter ,从发过的tweets的选项卡一路切到followers选项卡,Oliver发现页面的内容变化了,URL也变化了,但为何页面没有闪烁刷新呢?因而Oliver打开的网络监控器(没错,Oliver是个程序员),他惊讶地发如今切换选项卡时,只有几个XHR请求发生,但页面的URL却在对应着变化,这让Oliver不得不去思考这一机制的缘由…

叙事体故事讲完,进入正题。首先,咱们知道传统而经典的Web开发中,服务器端承担了大部分业务逻辑,但随着2.0时代ajax的到来,前端开始担负起更多的数据通讯和与之对应的逻辑。

在过去,Server端处理来自浏览器的请求时,要根据不一样的Url路由,拼接出对应的视图页面,经过Http返回给浏览器进行解析渲染。Server不得不承担这份艰巨的责任,谁叫他是Server,而不是Owner -_-“。为了让Server端更好地把重心放到实现核心逻辑和看守数据宝库,把部分数据交互的逻辑交给前端担负,让前端来分担Server端的压力显得尤其重要,前端也有这个责任和能力。

那么问题来了,前端的能力是什么呢,有哪些能力呢?

大部分的复杂的网站,都会把业务解耦为模块进行处理。这些网站中又有不少的网站会把适合的部分应用Ajax进行数据交互,展示给用户,很明显处理这样的数据通讯交互,不可避免的会涉及到跟URL打交道,让数据交互的变化反映到URL的变化上,进而能够给用户机会去经过保存的URL连接,还原刚才的页面内容板块的布局,这其中包括Ajax局部刷新的变化。

经过记录URL来记录web页面板块上Ajax的变化,咱们能够称之为 Ajax标签化 ,比较好实现能够参考 Pjax 等。而对于较大的framework,咱们称之为 路由系统 ,好比AngularJs等。

咱们先熟悉几个新的H5 history Api:

/*Returns the number of entries in the joint session history.*/
window . history . length

/*Returns the current state object.*/
window . history . state

/*Goes back or forward the specified number of steps in the joint session history.A zero delta will reload the current page.If the delta is out of range, does nothing.*/
window . history . go( [ delta ] )

/*Goes back one step in the joint session history.If there is no previous page, does nothing.*/
window . history . back()

/*Goes forward one step in the joint session history.If there is no next page, does nothing.*/
window . history . forward()

/*Pushes the given data onto the session history, with the given title, and, if provided and not null, the given URL.*/
window . history . pushState(data, title [url] )

/*Updates the current entry in the session history to have the given data, title, and,if provided and not null, URL.*/
window . history . replaceState(data, title [url] )
上边是Mozilla在HTML5中实现的几个History api的官方文档描述,咱们先来关注下最后边的两个api, history.pushState 和 history.replaceState ,这两个history新增的api,为前端操控浏览器历史栈提供了可能性:

/**
*parameters
*@data {object} state对象,这是一个javascript对象,通常是JSON格式的对象
*字面量。
*@title {string} 能够理解为document.title,在这里是做为新页面传入参数的。
*@url {string} 增长或改变的记录,对应的url,能够是相对路径或者绝对路径,
*url的具体格式能够自定。
*/
history.pushState(data, title, url) //向浏览器历史栈中增长一条记录。
history.replaceState(data, title, url) //替换历史栈中的当前记录。
这两个Api都会操做浏览器的历史栈,而不会引发页面的刷新。不一样的是,pushState会增长一条新的历史记录,而replaceState则会替换当前的历史记录。所需的参数相同,在将新的历史记录存入栈后,会把传入的data(即state对象)同时存入,以便之后调用。同时,这俩api都会更新或者覆盖当前浏览器的title和url为对应传入的参数。

url参数能够为绝对路径,如: http://tonylee.pw?name=tonylee ,https://www.tonylee.pw/name/tonylee ;也能够为相对路径: ?name=tonylee , /name/tonylee ;等等的形式,让咱们来在console中作个测试:

//假设当前网页URL为:http://tonylee.pw
window.history.pushState(null, null, "http://tonylee.pw?name=tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw?name=tonylee

window.history.pushState(null, null, "http://tonylee.pw/name/tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name/tonylee

window.history.pushState(null, null, "?name=tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw?name=tonylee

window.history.pushState(null, null, "name=tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name=tonylee

window.history.pushState(null, null, "/name/tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name/tonylee

window.history.pushState(null, null, "name/tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name/tonylee

//错误的用法:
window.history.pushState(null, null, "http://www.tonylee.pw?name=tonylee");
//error: 因为跨域将产生错误
能够看到,url做为一个改变当前浏览器地址的参数,用法是很灵活的,replaceState和pushState具备和上边测试相同的特性,传入的url若是可能,总会被作适当的处理,这种处理默以”/”相隔,也能够本身指定为”?”等。要注意,这两个api都是不能跨域的!好比在 http://tonylee.pw 下,只能在同域下进行调用,如二级域名http://www.tonylee.pw 就会产生错误。没错,我想你已经猜到了前边讲到的Oliver看到URL变化,页面板块变化,页面发出XHR请求,页面没有reload等等特性,都是所以而生!

若是有兴趣,你也能够去twitter亲自体验twitter的这一特性,看看他的前端路由系统是如何工做的。
https://twitter.com/following -> https://twitter.com/followers
至于api中的data参数,其实是一个state对象,也便是javascript对象。Firefox的实现中,它们是存在用户的本地硬盘上的,最大支持到640k,若是不够用,按照FF的说法你能够用 sessionStorage or localStorage -_-“。如:

var stateObj = { foo: "bar" };
history.pushState(stateObj, "the blog of Tony Lee", "name = Later");
若是当前页面通过这样的过程,历史栈对应的条目,被存入了stateObj,那么咱们能够随时主动地取出它,若是页面只是一个普通的历史记录,那么这个state就是null。如:

var currentState = history.state;  //若是没有则为null。
mozilla有一个应用pushState和replaceState小demo你们能够看一下:

<!DOCTYPE HTML>
<!-- this starts off as http://example.com/line?x=5 -->
<title>Line Game - 5</title>
<p>You are at coordinate <span id="coord">5</span> on the line.</p>
<p>
 <a href="?x=6" onclick="go(1); return false;">Advance to 6</a> or
 <a href="?x=4" onclick="go(-1); return false;">retreat to 4</a>?
</p>
<script>
 var currentPage = 5; // prefilled by server!!!!
 function go(d) {
     setupPage(currentPage + d);
     history.pushState(currentPage, document.title, '?x=' + currentPage);
 }
 onpopstate = function(event) {
     setupPage(event.state);
 }
 function setupPage(page) {
     currentPage = page;
     document.title = 'Line Game - ' + currentPage;
     document.getElementById('coord').textContent = currentPage;
     document.links[0].href = '?x=' + (currentPage+1);
     document.links[0].textContent = 'Advance to ' + (currentPage+1);
     document.links[1].href = '?x=' + (currentPage-1);
     document.links[1].textContent = 'retreat to ' + (currentPage-1);
 }
</script>
仔细阅读就会看到,这个demo已经快成为一个Ajax标签化或者前端路由系统的雏形了!

了解这俩api还不够,再来看下上边的demo中涉及到的 popstate 事件,我担忧解释的不到位,因此看看mozilla官方文档的解释:

An event handler for the popstate event on the window.

A popstate event is dispatched to the window every time the active history entry changes between two history entries for the same document. If the history entry being activated was created by a call to history.pushState() or was affected by a call to history.replaceState(), the popstateevent's state property contains a copy of the history entry's state object.

Note that just calling history.pushState() or history.replaceState() won't trigger apopstate event. The popstate event is only triggered by doing a browser action such as clicking on the back button (or calling history.back() in JavaScript). And the event is only triggered when the user navigates between two history entries for the same document.

Browsers tend to handle the popstate event differently on page load. Chrome (prior to v34) and Safari always emit a popstate event on page load, but Firefox doesn't.

Syntax
    window.onpopstate = funcRef;
    //funcRef is a handler function.
简而言之,就是说当同一个页面在历史记录间切换时,就会产生popstate事件。正常状况下,若是用户点击后退按钮或者开发者调用:history.back() or history.go(),页面根本就没有处理事件的机会,由于这些操做会使得页面reload。因此popstate只在不会让浏览器页面刷新的历史记录之间切换才能触发,这些历史记录通常由pushState/replaceState或者是由hash锚点等操做产生。而且在事件的句柄中能够访问state对象的引用副本!并且单纯的调用pushState/replaceState并不会触发popstate事件。页面初次加载时,知否会主动触发popstate事件,不一样的浏览器实现也不同。下边是官方的一个demo:

window.onpopstate = function(event) {
 alert("location: " + document.location + ", state: " +   JSON.stringify(event.state));
};

history.pushState({page: 1}, "title 1", "?page=1");
history.pushState({page: 2}, "title 2", "?page=2");
history.replaceState({page: 3}, "title 3", "?page=3");
history.back(); // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // alerts "location: http://example.com/example.html, state: null
history.go(2); // alerts "location: http://example.com/example.html?page=3, state: {"page":3}
这里即是经过event.state拿到的state的引用副本!

H5还新增了一个 hashchange 事件,也是颇有用途的一个新事件:

The 'hashchange' event is fired when the fragment identifier of the URL has changed (the part of the URL that follows the # symbol, including the # symbol).
当页面hash(#)变化时,即会触发hashchange。锚点Hash起到引导浏览器将此次记录推入历史记录栈顶的做用, window.location 对象处理“#”的改变并不会从新加载页面,而是将之当成新页面,放入历史栈里。而且,当前进或者后退或者触发hashchange事件时,咱们能够在对应的事件处理函数中注册ajax等操做!

可是hashchange这个事件不是每一个浏览器都有,低级浏览器须要用轮询检测URL是否在变化,来检测锚点的变化。当锚点内容(location.hash)被操做时,若是锚点内容发生改变浏览器才会将其放入历史栈中,若是锚点内容没发生变化,历史栈并不会增长,而且也不会触发hashchange事件。

想必你猜到了,这里说的低级浏览器,指的就是可爱的IE了。好比我有一个url从http://tonylee.pw#hash_start=1 变化到http://tonylee.pw#hash_start=2 ,实现良好的浏览器是会触发一个名为hashchange 的事件,可是对于低版本的IE(稍后我会对具体的兼容性作个总结),咱们只能经过设置一个Inerval来不断的轮询url是否发生变化,来判断是否发生了相似hashchange的事件,同时能够声明对应的事件处理函数,从而模拟事件的处理。以下是当浏览器不支持hashchange事件时的模拟方法:

(function(window) {

 // 若是浏览器不支持原生实现的事件,则开始模拟,不然退出。
 if ( "onhashchange" in window.document.body ) { return; }

 var location = window.location,
 oldURL = location.href,
 oldHash = location.hash;

 // 每隔100ms检查hash是否发生变化
 setInterval(function() {
     var newURL = location.href,
     newHash = location.hash;

     // hash发生变化且全局注册有onhashchange方法(这个名字是为了和模拟的事件名保持统一);
     if ( newHash != oldHash && typeof window.onhashchange === "function"  ) {
         // 执行方法
         window.onhashchange({
             type: "hashchange",
             oldURL: oldURL,
             newURL: newURL
         });

         oldURL = newURL;
         oldHash = newHash;
     }
 }, 100);
})(window);
熟悉了这些新的H5 api,大概对前端路由的实现方式,有了一个小小的模型了。咱们来看下兼容性:

<script type="text/javascript" src="./jquery-1.9.1.js"></script>
 <script>
 $(function (){
   if(history&&history.pushState){
     alert("true");
   }else{
     alert("false");
   }
   $(window).on("hashchange",function (){
     alert("hashchange");
   });
 });
 </script>
由上边的测试我得出了一些兼容性概览:

history&&history.pushState兼容以下:
 chrome true;
 Firefox true;
 IE10 true;
 IE<=9 false;  
 PS:ie<=9既然不支持这些api那就只能采用hash方案,来实现路由系统的兼容了。

hashchange兼容以下:
 IE9 true;
 IE8 true;
 IE7 false;
 ...

页面load时,onhashchange默认触发状况:
 chrome 需主动trigger才能触发
 FF 需主动trigger才能触发
 IE 需主动trigger才能触发

页面load时,onpopstate默认触发状况:
 chrome <34版本以前的默认触发 
 FF 默认不触发
 IE 默认不触发
PS:以上是我手动测试的一个大概状况,具体的兼容状况能够去这里测试(http://caniuse.com/)。
只有webkit内核浏览器才会默认触发 popstate (chrome>34的可能实现的有问题,safari就很正常)。

到这里,说了这么多api, 其实咱们对标签化/路由系统应该有了一个大概的了解。若是考虑H5的api,过去facebook和twitter实现路由系统时,约定用”#!”实现,这估计也是一个为了照顾搜索引擎的约定。毕竟前端路由系统涉及到大量的ajx,而这些ajax对应url路径对于搜索引擎来讲,是很难匹配起来的。

路由大概的实现过程能够这么理解, 对于高级浏览器,利用H5的新Api作好页面上不一样板块ajax等操做与url的映射关系,甚至能够本身用javascript书写一套历史栈管理模块,从而绕过浏览器本身的历史栈。而当用户的操做触发popstate时,能够判断此时的url与板块的映射关系,从而加载对应的ajax板块。这样你就能够把一个具备很复杂ajax版面结构页面的url发送给你的朋友了,而你的朋友在浏览器中打开这个连接时,前端路由系统url和板块映射关系会解析并还原出整个页面的原貌!通常SPA(单页面应用)和一些复杂的社交站应用,会广泛拥有本身的前端路由系统。

看到这里,想必你也想到一个问题,浏览器第一次打开某个连接时,确定会首先被定向到server端进行路由解析,上边所说的前端路由系统,都是创建在页面已经打开,而且前端能够利用H5等的api拦截下这些URL变化,确保这些URL变化不会发送的server端返回新的页面。可是考虑这种状况,连接是在一个新的浏览器tab中打开的,那么这时候前端就没法拦截下这个url,因此,这就要求serer和前端制定好一个规则,那些url是须要前端解析的,那些url是属于后端的,而server判断出这个url的某部分结构不是本身应该解决的部分时,它就应该意识到,这是前端路由系统的URL部分,须要定向到拥有前端路由系统javascript代码的页面,交给前端处理,好比,nodejs中:

//Express框架的路由访问控制文件server.js,增长路由配置。
app.use(function (req, res) {
  if(req.path.indexOf('/routeForServerSide')>=0){
     res.send("这里返回的都是server端处理的路由");
  }
  //好比AngularJS页面
  else{ 
     res.sendfile('这里能够将已经配置好angularJS路由的页面返回');
  }
});
经过这样的方式,属于前端的路由系统始终能够被正确的交给前端路由系统去handle。对于php,.net也都是相似的配置server路由,给前端路由留下出口便可。

AngularJS框架中路由通常都这样配置:

app.config(['$routeProvider', '$locationProvider', function ($routeProvider, $locationProvider) {
 $routeProvider
 .when('/login', {
   templateUrl: '/login.html',
   controller: 'LoginController'
 }).otherwise({
   redirectTo: '/homepage'
 });
 $locationProvider.html5Mode(true);
}])
能够看到,angular正是将URL、模块模板、模块控制器,进行一个系统的映射,从而实现出一套前端路由系统。这套路由系统默认是以#号开始的,url中锚点#号后边的url即标志着前端路由系统URL部分的开始。这么作是为了照顾到更多浏览器,由于利用hash方案,IE对这套路由系统也会有很好的支持性(前边已经说到,低版本IE对H5的新Api支持很差)。而若是项目压根就不想考虑IE,在Ng中,就能够直接调用$locationProvider.html5Mode(true) 来利用H5的api实现路由系统,从而去掉#号,不用hash方案,这样作URL可能会更美观一些-_-“。

正常状况下,URL中的”/”通常是server端路由采用的标记,而”?”或者”#”再或者”#!”,则通常为前端路由采用的开始标记,咱们能够在这些符号后边,经过键值对的形式,描述一个页面具备哪些板块配置信息。也不乏有的网站为了美观,先后端共用”/”进行路由索引(好比前边说的twitter)。

咱们来看两个比较经典的网站:

1.Sina(新浪)
做为国内SNS的翘楚,新浪的路由形式也很高大上,好比:
在FF,Chrome,IE>=10时新浪的URL是这样的:

http://weibo.com/mygroups?gid=221102230086340215&wvr=5&leftnav=1

PS:能够看到从?号开始就是前端路由了,一大堆的键值对。

在IE<=9时:

http://weibo.com/mygroups?gid=221102230086340215&wvr=5&leftnav=1#!/mygroups?gid=221102230086340215&wvr=5&leftnav=1

PS:仔细观察你会发现,新浪在#!后边把路由段,复制了一遍,这是由于IE低版本不支持H5的新api,所以采用#号的hash方案(好比前边讲到的hashchange或轮询等技术),这样就照顾到全部的浏览器啦~
2.Gmail
做为一款超好用的SPA应用典范中的典范,不管从界面风格仍是易用性...好吧不扯了直接说路由:
收件箱:https://mail.google.com/mail/u/1/#inbox
星标箱:https://mail.google.com/mail/u/1/#starred
发件箱:https://mail.google.com/mail/u/1/#sent
草稿箱:https://mail.google.com/mail/u/1/#drafts
PS:看到了么,Gmail表示url不是给正常人看的,一概用#来实现前端路由部分,甚是简洁明了(其实挺赞的!)。最重要的是,这种路由方案,兼容性没的说(多是Gmail很看重IE用户群体)!
最后总结下:

H5+hash方案:兼容因此浏览器,又照顾到了高级浏览器应用新特性。

纯H5方案:表示IE是谁,我不认识-_-",这套方案应用纯H5的新特性,URL随心定制。

纯Hash方案:其实一开始我是拒绝的,但是...但是...duang...IE~~:)
不论哪一种方案,最终的目的都是但愿能解决ajax标签化的问题。以上说了这么多,仅仅是分析了这些路由系统大概的实现方式和兼容性解决方案,若是有机会,我会再写一篇文章介绍下主流框架中或者类库中,具体是如何实现这套路由系统的,javascript版本的历史栈管理模块又是怎么样的,实现思路如何。