前言
總所周知,隨著前端應用的業務功能起來越複雜,使用者對於使用體驗的要求越來越高,單面(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
同文件的URL
pushState()
設定的新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
頁面支援。