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
控制頁面重新整理時是否記住使用者頁面的滾動位置。 該值是一個可設定值,有 auto
和 manual
兩種:
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 陣列:
此時點選 test 按鈕後:
瀏覽器新開啟tab 就會入棧一個 “New Tab”, 點選test 觸發 pushState()
之後, 會將當且頁面“History demo”入棧, 然後當前頁面變為 foo.html
, 頁面的狀態變作:{hello: 'world'}
, 可以透過history.state
訪問到。
值得注意的是, 頁面雖然改變了,但是還沒有更新渲染。 此時如果重新整理頁面,就會更新渲染了。
但是注意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'}
, 頁面同樣在重新整理後會更新。
注意, 該方法並不會新增一個記錄到歷史記錄。
關於History.pushState()
和 History.replaceState()
這兩個API 的補充:
- 這兩個API的,第三個引數可以可選的,如果預設,那麼操作預設以當前頁面為目標。 第二個引數可以認為始終傳入
""
一個空串。 - 頁面不僅僅
相關方法
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 的實現
優勢
- URL 看起來和普通的url 一樣, 更加美觀簡潔
- 在 SEO 方面, 普通 url 會有更多的優勢
- 現代框架通常預設支援該模式
挑戰
- 客戶端重新整理時,會把 SPA 的路由誤當作 資源請求連結,所以需要配置 web 伺服器以處理這些 “路由形式的URL” 以統一放回入口 index.html 檔案。
- 通常為了讓伺服器區分這些 “路由形式的URL”, 所以通常需要用一些字首以區分和普通 請求的區別,如
/api/*
- 透過這種方式實現時,定義路由的時候需要特別注意, 因為不當的連結跳轉可能會導致全頁面過載。
4.3.2 基於 Location.hash 的實現
優勢
- 瀏覽器不會將 URL.path 中
#
hash 後面的部分視作一個分頁,因此預設的就不會觸發頁面的過載 - 在前端定義帶有 hash 的連結總是安全的,因為它不會觸發頁面的過載
- 服務端不需要額外配置。
- 實現起來更加簡單
挑戰
- SEO 並不友好
- 使用者體驗不好
參考
- https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
- https://developer.mozilla.org/en-US/docs/Web/API/Window/hashchange_event
- https://developer.mozilla.org/en-US/docs/Web/API/Location/hash
- https://dev.to/thedevdrawer/single-page-application-routing-using-hash-or-url-9jh
- https://blog.bitsrc.io/using-hashed-vs-nonhashed-url-paths-in-single-page-apps-a66234cefc96
- https://www.jianshu.com/p/d2aa8fb951e4
- https://zhuanlan.zhihu.com/p/116023681#:~:text=前端路由實現原理就是,實現方式History 和hash。