SPA路由機制詳解(看不懂不要錢~~)

鬼鬼鬼發表於2018-09-21


前言

總所周知,隨著前端應用的業務功能起來越複雜,使用者對於使用體驗的要求越來越高,單面(SPA)成為前端應用的主流形式。而大型單頁應用最顯著特點之一就是採用的前端路由跳轉子頁面系統,通過改變頁面的URL,在不重新請求頁面的情況下,更新頁面檢視。

更新檢視但是瀏覽器不重新渲染整個頁面,只是重新渲染部分子頁面,載入速度快,頁面反應靈活,這是 SPA 的優勢,這也是前端路由原理的核心,這會給人一種彷彿在操作 APP 一樣的感覺,目前在瀏覽器環境中實現這一功能的方式主要有兩種:

  • 利用 URLhash(#)
  • 利用 H5 新增方法 History interface

利用URLHash(#)

H5 還沒有流行開來時,一般 SPA 都採用 urlhash(#) 作為錨點,獲取到 # 之後的值,並監聽其改變,再進行渲染對應的子頁面。網易雲音樂官網就是利用的此技術。

例如,你的地址為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>
複製程式碼

img

可見此時我們已經完全監聽到了 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>
複製程式碼

img

可見匯入檔案已經生效,目前大部分框架編譯過後是採用類似此種方式處理。

例如,vue 框架,.vue 檔案是一個自定義的檔案型別,用類 HTML 語法描述一個 Vue 元件。每個 .vue 檔案包含三種型別的頂級語言塊 <template><script><style>vue-loader 會解析檔案,提取每個語言塊,如有必要會通過其它 loader 處理,最後將他們組裝成一個 CommonJS 模組,module.exports 出一個 Vue.js 元件物件。。

AJAX 方式

本篇文章是詳解路由機制,AJAX 就直接採用 JQuery 這個輪子。

定義一個 HTML 檔案,名為 demo2.html,在裡面寫入一些內容(由於主頁面已經有headbody等根標籤,此檔案只需寫入需要替換的標籤):

<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>
複製程式碼

img

可見,利用 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>
複製程式碼

效果如下:

img

至此,利用 hash(#) 進行前端路由管理都已實現。

利用 H5 新增方法 History interface

上面使用的 hash 法實現路由固然不錯,但是問題就是實在太醜~ 如果在微信或者其他不顯示 URLAPP 中使用,倒也無所謂,但是如果在一般的瀏覽器中使用就會遇到問題了。

由此,H5History 模式,解決了這一問題。

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
  • 根據同源策略,如果設定不同域名地址,會報錯,這樣做的目的是:防止使用者以為它們是同一個網站,若沒有此限制,將很容易進行 XSSCSRF 等攻擊方式

改變當前的歷史記錄:replaceState(state, title, url)

瀏覽器支援度: IE10+

  • 引數含義同 pushstate
  • 改變當前的歷史記錄而不是新增新的記錄
  • 同樣不會觸發 popstate

history.state

瀏覽器支援度: IE10+

  • 返回當前歷史記錄的 state

popstate

定義:每當同一個文件的瀏覽歷史(即 history 物件)出現變化時,就會觸發 popstate 事件。

注意:若僅僅呼叫 pushState 方法或 replaceState 方法 ,並不會觸發該事件,只有使用者點選瀏覽器倒退按鈕和前進按鈕,或者使用 JavaScript 呼叫 backforwardgo 方法時才會觸發。另外,該事件只針對同一個文件,如果瀏覽歷史的切換,導致載入不同的文件,該事件也不會觸發。

栗子:

window.onpopstate= (event) => {
&emsp;&emsp;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
      }
    })
  });
複製程式碼

演示圖如下所示:

img

總結

一般場景下,hashhistory 都可以,除非你更在意顏值,# 符號夾雜在 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 依賴的頁面。”
  • 需在後端(ApacheNginx)進行簡單的路由配置,同時搭配前端路由的 404 頁面支援。

相關文章