前言
總所周知,隨着前端應用的業務功能起來越復雜,用戶對於使用體驗的要求越來越高,單面(SPA)成為前端應用的主流形式。而大型單頁應用最顯著特點之一就是采用的前端路由跳轉子頁面系統,通過改變頁面的URL,在不重新請求頁面的情況下,更新頁面視圖。
更新視圖但是瀏覽器不重新渲染整個頁面,只是重新渲染部分子頁面,加載速度快,頁面反應靈活,這是 SPA 的優勢,這也是前端路由原理的核心,這會給人一種仿佛在操作 APP 一樣的感覺,目前在瀏覽器環境中實現這一功能的方式主要有兩種:
- 利用
URL的hash(#) - 利用
H5新增方法History interface
利用URL的Hash(#)
在 H5 還沒有流行開來時,一般 SPA 都采用 url 的 hash(#) 作為錨點,獲取到 # 之后的值,並監聽其改變,再進行渲染對應的子頁面。網易雲音樂官網就是利用的此技術。
例如,你的地址為http://localhost:8888/#/abc 那么利用 location.hash 輸出的內容就為 #/abc。
那么我就先從 location 這個對象說起。
先來看看location的官方屬性有哪些
| 屬性 | 描述 |
|---|---|
| hash | 設置或返回從 # 開始的 URL (錨) |
| host | 設置或返回主機名和當前 URL 的端口號 |
| hostname | 設置或返回當前 URL 的主機名 |
| href | 設置或返回完整的 URL |
| pathname | 設置或返回當前 URL 的路徑部分 |
| port | 設置或返回當前 URL 的端口號 |
| protocol | 設置或返回當前 URL 的協議 |
| search | 設置或返回從 ? 開始的 URL 部分 |
由上表格可以知道,我們可以輕易的獲取到 # 之后的部分,那么拿到這個部分我們怎么監聽其變化以及對應的子頁面進行改變呢?
window 對象中有一個事件是專門監聽hash的變化,那就是onhashchange,首先我們需要監聽此事件:
<body>
<h1 id="id"></h1>
<a href="#/id1">id1</a>
<a href="#/id2">id2</a>
<a href="#/id3">id3</a>
</body>
<script>
window.addEventListener('hashchange', e => {
e.preventDefault()
document.querySelector('#id').innerHTML = location.hash
})
</script>

可見此時我們已經完全監聽到了 URL 的變化,頁面上的內容也對應改變了。
那么,該如何載入不同的頁面呢,目前來說有三種方式:
- 尋找節點內容並改變(也就是上面我們演示的內容)
import一個JS文件,文件內部export模版字符串- 利用
AJAX加載對應的HTML模版
第一種方式已經演示過,不過這種方式局限性太大,下面我會演示另外兩種方式加載頁面。
import 方式
定義一個 JS 文件,名為 demo1.js,在里面輸入內容:
const str = `
<div>
我是import進來的JS文件
</div>
`
export default str
在主文件里 import 進來,並進行測試(使用 Chrome 一定要使用服務器開啟,或者直接用火狐打開):
<body>
<h1 id="id"></h1>
<a href="#/id1">id1</a>
<a href="#/id2">id2</a>
<a href="#/id3">id3</a>
</body>
<!-- 在 HTML 導入文件記得要加上 type="module" -->
<script type="module">
import demo1 from './demo1.js'
document.querySelector('#id').innerHTML = demo1
window.addEventListener('hashchange', e => {
e.preventDefault()
document.querySelector('#id').innerHTML = location.hash
})
</script>

可見導入文件已經生效,目前大部分框架編譯過后是采用類似此種方式處理。
例如,vue 框架,.vue 文件是一個自定義的文件類型,用類 HTML 語法描述一個 Vue 組件。每個 .vue 文件包含三種類型的頂級語言塊 <template>, <script> 和 <style>,vue-loader 會解析文件,提取每個語言塊,如有必要會通過其它 loader 處理,最后將他們組裝成一個 CommonJS 模塊,module.exports 出一個 Vue.js 組件對象。。
AJAX 方式
本篇文章是詳解路由機制,AJAX 就直接采用 JQuery 這個輪子。
定義一個 HTML 文件,名為 demo2.html,在里面寫入一些內容(由於主頁面已經有head,body等根標簽,此文件只需寫入需要替換的標簽):
<div>
我是AJAX加載進來的HTML文件
</div>
我們在主文件里寫入,並進行測試:
<body>
<h1 id="id"></h1>
<a href="#/id1">id1</a>
<a href="#/id2">id2</a>
<a href="#/id3">id3</a>
</body>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script type="module">
// import demo1 from './demo1.js'
// document.querySelector('#id').innerHTML = demo1
$.ajax({
url: './demo2.html',
success: (res) => {
document.querySelector('#id').innerHTML = res
}
})
window.addEventListener('hashchange', e => {
e.preventDefault()
document.querySelector('#id').innerHTML = location.hash
})
</script>

可見,利用 AJAX 加載進來的文件也已經生效。
既然加載不同頁面的內容都已經生效,那么只需要包裝一下我們的監聽,利用觀察者模式封裝路由的變化:
<body>
<h1 id="id">我是空白頁</h1>
<a href="#/id1">id1</a>
<a href="#/id2">id2</a>
<a href="#/id3">id3</a>
</body>
<script type="module">
import demo1 from './demo1.js'
// 創建一個 newRouter 類
class newRouter {
// 初始化路由信息
constructor() {
this.routes = {};
this.currentUrl = '';
}
// 傳入 URL 以及 根據 URL 對應的回調函數
route(path, callback = () => {}) {
this.routes[path] = callback;
}
// 切割 hash,渲染頁面
refresh() {
this.currentUrl = location.hash.slice(1) || '/';
this.routes[this.currentUrl] && this.routes[this.currentUrl]();
}
// 初始化
init() {
window.addEventListener('load', this.refresh.bind(this), false);
window.addEventListener('hashchange', this.refresh.bind(this), false);
}
}
// new 一個 Router 實例
window.Router = new newRouter();
// 路由實例初始化
window.Router.init();
// 獲取關鍵節點
var content = document.querySelector('#id');
Router.route('/id1', () => {
content.innerHTML = 'id1'
});
Router.route('/id2', () => {
content.innerHTML = demo1
});
Router.route('/id3', () => {
$.ajax({
url: './demo2.html',
success: (res) => {
content.innerHTML = res
}
})
});
</script>
效果如下:

至此,利用 hash(#) 進行前端路由管理都已實現。
利用 H5 新增方法 History interface
上面使用的 hash 法實現路由固然不錯,但是問題就是實在太丑~ 如果在微信或者其他不顯示 URL 的 APP 中使用,倒也無所謂,但是如果在一般的瀏覽器中使用就會遇到問題了。
由此,H5 的 History 模式,解決了這一問題。
在 H5 之前, History 僅僅只有一下幾個 API:
| API | 說明 |
|---|---|
back() |
回退到上次訪問的 URL (與瀏覽器點擊后退按鈕相同) |
forward() |
前進到回退之前的 URL (與瀏覽器點擊向前按鈕相同) |
go(n) |
n 接收一個整數,移動到該整數指定的頁面,比如go(1)相當於forward(),go(-1) 相當於 back(),go(0)相當於刷新當前頁面 |
如果移動的位置超出了訪問歷史的邊界,以上三個方法並不報錯,而是靜默失敗。
然而,到了 H5 的時代,新的 H5 則賦予了其更多的新特性:
往返緩存
默認情況下,瀏覽器會緩存當前會話頁面,這樣當下一個頁面點擊后退按鈕,或前一個頁面點擊前進按鈕,瀏覽器便會從緩存中提取並加載此頁面,這個特性被稱為“往返緩存”。
PS: 此緩存會保留頁面數據、DOM和js狀態,實際上是將整個頁面完好無缺地保留。
往歷史記錄棧中添加記錄:pushState(state, title, url)
瀏覽器支持度: IE10+
- state: 一個
JS對象(不大於640kB),主要用於在popstate事件中作為參數被獲取。如果不需要這個對象,此處可以填null - title: 新頁面的標題,部分瀏覽器(比如 Firefox )忽略此參數,因此一般為
null - url: 新歷史記錄的地址,可為頁面地址,也可為一個錨點值,新
url必須與當前url處於同一個域,否則將拋出異常,此參數若沒有特別標注,會被設為當前文檔url
栗子:
// 現在是 localhost/1.html
const stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');
// 瀏覽器地址欄將立即變成 localhost/2.html
// 但!!!
// 不會跳轉到 2.html
// 不會檢查 2.html 是否存在
// 不會在 popstate 事件中獲取
// 不會觸發頁面刷新
// 這個方法僅僅是添加了一條最新記錄
除此之外,仍有幾點需要注意:
- 將
url設為錨點值時不會觸發hashchange - 根據同源策略,如果設置不同域名地址,會報錯,這樣做的目的是:防止用戶以為它們是同一個網站,若沒有此限制,將很容易進行
XSS、CSRF等攻擊方式
改變當前的歷史記錄:replaceState(state, title, url)
瀏覽器支持度: IE10+
- 參數含義同
pushstate - 改變當前的歷史記錄而不是添加新的記錄
- 同樣不會觸發
popstate
history.state
瀏覽器支持度: IE10+
- 返回當前歷史記錄的
state。
popstate
定義:每當同一個文檔的瀏覽歷史(即 history 對象)出現變化時,就會觸發 popstate 事件。
注意:若僅僅調用 pushState 方法或 replaceState 方法 ,並不會觸發該事件,只有用戶點擊瀏覽器倒退按鈕和前進按鈕,或者使用 JavaScript 調用 back 、 forward 、 go 方法時才會觸發。另外,該事件只針對同一個文檔,如果瀏覽歷史的切換,導致加載不同的文檔,該事件也不會觸發。
栗子:
window.onpopstate= (event) => {
console.log(event.state) //當前歷史記錄的state對象
}
實現
了解了這么多內容,那么就讓我們開始實現 History 模式的路由吧!
我們將上面的 HTML 稍稍改造下,請大家耐心分析:
<body>
<h1 id="id">我是空白頁</h1>
<a class="route" href="/id1">id1</a>
<a class="route" href="/id2">id2</a>
<a class="route" href="/id3">id3</a>
</body>
import demo1 from './demo1.js'
// 創建一個 newRouter 類
class newRouter {
// 初始化路由信息
constructor() {
this.routes = {};
this.currentUrl = '';
}
route(path, callback) {
this.routes[path] = (type) => {
if (type === 1) history.pushState( { path }, path, path );
if (type === 2) history.replaceState( { path }, path, path );
callback()
};
}
refresh(path, type) {
this.routes[this.currentUrl] && this.routes[this.currentUrl](type);
}
init() {
window.addEventListener('load', () => {
// 獲取當前 URL 路徑
this.currentUrl = location.href.slice(location.href.indexOf('/', 8))
this.refresh(this.currentUrl, 2)
}, false);
window.addEventListener('popstate', () => {
this.currentUrl = history.state.path
this.refresh(this.currentUrl, 2)
}, false);
const links = document.querySelectorAll('.route')
links.forEach((item) => {
// 覆蓋 a 標簽的 click 事件,防止默認跳轉行為
item.onclick = (e) => {
e.preventDefault()
// 獲取修改之后的 URL
this.currentUrl = e.target.getAttribute('href')
// 渲染
this.refresh(this.currentUrl, 2)
}
})
}
}
// new 一個 Router 實例
window.Router = new newRouter();
// 實例初始化
window.Router.init();
// 獲取關鍵節點
var content = document.querySelector('#id');
Router.route('/id1', () => {
content.innerHTML = 'id1'
});
Router.route('/id2', () => {
content.innerHTML = demo1
});
Router.route('/id3', () => {
$.ajax({
url: './demo2.html',
success: (res) => {
content.innerHTML = res
}
})
});
演示圖如下所示:

總結
一般場景下,hash 和 history 都可以,除非你更在意顏值,# 符號夾雜在 URL 里看起來確實有些不太美麗。
另外,根據 Mozilla Develop Network 的介紹,調用 history.pushState() 相比於直接修改 hash,存在以下優勢:
pushState()設置的新URL可以是與當前URL同源的任意URL;而hash只可修改#后面的部分,因此只能設置與當前URL同文檔的URLpushState()設置的新URL可以與當前URL一模一樣,這樣也會把記錄添加到棧中;而hash設置的新值必須與原來不一樣才會觸發動作將記錄添加到棧中pushState()通過stateObject參數可以添加任意類型的數據到記錄中;而hash只可添加短字符串;pushState()可額外設置title屬性供后續使用。
這么一看 history 模式充滿了 happy,感覺完全可以替代 hash 模式,但其實 history 也不是樣樣都好,雖然在瀏覽器里游刃有余,但真要通過 URL 向后端發起 HTTP 請求時,兩者的差異就來了。尤其在用戶手動輸入 URL 后回車,或者刷新(重啟)瀏覽器的時候。
hash模式下,僅hash符號之前的內容會被包含在請求中,如http://www.qqq.com,因此對於后端來說,即使沒有做到對路由的全覆蓋,也不會返回404錯誤。history模式下,前端的URL必須和實際向后端發起請求的URL一致,如http://www.qqq.com/book/id。如果后端缺少對/book/id的路由處理,將返回404錯誤。Vue-Router官網里如此描述:“不過這種模式要玩好,還需要后台配置支持……所以呢,你要在服務端增加一個覆蓋所有情況的候選資源:如果URL匹配不到任何靜態資源,則應該返回同一個index.html頁面,這個頁面就是你app依賴的頁面。”- 需在后端(
Apache或Nginx)進行簡單的路由配置,同時搭配前端路由的404頁面支持。
