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版本的歷史棧管理模組又是怎麼樣的,實現思路如何。
入坑方式:
歡迎加入~!氣氛熱情,歡樂多,妹子多!
web前端 聚集地,匯聚了全國頂尖的web前端熱愛者,最新技術,最炫潮流,最靠譜的話題:
做好現在!技術只是為了改變生活!JS前端實用開發QQ群 :147250970
掃描螢幕下方的二維碼,可以關注 我的前端公眾號 。聽說妹子挺多的,及時更新一些前端解惑和段子