現代前端項目多爲單頁Web應用(SPA),在單頁Web應用中路由是其中的重要環節。javascript
每一個現代前端框架都有與之對應的路由實現,例如 vue-router、react-router 等。html
本文並不涉及 vue-router、react-router 的實現方式,而是介紹前端路由的基本實現原理及實現方式。前端
vue-router、react-router 的源碼解析,會在之後的文章中逐步推出。vue
SPA 是 single page web application 的簡稱,譯爲單頁Web應用。java
簡單的說 SPA 就是一個WEB項目只有一個 HTML 頁面,一旦頁面加載完成,SPA 不會由於用戶的操做而進行頁面的從新加載或跳轉。 取而代之的是利用 JS 動態的變換 HTML 的內容,從而來模擬多個視圖間跳轉。react
對於初學者來講,理解傳統頁面與 SPA 視圖間的差別是困難的。git
在這裏,用兩張圖,來分別代表傳統頁面與 SPA 視圖間的區別:github
上圖代表了,在傳統的網站設計中,每一個HTML文件都是一個完成的HTML頁面,涵蓋了完整的HTML結構。web
上圖代表了,在 SPA 的應用設計中,一個應用只有一個HTML文件,在HTML文件中包含一個佔位符(即圖中的 container),佔位符對應的內容由每一個視圖來決定,對於 SPA 來講,頁面的切換就是視圖之間的切換。vue-router
最開始的網頁是多頁面的,直到 Ajax 的出現,才慢慢有了 SPA。
SPA 的出現大大提升了 WEB 應用的交互體驗。在與用戶的交互過程當中,再也不須要從新刷新頁面,獲取數據也是經過 Ajax 異步獲取,頁面顯示變的更加流暢。
但因爲 SPA 中用戶的交互是經過 JS 改變 HTML 內容來實現的,頁面自己的 url 並無變化,這致使了兩個問題:
前端路由就是爲了解決上述問題而出現的。
簡單的說,就是在保證只有一個 HTML 頁面,且與用戶交互時不刷新和跳轉頁面的同時,爲 SPA 中的每一個視圖展現形式匹配一個特殊的 url。在刷新、前進、後退和SEO時均經過這個特殊的 url 來實現。
爲實現這一目標,咱們須要作到如下二點:
接下來要介紹的 hash 模式和 history 模式,就是實現了上面的功能
這裏的 hash 就是指 url 後的 # 號以及後面的字符。好比說 "www.baidu.com/#hashhash" ,其中 "#hashhash" 就是咱們指望的 hash 值。
因爲 hash 值的變化不會致使瀏覽器像服務器發送請求,並且 hash 的改變會觸發 hashchange 事件,瀏覽器的前進後退也能對其進行控制,因此在 H5 的 history 模式出現以前,基本都是使用 hash 模式來實現前端路由。
使用到的API:
window.location.hash = 'hash字符串'; // 用於設置 hash 值
let hash = window.location.hash; // 獲取當前 hash 值
// 監聽hash變化,點擊瀏覽器的前進後退會觸發
window.addEventListener('hashchange', function(event){
let newURL = event.newURL; // hash 改變後的新 url
let oldURL = event.oldURL; // hash 改變前的舊 url
},false)
複製代碼
接下來咱們來實現一個路由對象
建立一個路由對象, 實現 register 方法用於註冊每一個 hash 值對應的回調函數
class HashRouter{
constructor(){
//用於存儲不一樣hash值對應的回調函數
this.routers = {};
}
//用於註冊每一個視圖
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
}
複製代碼
不存在hash值時,認爲是首頁,因此實現 registerIndex 方法用於註冊首頁時的回調函數
class HashRouter{
constructor(){
//用於存儲不一樣hash值對應的回調函數
this.routers = {};
}
//用於註冊每一個視圖
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
//用於註冊首頁
registerIndex(callback = function(){}){
this.routers['index'] = callback;
}
}
複製代碼
經過 hashchange 監聽 hash 變化,並定義 hash 變化時的回調函數
class HashRouter{
constructor(){
//用於存儲不一樣hash值對應的回調函數
this.routers = {};
window.addEventListener('hashchange',this.load.bind(this),false)
}
//用於註冊每一個視圖
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
//用於註冊首頁
registerIndex(callback = function(){}){
this.routers['index'] = callback;
}
//用於調用不一樣視圖的回調函數
load(){
let hash = location.hash.slice(1),
handler;
//沒有hash 默認爲首頁
if(!hash){
handler = this.routers.index;
}else{
handler = this.routers[hash];
}
//執行註冊的回調函數
handler.call(this);
}
}
複製代碼
咱們作一個例子來演示一下咱們剛剛完成的 HashRouter
<body>
<div id="nav">
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
</div>
<div id="container"></div>
</body>
複製代碼
let router = new HashRouter();
let container = document.getElementById('container');
//註冊首頁回調函數
router.registerIndex(()=> container.innerHTML = '我是首頁');
//註冊其餘視圖回到函數
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');
//加載視圖
router.load();
複製代碼
來看一下效果:
基本的路由功能咱們已經實現了,但依然有點小問題
對應的解決辦法以下:
代碼修改後:
class HashRouter{
constructor(){
//用於存儲不一樣hash值對應的回調函數
this.routers = {};
window.addEventListener('hashchange',this.load.bind(this),false)
}
//用於註冊每一個視圖
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
//用於註冊首頁
registerIndex(callback = function(){}){
this.routers['index'] = callback;
}
//用於處理視圖未找到的狀況
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用於處理異常狀況
registerError(callback = function(){}){
this.routers['error'] = callback;
}
//用於調用不一樣視圖的回調函數
load(){
let hash = location.hash.slice(1),
handler;
//沒有hash 默認爲首頁
if(!hash){
handler = this.routers.index;
}
//未找到對應hash值
else if(!this.routers.hasOwnProperty(hash)){
handler = this.routers['404'] || function(){};
}
else{
handler = this.routers[hash]
}
//執行註冊的回調函數
try{
handler.apply(this);
}catch(e){
console.error(e);
(this.routers['error'] || function(){}).call(this,e);
}
}
}
複製代碼
再來一個例子,演示一下:
<body>
<div id="nav">
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
<a href="#/page4">page4</a>
<a href="#/page5">page5</a>
</div>
<div id="container"></div>
</body>
複製代碼
let router = new HashRouter();
let container = document.getElementById('container');
//註冊首頁回調函數
router.registerIndex(()=> container.innerHTML = '我是首頁');
//註冊其餘視圖回到函數
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');
router.register('/page4',()=> {throw new Error('拋出一個異常')});
//加載視圖
router.load();
//註冊未找到對應hash值時的回調
router.registerNotFound(()=>container.innerHTML = '頁面未找到');
//註冊出現異常時的回調
router.registerError((e)=>container.innerHTML = '頁面異常,錯誤消息:<br>' + e.message);
複製代碼
來看一下效果:
至此,基於 hash 方式實現的前端路由,咱們已經將基本雛形實現完成了。
接下來咱們來介紹前端路由的另外一種模式:history 模式。
在 HTML5 以前,瀏覽器就已經有了 history 對象。但在早期的 history 中只能用於多頁面的跳轉:
history.go(-1); // 後退一頁
history.go(2); // 前進兩頁
history.forward(); // 前進一頁
history.back(); // 後退一頁
複製代碼
在 HTML5 的規範中,history 新增瞭如下幾個 API:
history.pushState(); // 添加新的狀態到歷史狀態棧
history.replaceState(); // 用新的狀態代替當前狀態
history.state // 返回當前狀態對象
複製代碼
來自MDN的解釋:
HTML5引入了 history.pushState() 和 history.replaceState() 方法,它們分別能夠添加和修改歷史記錄條目。這些方法一般與window.onpopstate 配合使用。
history.pushState() 和 history.replaceState() 均接收三個參數(state, title, url)
參數說明以下:
history.pushState() 和 history.replaceState() 的區別在於:
因爲 history.pushState() 和 history.replaceState() 能夠改變 url 同時,不會刷新頁面,因此在 HTML5 中的 histroy 具有了實現前端路由的能力。
回想咱們以前完成的 hash 模式,當 hash 變化時,能夠經過 hashchange 進行監聽。 而 history 的改變並不會觸發任何事件,因此咱們沒法直接監聽 history 的改變而作出相應的改變。
因此,咱們須要換個思路,咱們能夠羅列出全部可能觸發 history 改變的狀況,而且將這些方式一一進行攔截,變相地監聽 history 的改變。
對於單頁應用的 history 模式而言,url 的改變只能由下面四種方式引發:
思路已經有了,接下來咱們來實現一個路由對象
class HistoryRouter{
constructor(){
//用於存儲不一樣path值對應的回調函數
this.routers = {};
}
//用於註冊每一個視圖
register(path,callback = function(){}){
this.routers[path] = callback;
}
//用於註冊首頁
registerIndex(callback = function(){}){
this.routers['/'] = callback;
}
//用於處視圖未找到的狀況
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用於處理異常狀況
registerError(callback = function(){}){
this.routers['error'] = callback;
}
}
複製代碼
class HistoryRouter{
constructor(){
//用於存儲不一樣path值對應的回調函數
this.routers = {};
}
//用於註冊每一個視圖
register(path,callback = function(){}){
this.routers[path] = callback;
}
//用於註冊首頁
registerIndex(callback = function(){}){
this.routers['/'] = callback;
}
//用於處理視圖未找到的狀況
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用於處理異常狀況
registerError(callback = function(){}){
this.routers['error'] = callback;
}
//跳轉到path
assign(path){
history.pushState({path},null,path);
this.dealPathHandler(path)
}
//替換爲path
replace(path){
history.replaceState({path},null,path);
this.dealPathHandler(path)
}
//通用處理 path 調用回調函數
dealPathHandler(path){
let handler;
//沒有對應path
if(!this.routers.hasOwnProperty(path)){
handler = this.routers['404'] || function(){};
}
//有對應path
else{
handler = this.routers[path];
}
try{
handler.call(this)
}catch(e){
console.error(e);
(this.routers['error'] || function(){}).call(this,e);
}
}
}
複製代碼
最終代碼以下:
class HistoryRouter{
constructor(){
//用於存儲不一樣path值對應的回調函數
this.routers = {};
this.listenPopState();
this.listenLink();
}
//監聽popstate
listenPopState(){
window.addEventListener('popstate',(e)=>{
let state = e.state || {},
path = state.path || '';
this.dealPathHandler(path)
},false)
}
//全局監聽A連接
listenLink(){
window.addEventListener('click',(e)=>{
let dom = e.target;
if(dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')){
e.preventDefault()
this.assign(dom.getAttribute('href'));
}
},false)
}
//用於首次進入頁面時調用
load(){
let path = location.pathname;
this.dealPathHandler(path)
}
//用於註冊每一個視圖
register(path,callback = function(){}){
this.routers[path] = callback;
}
//用於註冊首頁
registerIndex(callback = function(){}){
this.routers['/'] = callback;
}
//用於處理視圖未找到的狀況
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用於處理異常狀況
registerError(callback = function(){}){
this.routers['error'] = callback;
}
//跳轉到path
assign(path){
history.pushState({path},null,path);
this.dealPathHandler(path)
}
//替換爲path
replace(path){
history.replaceState({path},null,path);
this.dealPathHandler(path)
}
//通用處理 path 調用回調函數
dealPathHandler(path){
let handler;
//沒有對應path
if(!this.routers.hasOwnProperty(path)){
handler = this.routers['404'] || function(){};
}
//有對應path
else{
handler = this.routers[path];
}
try{
handler.call(this)
}catch(e){
console.error(e);
(this.routers['error'] || function(){}).call(this,e);
}
}
}
複製代碼
再作一個例子來演示一下咱們剛剛完成的 HistoryRouter
<body>
<div id="nav">
<a href="/page1">page1</a>
<a href="/page2">page2</a>
<a href="/page3">page3</a>
<a href="/page4">page4</a>
<a href="/page5">page5</a>
<button id="btn">page2</button>
</div>
<div id="container">
</div>
</body>
複製代碼
let router = new HistoryRouter();
let container = document.getElementById('container');
//註冊首頁回調函數
router.registerIndex(() => container.innerHTML = '我是首頁');
//註冊其餘視圖回到函數
router.register('/page1', () => container.innerHTML = '我是page1');
router.register('/page2', () => container.innerHTML = '我是page2');
router.register('/page3', () => container.innerHTML = '我是page3');
router.register('/page4', () => {
throw new Error('拋出一個異常')
});
document.getElementById('btn').onclick = () => router.assign('/page2')
//註冊未找到對應path值時的回調
router.registerNotFound(() => container.innerHTML = '頁面未找到');
//註冊出現異常時的回調
router.registerError((e) => container.innerHTML = '頁面異常,錯誤消息:<br>' + e.message);
//加載頁面
router.load();
複製代碼
來看一下效果:
至此,基於 history 方式實現的前端路由,咱們已經將基本雛形實現完成了。
但須要注意的是,history 在修改 url 後,雖然頁面並不會刷新,但咱們在手動刷新,或經過 url 直接進入應用的時候, 服務端是沒法識別這個 url 的。由於咱們是單頁應用,只有一個 html 文件,服務端在處理其餘路徑的 url 的時候,就會出現404的狀況。 因此,若是要應用 history 模式,須要在服務端增長一個覆蓋全部狀況的候選資源:若是 URL 匹配不到任何靜態資源,則應該返回單頁應用的 html 文件。
接下來,咱們來探究一下,什麼時候使用 hash 模式,什麼時候使用 history 模式。
hash 模式相比於 history 模式的優勢:
hash 模式相比於 history 模式的缺點:
綜上所述,當咱們不須要兼容老版本IE瀏覽器,而且能夠控制服務端覆蓋全部狀況的候選資源時,咱們能夠愉快的使用 history 模式了。
反之,很遺憾,只能使用醜陋的 hash 模式~
本文簡單分析並實現了單頁路由中的 hash 模式和 history 模式,固然,它與 vue-router、react-router 相比還太過簡陋,關於 vue-router、react-router 的源碼解析,會在之後的文章中逐步推出。
歡迎關注微信公衆號
【前端小黑屋】
,每週1-3篇精品優質文章推送,助你走上進階之旅