【深入吧,HTML 5】 效能 & 整合 —— History API

晨風明悟發表於2019-01-31

部落格 有更多精品文章喲。

前言

在深入瞭解 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) 方法能夠達到同樣的效果。

User navigation

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

Jump to pushState

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

Jump to hash

獲取當前狀態物件

在介紹 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:password@www.test.com: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:password@www.test.com: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

window.location.href cross domain

window.location.href cross domain error

改變 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) 觸發。

hashchange

方法

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 為例

相關文章