故事從名叫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版本的歷史棧管理模塊又是怎么樣的,實現思路如何。