浅析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版本的历史栈管理模块又是怎么样的,实现思路如何。