【深入吧,HTML5】性能&集成——HistoryAPI

NO IMAGE

博客 有更多精品文章喲。

前言

在深入瞭解 History API 之前,我們需要討論一下前端路由;路由指的是通過不同 URL 展示不同頁面或者內容的功能,這個概念最初是由後端提出的,因此,在傳統的 Web 開發模式中,路由都是服務器來控制和管理的。

既然已經有了後端路由,為什麼還需要前端路由呢?我們知道跳轉頁面實際上就是為了展示那個頁面的內容,那麼無論是選擇 AJAX 異步的方式獲取數據還是將頁面內容保存在本地,都是為了讓頁面之間的交互不必每次都刷新頁面,這樣用戶體驗會有極大的提升,也就能被稱為 SPA(單頁面應用)了;但是,不夠完美,因為這種場景下缺少路由功能,所以會導致用戶多次獲取頁面之後,不小心刷新當前頁面,會直接退回到頁面的 初始狀態,用戶體驗極差。

那麼前端路由是怎樣解決改變頁面內容的同時改變 URL 並保持頁面不刷新呢?這就引出了我們這篇文章的主題:History API

History API

DOM window 對象通過 history 對象提供了對 當前會話(標籤頁或者 frame)瀏覽歷史的訪問,在 HTML4 的時候我們已經能夠操縱瀏覽歷史向前或向後跳轉了;當時,我們能夠使用的屬性和方法有下面這些:

  • window.history.length:返回當前會話瀏覽過的頁面數量。
  • window.history.go(?delta):接受一個整數作為參數,按照當前頁面在會話瀏覽歷史記錄中的位置為基準進行移動。如果參數為 0 或 undefined、null、false,將刷新頁面,相當於執行 window.location.reload()。如果在運行這個方法的過程中,發現移動後會超出會話瀏覽歷史記錄的邊界時,將沒有任何效果,並且也不會報錯。
  • window.history.back():移動到上一頁,相當於點擊瀏覽器的後退按鈕,等價於 window.history.go(-1)
  • window.history.forward():移動到下一頁,相當於點擊瀏覽器的前進按鈕,等價於 window.history.go(1)

window.history.back()window.history.forward() 就是通過 window.history.go(?delta) 實現的,因此,如果沒有上一頁或者下一頁,那表示會超出邊界,所以它們的處理方式和 window.history.go(?delta) 是一樣的。

HTML4 的時候並沒有能夠改變 URL 的 API;但是,從 HTML5 開始,History API 新增了操作會話瀏覽歷史記錄的功能。以下是新增的屬性和方法:

  • window.history.state:這個參數是隻讀的,表示與會話瀏覽歷史的當前記錄相關聯的狀態對象。
  • window.history.pushState(data, title, ?url):在會話瀏覽歷史記錄中添加一條記錄。以下是方法的參數詳情:
    • data(狀態對象):是一個能被序列化的任何東西,例如 object、array、string、null 等。為了方便用戶重新載入時使用,狀態對象會在序列化之後保存在本地;此外,序列化之後 的狀態對象根據瀏覽器的不同有不一樣的大小限制(注意:規範 並沒有說需要限制大小),如果超出,將會拋出異常。
    • title(頁面標題):當前所有的瀏覽器都會忽略這個參數,因此可以置為空字符串。
    • url(頁面地址):如果新的 URL 不是絕對路徑,那麼將會相對於當前 URL 處理;並且,新的 URL 必須與當前 URL 同源,否則將拋出錯誤。另外,該參數是可選的,默認為當前頁面地址。
  • window.history.replaceState(data, title, ?url):與 window.history.pushState(data, title, ?url) 類似,區別在於 replaceState 將修改會話瀏覽歷史的當前記錄,而不是新增一條記錄;但是,需要注意:調用 replaceState 方法還是會在 全局 瀏覽歷史記錄中創建新記錄 。

調用 pushStatereplaceState 方法之後,地址欄會更改 URL,卻不會立即加載新的頁面,等到用戶重新載入時,才會真正進行加載。因此,同源的目的 是為了防止惡意代碼讓用戶以為自己處於另一個頁面。

popstate 事件

每當用戶導航會話瀏覽歷史的記錄時,就會觸發 popstate 事件;例如,用戶點擊瀏覽器的倒退和前進按鈕;當然這些操作在 JavaScript 中也有對應的 window.history.back()window.history.forward()window.history.go(?delta) 方法能夠達到同樣的效果。

【深入吧,HTML5】性能&集成——HistoryAPI

如果導航到的記錄是由 window.history.pushState(data, title, ?url) 創建或者 window.history.replaceState(data, title, ?url) 修改的,那麼 popstate 事件對象的 state 屬性將包含導航到的記錄的狀態對象的一個 拷貝

【深入吧,HTML5】性能&集成——HistoryAPI

另外,如果用戶在地址欄中 手動 修改 hash 或者通過寫入 window.location.hash 的方式來 模擬用戶 行為,那麼也會觸發 popstate 事件,並且還會在會話瀏覽歷史中新增一條記錄。需要注意的是,在調用 window.history.pushState(data, title, ?url) 時,如果 url 參數中有 hash,並不會觸發這一條規則;因為我們要知道,pushState 只是導致會話瀏覽歷史的記錄發生變化,讓地址欄有所反應,並不是 用戶導航 或者通過腳本來 模擬用戶 的行為。

【深入吧,HTML5】性能&集成——HistoryAPI

獲取當前狀態對象

在介紹 HTML5 中 history 對象新增的屬性和方法時,有說道 window.history.state 屬性,通過它我們也能得到 popstate 事件觸發時獲取的狀態對象。

在用戶重新載入頁面時,popstate 事件並不會觸發,因此,想要獲取會話瀏覽歷史的當前記錄的狀態對象,只能通過 window.history.state 屬性。

Location 對象

Location 對象提供了 URL 相關的信息和操作方法,通過 document.locationwindow.location 屬性都能訪問這個對象。

History API 和 Location 對象實際上是通過地址欄中的 URL 關聯 的,因為 Location 對象的值始終與地址欄中的 URL 保持一致,所以當我們操作會話瀏覽歷史的記錄時,Location 對象也會隨之更改;當然,我們修改 Location 對象,也會觸發瀏覽器執行相應操作並且改變地址欄中的 URL。

屬性

Location 對象提供以下屬性:

  • window.location.href:完整的 URL;http://username:[email protected]:8080/test/index.html?id=1&name=test#test
  • window.location.protocol:當前 URL 的協議,包括 :http:
  • window.location.host:主機名和端口號,如果端口號是 80(http)或者 443(https),那就會省略端口號,因此只會包含主機名;www.test.com:8080
  • window.location.hostname:主機名;www.test.com
  • window.location.port:端口號;8080
  • window.location.pathname:URL 的路徑部分,從 / 開始;/test/index.html
  • window.location.search:查詢參數,從 ? 開始;?id=1&name=test
  • window.location.hash:片段標識符,從 # 開始;#test
  • window.location.username:域名前的用戶名;username
  • window.location.password:域名前的密碼;password
  • window.location.origin:只讀,包含 URL 的協議、主機名和端口號;http://username:[email protected]:8080

除了 window.location.origin 之外,其他屬性都是可讀寫的;因此,改變屬性的值能讓頁面做出相應變化。例如對 window.location.href 寫入新的 URL,瀏覽器就會立即跳轉到相應頁面;另外,改變 window.location 也能達到同樣的效果。

// window.location = 'https://www.example.com';
window.location.href = 'https://www.example.com';

需要注意的是,如果想要在同一標籤頁下的不同 frame(例如父窗口和子窗口)之間 跨域 改寫 URL,那麼只能通過 window.location.href 屬性,其他的屬性寫入都會拋出跨域錯誤。

Demo

【深入吧,HTML5】性能&集成——HistoryAPI

【深入吧,HTML5】性能&集成——HistoryAPI

改變 hash

改變 hash 並不會觸發頁面跳轉,因為 hash 鏈接的是當前頁面中的某個片段,所以如果 hash 有變化,那麼頁面將會滾動到 hash 所鏈接的位置;當然,頁面中如果 不存在 hash 對應的片段,則沒有 任何效果。這和 window.history.pushState(data, title, ?url) 方法非常類似,都能在不刷新頁面的情況下更改 URL;因此,我們也可以使用 hash 來實現前端路由,但是 hash 相比 pushState 來說有以下缺點:

  • hash 只能修改 URL 的片段標識符部分,並且必須從 # 開始;而 pushState 卻能修改路徑、查詢參數和片段標識符;因此,在新增會話瀏覽歷史的記錄時,pushState 比起 hash 來說更符合以前後端路由的訪問方式,也更加優雅。

    // hash
    http://www.example.com/#/example
    // pushState
    http://www.example.com/example
    
  • hash 必須與原先的值不同,才能新增會話瀏覽歷史的記錄;而 pushState 卻能新增相同 URL 的記錄。

  • hash 想為新增的會話瀏覽歷史記錄關聯數據,只能通過字符串的形式放入 URL 中;而 pushState 方法卻能關聯所有能被序列化的數據。

  • hash 不能修改頁面標題,雖然 pushState 現在設置的標題會被瀏覽器忽略,但是並不代表以後不會支持。

hashchange 事件

我們可以通過 hashchange 事件監聽 hash 的變化,這個事件會在用戶導航到有 hash 的記錄時觸發,它的事件對象將包含 hash 改變前的 oldURL 屬性和 hash 改變後的 newURL 屬性。

另外,hashchange 事件與 popstate 事件一樣也不會通過 window.history.pushState(data, title, ?url) 觸發。

【深入吧,HTML5】性能&集成——HistoryAPI

方法

Location 對象提供以下方法:

  • window.location.assign(url) 方法接受一個 URL 字符串作為參數,使得瀏覽器立刻跳轉到新的 URL。

    document.location.assign('http://www.example.com');
    // or
    // document.location = 'http://www.example.com';
    
  • window.location.replace(url) 方法與window.location.assign(url) 實現一樣的功能,區別在於 replace 方法執行後跳轉的 URL 會 覆蓋 瀏覽歷史中的當前記錄,因此原先的當前記錄就在瀏覽歷史中 刪除 了。

  • window.location.reload(boolean) 方法使得瀏覽器重新加載當前 URL。如果該方法沒有接受值或值為 false,那麼就相當於用戶點擊瀏覽器的刷新按鈕,這將導致瀏覽器 拉取緩存 中的頁面;當然,如果沒有緩存,那就會像執行 window.location.reload(true) 一樣,重新請求 頁面。

  • window.location.toString() 方法返回整個 URL 字符串。

    window.location.toString();
    // or
    // window.location.href;
    

路由實現

在使用 History API 實現路由時,我們要注意這個 API 裡的方法(pushStatereplaceState)在改變 URL 時,並不會觸發事件;因此想要像 hash 一樣 只通過 事件(hashchange)實現路由是不太可能了。

既然如此,我們就需要知道哪些方式能夠觸發 URL 的更新了;在單頁面應用中,URL 改變只能由下面三種情況引起:

  1. 點擊瀏覽器的前進或後退按鈕。
  2. 點擊 a 標籤。
  3. 調用 pushState 或者 replaceState 方法。

對於用戶手動點擊瀏覽器的前進或後退按鈕的操作,通過監聽 popstate 事件,我們就能知道 URL 是否改變了;點擊 a 標籤實際上也是調用了 pushState 或者 replaceState 方法,只不過因為 a 標籤會有 默認行為,所以需要阻止它,以避免進行跳轉。

Demo

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>前端路由實現</title>
<style>
.link {
color: #00f;
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<ul>
<li><a class="link" data-href="/111">111</a></li>
<li><a class="link" data-href="/222">222</a></li>
<li><a class="link" data-href="/333">333</a></li>
</ul>
<div id="content"></div>
<script src="./router.js"></script>
<script>
// 創建實例
const router = new Router();
const contentDOM = document.querySelector('#content');
// 註冊路由
router.route('/111', state => {
contentDOM.innerHTML = '111';
});
router.route('/222', state => {
contentDOM.innerHTML = '222';
});
router.route('/333', state => {
contentDOM.innerHTML = '333';
});
</script>
</body>
</html>
// router.js
const noop = () => undefined;
class Router {
constructor() {
this.init();
}
// 初始化
init() {
this.routes = {};
this.listen();
this.bindLink();
}
// 全部的監聽事件
listen() {
window.addEventListener('DOMContentLoaded', this.listenEventInstance.bind(this));
window.addEventListener('popstate', this.listenEventInstance.bind(this));
}
unlisten() {
window.removeEventListener('DOMContentLoaded', this.listenEventInstance);
window.removeEventListener('popstate', this.listenEventInstance);
}
// 監聽事件後,觸發路由的回調
listenEventInstance() {
this.trigger(this.getCurrentPathname());
};
getCurrentPathname() {
return window.location.pathname;
}
// 註冊路由
route(pathname, callback = noop) {
this.routes[pathname] = callback;
}
// 觸發回調
trigger(pathname) {
if (!this.routes[pathname]) {
return;
}
const {state} = window.history;
this.routes[pathname](state);
}
// 綁定 a 標籤,阻止默認行為
bindLink() {
document.addEventListener('click', e => {
const {target} = e;
const {nodeName, dataset: {href}} = target;
if (!nodeName === 'A' || !href) {
return;
}
e.preventDefault();
window.history.pushState(null, '', href);
this.trigger(href);
});
}
}

生成 Router 的實例時,我們需要做以下工作:

  • 初始化路由映射;這個映射實際上就是一個對象,key 是路徑名,value 是觸發的回調。
  • 監聽 popstateDOMContentLoaded 事件;在上文我們已經知道 popstate 事件在頁面加載時並不會觸發,因此需要監聽 DOMContentLoaded 事件來觸發初始的 URL 的回調。
  • 綁定全部 a 標籤,以便我們在阻止默認行為之後,能夠調用 pushStatereplaceState 方法來更新 URL,並觸發回調。

註冊路由其實上就是在 路由映射對象 中為 路徑 綁定 回調,因為 URL 改變後會執行回調,所以我們可以在回調中改變內容;這樣一個很簡單的前端路由就實現了。

總結

到此為止,我們深入的瞭解了 History API 和 Location 對象,並理清了它們之間的關係。最重要的是需要明白為什麼需要前端路由以及適合在什麼樣的場景下使用;另外,我們也通過 History API 實現了一個小巧的前端路由,雖然這個實現很簡單,但是五臟俱全,通過它能很清晰的知道像 React、Vue 之類的前端框架的路由實現原理。

參考資料

  1. Manipulating the browser history
  2. HTML5 History API 和 Location 對象剖析
  3. 技術選型 — 關於前端路由和後端路由的個人思考
  4. History 對象
  5. Location 對象,URL 對象,URLSearchParams 對象
  6. Session history and navigation
  7. 前端路由實現與 react-router 源碼分析
  8. 剖析單頁面應用路由實現原理
  9. 由淺入深地教你開發自己的 React Router v4
  10. 單頁面應用路由實現原理:以 React-Router 為例

相關文章

[javascript]搞清this的指向只需問兩個問題

[webpack]中小型多頁面應用整合webpack終極方案

[web前端性能優化]性能優化只有三步,你瞭解嗎

React怎麼實現Vue的組件