前端單頁應用實現路由的策略有兩種,分別是 基於hash 和 基於 History API
基於hash
通過將一個 URL path 用
#
Hash 符號拆分。— 瀏覽器視作其為虛擬的片段。
最早前端路由的實現就是 基於 location.hash 來實現的, 有這樣幾個特性:
觸發hash 變化的方式有兩種:
基於 History API
普通的 URL path (無雜湊的 URL path) — 伺服器需要攔截路徑請求返回 入口 index.html 檔案
基於 hash 的實現,存在一些問題,例如
#
影響美觀。因此 H5 中,提供了 History API 來實現 URL 的變化。 採用這種策略實現的前端路由, 主要是利用了 popstate 事件來監聽歷史記錄的變化。
補在文章後面:
除此之外, 基於 Hash 的路由不需要對伺服器做改動,但是基於 History API 的路由則需要對伺服器做一些 hanle 處理。
在開始手動實現之前,有必要先了解一下將會涉及的 API 與 方法。
返回 URL 中 #
部分的內容, 例如 http://www.example.com/index.html#hello
中的 #hello
部分。
window.hashchange
方法該方法監聽url 中, hash 部分改變時的回撥。
注意⚠️: 使用者點選 瀏覽器的前進後退 按鈕,如果url 的hash 發生改變,同樣也會觸發該方法。
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 的補充:
""
一個空串。window.popstate
方法該方法當用戶通過點選 前進後退 按鈕,或者通過js, 呼叫 history.go()
, history.back()
, history.forward()
時,將會被觸發
瞭解了所必須的 API,下面詳細的試試如何手動實現路由。
因為接下來的兩種實現,我們都將用到同樣的檔案目錄結構,所以在這裡我們先建立好他們。
.
├── index.html
├── js
│ └── router.js
└── templates
├── 404.html
├── about.html
├── contact.html
└── index.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>
注意一個步驟中的程式碼,在body閉合標籤的上方,我們引入了js指令碼。
將以下程式碼新增到 <nav></nav>
標籤內,作為我們的路由導航。
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
// 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",
},
};
//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();
});
//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);
};
最後,我們需要在頁面首次載入的時候呼叫一下 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
方法。
將以下內容新增在 <nav></nav>
標籤內:
<a href="/">Home</a>
<a href="#about">About</a>
<a href="#contact">Contact</a>
//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",
},
};
//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);
};
同樣的,頁面首次載入,以及 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();
總結的來說, 基於 History API 的實現,主要是利用了 h5 提供的 pushState
, replaceState
方法。去改變當前頁面的 URL, 同時,利用點選事件 結合 window.popState
監聽事件觸發頁面的更新渲染邏輯。
而 基於 location.hash 的實現,則更為簡單,直接 利用 a 便籤的 href 屬性,觸發 hashchange
事件,進而觸發頁面的更新邏輯。
對比起來, 基於 location.hash 的實現要更為簡單。 但是基於 History API 實現的路由,URL 中不含有 #
而顯得使用者體驗更加良好。
此外值得注意的一點是, 現在的框架中,大都提供了這兩中實現方案。 在實際應用中。 如果應用了 基於 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;
}
}
/api/*
#
hash 後面的部分視作一個分頁,因此預設的就不會觸發頁面的過載