SPA路由實現的基本原理

暮冬有八發表於2023-02-22

1. SPA路由實現的基本原理

前端單頁應用實現路由的策略有兩種,分別是 基於hash基於 History API

基於hash

透過將一個 URL path 用 # Hash 符號拆分。— 瀏覽器視作其為虛擬的片段。

最早前端路由的實現就是 基於 location.hash 來實現的, 有這樣幾個特性:

  • URL 中hash值的改變不會被觸發頁面的過載
  • 頁面傳送請求時, hash 部分不會被髮送
  • hash 值的改變,會記錄在瀏覽器的歷史記錄,可使用瀏覽器的“後退” “前進”觸發頁面跳轉
  • 可以利用 hashchange 事件來監聽 hash 的變化

觸發hash 變化的方式有兩種:

  • 透過a 標籤的 href 屬性,使用者點選後,URL 就會發生改變,進而觸發 hashchange 事件
  • 直接對 location.hash 賦值,從而改變 URL, 觸發hashchange 事件。

基於 History API

普通的 URL path (無雜湊的 URL path) — 伺服器需要攔截路徑請求返回 入口 index.html 檔案

基於 hash 的實現,存在一些問題,例如

  • URL 上很多 # 影響美觀。

因此 H5 中,提供了 History API 來實現 URL 的變化。 採用這種策略實現的前端路由, 主要是利用了 popstate 事件來監聽歷史記錄的變化。

補在文章後面:

除此之外, 基於 Hash 的路由不需要對伺服器做改動,但是基於 History API 的路由則需要對伺服器做一些 hanle 處理。

2. 相關API 與方法

在開始手動實現之前,有必要先了解一下將會涉及的 API 與 方法。

基於 Location.hash 相關

location

例項屬性 hash

返回 URL 中 # 部分的內容, 例如 http://www.example.com/index.html#hello 中的 #hello 部分。

相關方法

window.hashchange 方法

該方法監聽url 中, hash 部分改變時的回撥。

注意⚠️: 使用者點選 瀏覽器的前進後退 按鈕,如果url 的hash 發生改變,同樣也會觸發該方法。

History API

例項屬性

History.length [只讀屬性]

返回 session 歷史記錄的長度,新開啟的tab 頁面該屬性值為 1 。

History.scrollRestoration

控制頁面重新整理時是否記住使用者頁面的滾動位置。 該值是一個可設定值,有 automanual 兩種:

  • auto : 儲存頁面滾動位置(預設)
  • manual: 不儲存頁面滾動位置

新開啟tab 頁面時,滾動位置始終回到頁面頂部。 這兩個屬性僅對 頁面重新整理有效。

History.state [只讀屬性]

瀏覽器頁面歷史記錄是一個棧結構,該屬性將返回棧頂頁面的 狀態, 在使用pushState() 或者 replaceState() 方法之前, 這個值為 null. state 是一個 js 物件,該物件將會被儲存到使用者的本地磁碟, 瀏覽器關閉重啟之後,還能夠訪問歷史值。 但是該物件有大小限制(具體大小根據瀏覽器的實現而定),一旦所設定內容超出該限制,瀏覽器將會丟擲錯誤。

例項方法

History.go()

該方法控制頁面回到歷史中的某個相對位置,僅一個可選參數列示前進的步進值,它可以為正值,也可以為負值。

  • 如果不傳入引數,或者傳入 0, 則等同於頁面重新整理。
  • 該方法無返回值。
History.back()

該方法,不接受任何引數。 它等同於 history.go(-1), 也就是頁面的 回退 按鈕。

History.forward()

該方法同樣不接受引數,等同於 history.go(1), 也就是頁面的 前進 按鈕

History.pushState()History.replaceState()

語法:

pushState(state, unused)
pushState(state, unused, url)

state 是一個 js 物件,可以是任意被序列化的值。 第二個引數被廢棄了,但是由於歷史緣故,依然保留,在使用時,應該傳入一個空字串。

這兩個方法用於手動操作History物件。

.
├── foo.html
└── index.html
<head>  
	<title>History demo</title>
</head>
<body>
    <button onclick="handleClick()">test</button>
    <p>
        some text here...
    </p>
</body>
function handleClick() {
  window.history.pushState({ hello: 'world' }, '', 'foo.html');
}

chrome 瀏覽器中,可以將滑鼠按住後退按鈕,檢視到 History 陣列:

image

此時點選 test 按鈕後:

image

瀏覽器新開啟tab 就會入棧一個 “New Tab”, 點選test 觸發 pushState() 之後, 會將當且頁面“History demo”入棧, 然後當前頁面變為 foo.html, 頁面的狀態變作:{hello: 'world'}, 可以透過history.state 訪問到。

值得注意的是, 頁面雖然改變了,但是還沒有更新渲染。 此時如果重新整理頁面,就會更新渲染了。

image

但是注意pushState 的目標url,必須是一個子域地址,且如果pushState 的目標頁面不存在,頁面重新整理之後會報404錯誤。

foo.html 中新增一個按鈕,測試 replaceState 方法:

<!--foo.html-->
  <body>
    <button onclick="handleReplace()">replace</button>
    foo foo foo foo foo foo foo foo foo foo
    <script src="script.js"></script>
  </body>
// script.js
function handleReplace() {
  window.history.replaceState({ foo: 'bar' }, '', 'bar.html');
}

當前頁面foo.html 將會被替換為 bar.html, 狀態改變為 {foo: 'bar'}, 頁面同樣在重新整理後會更新。

注意, 該方法並不會新增一個記錄到歷史記錄。

image

關於History.pushState()History.replaceState() 這兩個API 的補充:

  1. 這兩個API的,第三個引數可以可選的,如果預設,那麼操作預設以當前頁面為目標。 第二個引數可以認為始終傳入 "" 一個空串。
  2. 頁面不僅僅

相關方法

window.popstate 方法

該方法當使用者透過點選 前進後退 按鈕,或者透過js, 呼叫 history.go(), history.back(), history.forward() 時,將會被觸發

瞭解了所必須的 API,下面詳細的試試如何手動實現路由。

3. 手寫一個簡單的路由

1.0 預準備

因為接下來的兩種實現,我們都將用到同樣的檔案目錄結構,所以在這裡我們先建立好他們。

1.0.1 檔案目錄

.
├── index.html
├── js
│   └── router.js
└── templates
    ├── 404.html
    ├── about.html
    ├── contact.html
    └── index.html

1.0.2 建立伺服器 serve 的主 html 檔案

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" />
    <title></title>
  </head>
  <body>
    <nav>
      <a href="/">Home</a>
      <a href="#about">About</a>
      <a href="#contact">Contact</a>
    </nav>
    <div id="content"></div>
    <script src="js/router.js"></script>
  </body>
</html>

1.0.3 建立指令碼檔案

注意一個步驟中的程式碼,在body閉合標籤的上方,我們引入了js指令碼。

1.1 基於 History API

1.1.1 新增路由導航

將以下程式碼新增到 <nav></nav> 標籤內,作為我們的路由導航。

<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>

1.1.2 建立路由陣列

// route.js
const routes = {
    404: {
        template: "/templates/404.html",
        title: "404",
        description: "Page not found",
    },
    "/": {
        template: "/templates/index.html",
        title: "Home",
        description: "This is the home page",
    },
    "/about": {
        template: "/templates/about.html",
        title: "About Us",
        description: "This is the about page",
    },
    "/contact": {
        template: "/templates/contact.html",
        title: "Contact Us",
        description: "This is the contact page",
    },
};

1.1.3 給導航新增點選事件監聽器

//script.js
const route = (event) => {
    event = event || window.event; // get window.event if event argument not provided
    event.preventDefault();
    // window.history.pushState(state, unused, target link);
    window.history.pushState({}, "", event.target.href);
    locationHandler();
};

// create document click that watches the nav links only
document.addEventListener("click", (e) => {
    const { target } = e;
    if (!target.matches("nav a")) {
        return;
    }
    e.preventDefault();
    route();
});

1.1.4 建立處理 location URL 的函式

//script.js
const locationHandler = async () => {
    const location = window.location.pathname; // get the url path
    // if the path length is 0, set it to primary page route
    if (location.length == 0) {
        location = "/";
    }
    // get the route object from the urlRoutes object
    const route = routes[location] || routes["404"];
    // get the html from the template
    // 注意這裡是怎麼獲取到html模板的,很具有技巧性
    const html = await fetch(route.template).then((response) => response.text());
    // set the content of the content div to the html
    document.getElementById("content").innerHTML = html;
    // set the title of the document to the title of the route
    document.title = route.title;
    // 如何選中 meta 標籤
    // set the description of the document to the description of the route
    document
        .querySelector('meta[name="description"]')
        .setAttribute("content", route.description);
};

1.1.5 完成指令碼

最後,我們需要在頁面首次載入的時候呼叫一下 locationHandler 方法,否則,首頁無法呈現。

此外,我們還需要新增 URL 變化的 watcher

//script.js
// add an event listener to the window that watches for url changes
window.onpopstate = locationHandler;
// call the urlLocationHandler function to handle the initial url
window.route = route;
// call the urlLocationHandler function to handle the initial url
locationHandler();

注意,點選導航,以及使用者控制頁面前進後退,都會觸發頁面的渲染。 所以需要呼叫 locationHandler 方法。

1.2 基於hash 的實現

1.2.1 新增路由導航

將以下內容新增在 <nav></nav> 標籤內:

<a href="/">Home</a>
<a href="#about">About</a>
<a href="#contact">Contact</a>

1.2.2 建立路由陣列

//script.js
const routes = {
    404: {
        template: "/templates/404.html",
        title: "404",
        description: "Page not found",
    },
    "/": {
        template: "/templates/index.html",
        title: "Home",
        description: "This is the home page",
    },
    about: {
        template: "/templates/about.html",
        title: "About Us",
        description: "This is the about page",
    },
    contact: {
        template: "/templates/contact.html",
        title: "Contact Us",
        description: "This is the contact page",
    },
};

1.2.3 建立處理 location URL 的函式

//script.js
const locationHandler = async () => {
    // get the url path, replace hash with empty string
    var location = window.location.hash.replace("#", "");
    // if the path length is 0, set it to primary page route
    if (location.length == 0) {
        location = "/";
    }
    // get the route object from the routes object
    const route = routes[location] || routes["404"];
    // get the html from the template
    const html = await fetch(route.template).then((response) => response.text());
    // set the content of the content div to the html
    document.getElementById("content").innerHTML = html;
    // set the title of the document to the title of the route
    document.title = route.title;
    // set the description of the document to the description of the route
    document
        .querySelector('meta[name="description"]')
        .setAttribute("content", route.description);
};

1.2.4 完成指令碼

同樣的,頁面首次載入,以及 hashchange 的時候都需要呼叫 locationHandler 函式

//script.js
// create a function that watches the hash and calls the urlLocationHandler
window.addEventListener("hashchange", locationHandler);
// call the urlLocationHandler to load the page
locationHandler();

4. 總結

4.1 原理總結

總結的來說, 基於 History API 的實現,主要是利用了 h5 提供的 pushState, replaceState方法。去改變當前頁面的 URL, 同時,利用點選事件 結合 window.popState 監聽事件觸發頁面的更新渲染邏輯。

而 基於 location.hash 的實現,則更為簡單,直接 利用 a 便籤的 href 屬性,觸發 hashchange 事件,進而觸發頁面的更新邏輯。

對比起來, 基於 location.hash 的實現要更為簡單。 但是基於 History API 實現的路由,URL 中不含有 # 而顯得使用者體驗更加良好。

4.2 基於History API 的實現需要注意的事項

此外值得注意的一點是, 現在的框架中,大都提供了這兩中實現方案。 在實際應用中。 如果應用了 基於 History API 的實現方式,伺服器通常需要做一些配置

因為由於單頁應用路由的實現是前端實現的, 可以理解為是 “偽路由”, 路由的跳轉邏輯都是前端程式碼完成的,這樣就存在一個問題, 例如上面的實現中, http://127.0.0.1:5500/about 這個頁面使用者點選了頁面重新整理,就會找不到頁面。 因為瀏覽器會向伺服器 “http://127.0.0.1:5500/about” 這個地址傳送 GET 請求, 希望請求到一個單獨的 index.html 檔案, 而實際上這個檔案我們伺服器上是不存在的。 我們需要將其處理為:

http://127.0.0.1:5500/ server 返回首頁

http://127.0.0.1:5500/about server 返回首頁, 然後前端路由跳轉到 about 頁

http://127.0.0.1:5500/contact server 返回首頁, 然後前端路由跳轉到 contact 頁

為了做到這點,所以我們需要對伺服器做一些轉發處理,

// root 是我本地的頁面地址
// try_files 將匹配子級路由全部嘗試返回 index.html 檔案
server {
    listen 7000;
    location / {
        root /home/jayce/Desktop/temp/demo/front-end-router-implement/HistoryApi;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}

4.3 兩種實現模式的優勢和挑戰對比

4.3.1 基於 History API 的實現

優勢
  1. URL 看起來和普通的url 一樣, 更加美觀簡潔
  2. 在 SEO 方面, 普通 url 會有更多的優勢
  3. 現代框架通常預設支援該模式
挑戰
  1. 客戶端重新整理時,會把 SPA 的路由誤當作 資源請求連結,所以需要配置 web 伺服器以處理這些 “路由形式的URL” 以統一放回入口 index.html 檔案。
  2. 通常為了讓伺服器區分這些 “路由形式的URL”, 所以通常需要用一些字首以區分和普通 請求的區別,如 /api/*
  3. 透過這種方式實現時,定義路由的時候需要特別注意, 因為不當的連結跳轉可能會導致全頁面過載。

4.3.2 基於 Location.hash 的實現

優勢
  1. 瀏覽器不會將 URL.path 中 # hash 後面的部分視作一個分頁,因此預設的就不會觸發頁面的過載
  2. 在前端定義帶有 hash 的連結總是安全的,因為它不會觸發頁面的過載
  3. 服務端不需要額外配置。
  4. 實現起來更加簡單
挑戰
  1. SEO 並不友好
  2. 使用者體驗不好

參考

  1. https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
  2. https://developer.mozilla.org/en-US/docs/Web/API/Window/hashchange_event
  3. https://developer.mozilla.org/en-US/docs/Web/API/Location/hash
  4. https://dev.to/thedevdrawer/single-page-application-routing-using-hash-or-url-9jh
  5. https://blog.bitsrc.io/using-hashed-vs-nonhashed-url-paths-in-single-page-apps-a66234cefc96
  6. https://www.jianshu.com/p/d2aa8fb951e4
  7. https://zhuanlan.zhihu.com/p/116023681#:~:text=前端路由實現原理就是,實現方式History 和hash。

推薦閱讀

搞不懂路由跳轉?帶你瞭解 history.js 實現原理

相關文章