從理解路由到實現一套 Router(路由)
平時在 Vue 項目中經常用到路由,但是也僅僅處於會用的層面,很多基礎知識並不是真正的理解。於是就趁着十一” 小長假 “查閱了很多資料,總結下路由相關的知識,查缺不漏,加深自己對路由的理解。
路由
在 Web 開發過程中,經常遇到路由的概念。那麼到底什麼是路由呢?簡單來說,路由就是 URL 到函數的映射。
路由這個概念本來是由後端提出來的,在以前用模板引擎開發頁面的時候,是使用路由返回不同的頁面,大致流程是這樣的:
-
瀏覽器發出請求;
-
服務器監聽到 80 或者 443 端口有請求過來,並解析 UR L 路徑;
-
服務端根據路由設置,查詢相應的資源,可能是 html 文件,也可能是圖片資源......,然後將這些資源處理並返回給瀏覽器;
-
瀏覽器接收到數據,通過
content-type
決定如何解析數據
簡單來說,路由就是用來跟後端服務器交互的一種方式,通過不同的路徑來請求不同的資源,請求HTML
頁面只是路由的其中一項功能。
服務端路由
當服務端接收到客戶端發來的 HTTP 請求時,會根據請求的 URL,找到相應的映射函數,然後執行該函數,並將函數的返回值發送給客戶端。
對於最簡單的靜態資源服務器,可以認爲,所有 URL 的映射函數就是一個文件讀取操作。對於動態資源,映射函數可能是一個數據庫讀取操作,也可能進行一些數據處理,等等。
客戶端路由
服務端路由會造成服務器壓力比較大,而且用戶訪問速度也比較慢。在這種情況下,出現了單頁應用。
單頁應用,就是隻有一個頁面,用戶訪問網址,服務器返回的頁面始終只有一個,不管用戶改變了瀏覽器地址欄的內容或者在頁面發生了跳轉,服務器不會重新返回新的頁面,而是通過相應的 js 操作來實現頁面的更改。
前端路由其實就是:通過地址欄內容的改變,顯示不同的頁面。
前端路由的優點:
-
前端路由可以讓前端自己維護路由與頁面展示的邏輯,每次頁面改動不需要通知服務端。
-
更好的交互體驗:不用每次從服務端拉取資源。
前端路由的缺點: 使用瀏覽器的前進、後退鍵時會重新發送請求,來獲取數據,沒有合理利用緩存。
前端路由實現原理: 本質就是監測 URL 的變化,通過攔截 URL 然後解析匹配路由規則。
前端路由的實現方式
- hash 模式(location.hash + hashchange 事件)
hash 模式的實現方式就是通過監聽 URL 中的 hash 部分的變化,觸發haschange
事件,頁面做出不同的響應。但是 hash 模式下,URL 中會帶有 #,不太美觀。
- history 模式
history 路由模式的實現,基於 HTML5 提供的 History 全局對象,它的方法有:
-
history.go()
:在會話歷史中向前或者向後移動指定頁數 -
history.forward()
:在會話歷史中向前移動一頁,跟瀏覽器的前進按鈕功能相同 -
history.back()
:在會話歷史記錄中向後移動一頁,跟瀏覽器的後腿按鈕功能相同 -
history.pushState()
:向當前瀏覽器會話的歷史堆棧中添加一個狀態,會改變當前頁面 url,但是不會伴隨這刷新 -
history.replaceState()
:將當前的會話頁面的 url 替換成指定的數據,replaceState 會改變當前頁面的 url,但也不會刷新頁面 -
window.onpopstate
:當前活動歷史記錄條目更改時,將觸發popstate
事件
history 路由的實現,主要是依靠pushState
、replaceState
和window.onpopstate
實現的。但是有幾點要注意:
-
當活動歷史記錄條目更改時,將觸發 popstate 事件;
-
調用
history.pushState()
或history.replaceState()
不會觸發 popstate 事件 -
popstate 事件只會在瀏覽器某些行爲下觸發,比如:點擊後退、前進按鈕(或者在 JavaScript 中調用
history.back()
、history.forward()
、history.go()
方法) -
a 標籤的錨點也會觸發該事件
對 pushState 和 replaceState 行爲的監聽
如果想監聽 pushState 和 replaceState 行爲,可以通過在方法裏面主動去觸發 popstate 事件,另一種是重寫history.pushState
,通過創建自己的eventedPushState
自定義事件,並手動派發,實際使用過程中就可以監聽了。具體做法如下:
function eventedPushState(state, title, url) {
var pushChangeEvent = new CustomEvent("onpushstate", {
detail: {
state,
title,
url
}
});
document.dispatchEvent(pushChangeEvent);
return history.pushState(state, title, url);
}
document.addEventListener(
"onpushstate",
function(event) {
console.log(event.detail);
},
false
);
eventedPushState({}, "", "new-slug");
複製代碼
router 和 route 的區別
route 就是一條路由,它將一個 URL 路徑和一個函數進行映射。而 router 可以理解爲一個容器,或者說一種機制,它管理了一組 route。
概括爲:route 只是進行了 URL 和函數的映射,在當接收到一個 URL 後,需要去路由映射表中查找相應的函數,這個過程是由 router 來處理的。
動態路由和靜態路由
- 靜態路由
靜態路由只支持基於地址的全匹配。
- 動態路由
動態路由除了可以兼容全匹配外還支持多種” 高級匹配模式 “,它的路徑地址中含有路徑參數,使得它可以按照給定的匹配模式將符合條件的一個或多個地址映射到同一個組件上。
動態路由一般結合角色權限控制使用。
動態路由的存儲有兩種方式:
-
將路由存儲到前端
-
將路由存儲到數據庫
動態路由的好處:
-
靈活,無需手動維護
-
存儲到數據庫,增加安全性
實現一個路由
一個簡單的 Router 應該具備哪些功能
- 以 Vue 爲例,需要有
<router-link>
鏈接、<router-view>
容器、component
組件和path
路由路徑:
<div id="app">
<h1>Hello World</h1>
<p>
<!-- 使用 router-link 組件進行導航 -->
<!-- 通過傳遞 to 來指定鏈接 -->
<!-- <router-link> 將呈現一個帶有正確 href屬性的<a>標籤 -->
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
</p>
<!-- 路由出口 -->
<!-- 路由匹配到的組件將渲染在這裏 -->
<router-view></router-view>
</div>
複製代碼
const routes = [{
path: '/',
component: Home
},
{
path: '/about',
component: About
}]
複製代碼
- 以 React 爲例,需要有
<BrowserRouter>
容器、<Route>
路由、組件和鏈接:
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
複製代碼
<div>
<h1>Home</h1>
<nav>
<Link to="/">Home</Link> | {""}
<Link to="about">About</Link>
</nav>
</div>
複製代碼
-
綜上,一個簡單的 Router 應該具備以下功能:
-
容器(組件)
-
路由
-
業務組件 & 鏈接組件
不借助第三方工具庫,如何實現路由
不借助第三方工具庫實現路由,我們需要思考以下幾個問題:
-
如何實現自定義標籤,如 vue 的
<router-view>
,React 的<Router>
-
如何實現業務組件
-
如何動態切換路由
準備工作
- 根據對前端路由 history 模式的理解,將大致過程用如下流程圖表示:
- 如果不借助第三方庫,我們選擇使用 Web components 。Web Components 由三項主要技術組成,它們可以一起使用來創建封裝功能的定製元素。
-
Custom elements(自定義元素) :一組 JavaScript API,允許我們定義 custom elements 及其行爲,然後可以在界面按照需要使用它們。
-
Shadow DOM(影子 DOM) :一組 JavaScript API,用於將封裝的 “影子”DOM 樹附加到元素(與主文檔分開呈現)並控制關聯的功能。通過這種方式,可以保持元素的功能私有。
-
HTML template(HTML 模版) :
<template>
和<slot>
可以編寫不在頁面顯示的標記模板,然後它們可以作爲自定義元素結構的基礎被多次重用。
另外還需要注意 Web Components 的生命週期:
connectedCallback:當 custom element 首次被插入文檔 DOM 時,被調用
disconnectedCallback:當 custom element 從文檔 DOM 中刪除時,被調用
adoptedCallback:當 custom element 被移動到新的文檔時,被調用
attributeChangedCallback:當 custom element 增加、刪除、修改自身屬性時,被調用
- Shadow DOM
-
Shadow DOM 特有的術語:
- Shadow host:一個常規DOM節點,Shadow DOM 會被附加到這個節點上 - Shadow tree:Shadow DOM 內部的 DOM 樹 - Shadow boundary:Shadow DOM 結束的地方,也是常規DOM開始的地方 - Shadow root:Shadow tree 的根節點
-
Shadow DOM 的重要參數 mode:
- open:shadow root 元素可以從 js 外部訪問根節點 - close :拒絕從 js 外部訪問關閉的 shadow root 節點 - 語法:`const shadow = this.attachShadow({mode:closed});`
- 通過自定義標籤創建容器組件、路由、業務組件和鏈接組件標籤,使用
CustomElementRegistry.define()
註冊自定義元素。其中,Custom elements 的簡單寫法舉例:
<my-text></my-text>
<script>
class MyText extends HTMLElement{
constructor(){
super();
this.append(“我的文本”);
}
}
window.customElements.define("my-text",MyText);
</script>
複製代碼
- 組件的實現可以使用 Web Components,但是這樣有缺點,我們沒有打包引擎處理 Web Components 組件,將其全部加載過來。
爲了解決以上問題,我們選擇動態加載,遠程去加載一個 html 文件。html 文件裏面的結構如下:支持模版 (template),腳本 (template),腳本 (script),樣式 (style),非常地像 vue。組件開發模版如下:
<template>
<div>商品詳情</div>
<div id="detail">
商品ID:<span id="product-id" class="product-id"></span>
</div>
</template>
<script>
this.querySelector("#product-id").textContent = history.state.id;
</script>
<style>
.product-id{
color:red;
}
</style>
複製代碼
- 監聽路由的變化:
popstate
可以監聽大部分路由變化的場景,除了pushState
和 replaceState
。
pushState
和 replaceState
可以改變路由,改變歷史記錄,但是不能觸發popstate
事件,需要自定義事件並手動觸發自定義事件,做出響應。
- 整體架構圖如下:
- 鏈接組件 — CustomLink(c-link)
當用戶點擊<c-link>
標籤後,通過event.preventDefault();
阻止頁面默認跳轉。根據當前標籤的to
屬性獲取路由,通過history.pushState("","",to)
進行路由切換。
// <c-link to="/" class="c-link">首頁</c-link>
class CustomLink extends HTMLElement {
connectedCallback() {
this.addEventListener("click", ev => {
ev.preventDefault();
const to = this.getAttribute("to");
// 更新瀏覽器歷史記錄
history.pushState("", "", to)
})
}
}
window.customElements.define("c-link", CustomLink);
複製代碼
- 容器組件 — CustomRouter(c-router)
主要是收集路由信息,監聽路由信息的變化,然後加載對應的組件
- 路由 — CustomRoute(c-route)
主要是提供配置信息,對外提供 getData 的方法
// 優先於c-router註冊
// <c-route path="/" component="home" default></c-route>
class CustomRoute extends HTMLElement {
#data = null;
getData() {
return {
default: this.hasAttribute("default"),
path: this.getAttribute("path"),
component: this.getAttribute("component")
}
}
}
window.customElements.define("c-route", CustomRoute);
複製代碼
- 業務組件 — CustomComponent(c-component)
實現組件,動態加載遠程的 html,並解析
完整代碼實現
index.html:
<div class="product-item">測試的產品</div>
<div class="flex">
<ul class="menu-x">
<c-link to="/" class="c-link">首頁</c-link>
<c-link to="/about" class="c-link">關於</c-link>
</ul>
</div>
<div>
<c-router>
<c-route path="/" component="home" default></c-route>
<c-route path="/detail/:id" component="detail"></c-route>
<c-route path="/about" component="about"></c-route>
</c-router>
</div>
<script src="./router.js"></script>
複製代碼
home.html:
<template>
<div>商品清單</div>
<div id="product-list">
<div>
<a data-id="10" class="product-item c-link">香蕉</a>
</div>
<div>
<a data-id="11" class="product-item c-link">蘋果</a>
</div>
<div>
<a data-id="12" class="product-item c-link">葡萄</a>
</div>
</div>
</template>
<script>
let container = this.querySelector("#product-list");
// 觸發歷史更新
// 事件代理
container.addEventListener("click", function (ev) {
console.log("item clicked");
if (ev.target.classList.contains("product-item")) {
const id = +ev.target.dataset.id;
history.pushState({
id
}, "", `/detail/${id}`)
}
})
</script>
<style>
.product-item {
cursor: pointer;
color: blue;
}
</style>
複製代碼
detail.html:
<template>
<div>商品詳情</div>
<div id="detail">
商品ID:<span id="product-id" class="product-id"></span>
</div>
</template>
<script>
this.querySelector("#product-id").textContent=history.state.id;
</script>
<style>
.product-id{
color:red;
}
</style>
複製代碼
about.html:
<template>
About Me!
</template>
複製代碼
route.js:
const oriPushState = history.pushState;
// 重寫pushState
history.pushState = function (state, title, url) {
// 觸發原事件
oriPushState.apply(history, [state, title, url]);
// 自定義事件
var event = new CustomEvent("c-popstate", {
detail: {
state,
title,
url
}
});
window.dispatchEvent(event);
}
// <c-link to="/" class="c-link">首頁</c-link>
class CustomLink extends HTMLElement {
connectedCallback() {
this.addEventListener("click", ev => {
ev.preventDefault();
const to = this.getAttribute("to");
// 更新瀏覽歷史記錄
history.pushState("", "", to);
})
}
}
window.customElements.define("c-link", CustomLink);
// 優先於c-router註冊
// <c-toute path="/" component="home" default></c-toute>
class CustomRoute extends HTMLElement {
#data = null;
getData() {
return {
default: this.hasAttribute("default"),
path: this.getAttribute("path"),
component: this.getAttribute("component")
}
}
}
window.customElements.define("c-route", CustomRoute);
// 容器組件
class CustomComponent extends HTMLElement {
async connectedCallback() {
console.log("c-component connected");
// 獲取組件的path,即html的路徑
const strPath = this.getAttribute("path");
// 加載html
const cInfos = await loadComponent(strPath);
const shadow = this.attachShadow({ mode: "closed" });
// 添加html對應的內容
this.#addElement(shadow, cInfos);
}
#addElement(shadow, info) {
// 添加模板內容
if (info.template) {
shadow.appendChild(info.template.content.cloneNode(true));
}
// 添加腳本
if (info.script) {
// 防止全局污染,並獲得根節點
var fun = new Function(`${info.script.textContent}`);
// 綁定腳本的this爲當前的影子根節點
fun.bind(shadow)();
}
// 添加樣式
if (info.style) {
shadow.appendChild(info.style);
}
}
}
window.customElements.define("c-component", CustomComponent);
// <c-router></c-router>
class CustomRouter extends HTMLElement {
#routes
connectedCallback() {
const routeNodes = this.querySelectorAll("c-route");
console.log("routes:", routeNodes);
// 獲取子節點的路由信息
this.#routes = Array.from(routeNodes).map(node => node.getData());
// 查找默認的路由
const defaultRoute = this.#routes.find(r => r.default) || this.#routes[0];
// 渲染對應的路由
this.#onRenderRoute(defaultRoute);
// 監聽路由變化
this.#listenerHistory();
}
// 渲染路由對應的內容
#onRenderRoute(route) {
var el = document.createElement("c-component");
el.setAttribute("path", `/${route.component}.html`);
el.id = "_route_";
this.append(el);
}
// 卸載路由清理工作
#onUploadRoute(route) {
this.removeChild(this.querySelector("#_route_"));
}
// 監聽路由變化
#listenerHistory() {
// 導航的路由切換
window.addEventListener("popstate", ev => {
console.log("onpopstate:", ev);
const url = location.pathname.endsWith(".html") ? "/" : location.pathname;
const route = this.#getRoute(this.#routes, url);
this.#onUploadRoute();
this.#onRenderRoute(route);
});
// pushStat或replaceSate
window.addEventListener("c-popstate", ev => {
console.log("c-popstate:", ev);
const detail = ev.detail;
const route = this.#getRoute(this.#routes, detail.url);
this.#onUploadRoute();
this.#onRenderRoute(route);
})
}
// 路由查找
#getRoute(routes, url) {
return routes.find(function (r) {
const path = r.path;
const strPaths = path.split('/');
const strUrlPaths = url.split("/");
let match = true;
for (let i = 0; i < strPaths.length; i++) {
if (strPaths[i].startsWith(":")) {
continue;
}
match = strPaths[i] === strUrlPaths[i];
if (!match) {
break;
}
}
return match;
})
}
}
window.customElements.define("c-router", CustomRouter);
// 動態加載組件並解析
async function loadComponent(path, name) {
this.caches = this.caches || {};
// 緩存存在,直接返回
if (!!this.caches[path]) {
return this.caches[path];
}
const res = await fetch(path).then(res => res.text());
// 利用DOMParser校驗
const parser = new DOMParser();
const doc = parser.parseFromString(res, "text/html");
// 解析模板,腳本,樣式
const template = doc.querySelector("template");
const script = doc.querySelector("script");
const style = doc.querySelector("style");
// 緩存內容
this.caches[path] = {
template,
script,
style
}
return this.caches[path];
}
複製代碼
關於本文
作者:betterwlf
https://juejin.cn/post/7150794643985137695
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/tmGFCDZGVAkOR8avReSzkg