從理解路由到實(shí)現(xiàn)一套R(shí)outer(路由)
共 29343字,需瀏覽 59分鐘
·
2024-06-25 09:20
點(diǎn)擊上方 前端Q,關(guān)注公眾號(hào)
回復(fù)加群,加入前端Q技術(shù)交流群
作者:betterwlf
https://juejin.cn/post/7150794643985137695
平時(shí)在Vue項(xiàng)目中經(jīng)常用到路由,但是也僅僅處于會(huì)用的層面,很多基礎(chǔ)知識(shí)并不是真正的理解。于是就趁著十一”小長(zhǎng)假“查閱了很多資料,總結(jié)下路由相關(guān)的知識(shí),查缺不漏,加深自己對(duì)路由的理解。
路由
在 Web 開(kāi)發(fā)過(guò)程中,經(jīng)常遇到路由的概念。那么到底什么是路由呢?簡(jiǎn)單來(lái)說(shuō),路由就是 URL 到函數(shù)的映射。
路由這個(gè)概念本來(lái)是由后端提出來(lái)的,在以前用模板引擎開(kāi)發(fā)頁(yè)面的時(shí)候,是使用路由返回不同的頁(yè)面,大致流程是這樣的:
-
瀏覽器發(fā)出請(qǐng)求; -
服務(wù)器監(jiān)聽(tīng)到 80 或者 443 端口有請(qǐng)求過(guò)來(lái),并解析 UR L路徑; -
服務(wù)端根據(jù)路由設(shè)置,查詢相應(yīng)的資源,可能是 html 文件,也可能是圖片資源......,然后將這些資源處理并返回給瀏覽器; -
瀏覽器接收到數(shù)據(jù),通過(guò) content-type決定如何解析數(shù)據(jù)
簡(jiǎn)單來(lái)說(shuō),路由就是用來(lái)跟后端服務(wù)器交互的一種方式,通過(guò)不同的路徑來(lái)請(qǐng)求不同的資源,請(qǐng)求HTML頁(yè)面只是路由的其中一項(xiàng)功能。
服務(wù)端路由
當(dāng)服務(wù)端接收到客戶端發(fā)來(lái)的 HTTP 請(qǐng)求時(shí),會(huì)根據(jù)請(qǐng)求的 URL,找到相應(yīng)的映射函數(shù),然后執(zhí)行該函數(shù),并將函數(shù)的返回值發(fā)送給客戶端。
對(duì)于最簡(jiǎn)單的靜態(tài)資源服務(wù)器,可以認(rèn)為,所有 URL 的映射函數(shù)就是一個(gè)文件讀取操作。對(duì)于動(dòng)態(tài)資源,映射函數(shù)可能是一個(gè)數(shù)據(jù)庫(kù)讀取操作,也可能進(jìn)行一些數(shù)據(jù)處理,等等。
客戶端路由
服務(wù)端路由會(huì)造成服務(wù)器壓力比較大,而且用戶訪問(wèn)速度也比較慢。在這種情況下,出現(xiàn)了單頁(yè)應(yīng)用。
單頁(yè)應(yīng)用,就是只有一個(gè)頁(yè)面,用戶訪問(wèn)網(wǎng)址,服務(wù)器返回的頁(yè)面始終只有一個(gè),不管用戶改變了瀏覽器地址欄的內(nèi)容或者在頁(yè)面發(fā)生了跳轉(zhuǎn),服務(wù)器不會(huì)重新返回新的頁(yè)面,而是通過(guò)相應(yīng)的js操作來(lái)實(shí)現(xiàn)頁(yè)面的更改。
前端路由其實(shí)就是:通過(guò)地址欄內(nèi)容的改變,顯示不同的頁(yè)面。
前端路由的優(yōu)點(diǎn):
-
前端路由可以讓前端自己維護(hù)路由與頁(yè)面展示的邏輯,每次頁(yè)面改動(dòng)不需要通知服務(wù)端。 -
更好的交互體驗(yàn):不用每次從服務(wù)端拉取資源。
前端路由的缺點(diǎn): 使用瀏覽器的前進(jìn)、后退鍵時(shí)會(huì)重新發(fā)送請(qǐng)求,來(lái)獲取數(shù)據(jù),沒(méi)有合理利用緩存。
前端路由實(shí)現(xiàn)原理: 本質(zhì)就是監(jiān)測(cè) URL 的變化,通過(guò)攔截 URL 然后解析匹配路由規(guī)則。
前端路由的實(shí)現(xiàn)方式
-
hash模式(location.hash + hashchange 事件)
hash 模式的實(shí)現(xiàn)方式就是通過(guò)監(jiān)聽(tīng) URL 中的 hash 部分的變化,觸發(fā)haschange事件,頁(yè)面做出不同的響應(yīng)。但是 hash 模式下,URL 中會(huì)帶有 #,不太美觀。
-
history模式
history 路由模式的實(shí)現(xiàn),基于 HTML5 提供的 History 全局對(duì)象,它的方法有:
-
history.go():在會(huì)話歷史中向前或者向后移動(dòng)指定頁(yè)數(shù) -
history.forward():在會(huì)話歷史中向前移動(dòng)一頁(yè),跟瀏覽器的前進(jìn)按鈕功能相同 -
history.back():在會(huì)話歷史記錄中向后移動(dòng)一頁(yè),跟瀏覽器的后腿按鈕功能相同 -
history.pushState():向當(dāng)前瀏覽器會(huì)話的歷史堆棧中添加一個(gè)狀態(tài),會(huì)改變當(dāng)前頁(yè)面url,但是不會(huì)伴隨這刷新 -
history.replaceState():將當(dāng)前的會(huì)話頁(yè)面的url替換成指定的數(shù)據(jù),replaceState 會(huì)改變當(dāng)前頁(yè)面的url,但也不會(huì)刷新頁(yè)面 -
window.onpopstate:當(dāng)前活動(dòng)歷史記錄條目更改時(shí),將觸發(fā)popstate事件
history路由的實(shí)現(xiàn),主要是依靠pushState、replaceState和window.onpopstate實(shí)現(xiàn)的。但是有幾點(diǎn)要注意:
-
當(dāng)活動(dòng)歷史記錄條目更改時(shí),將觸發(fā) popstate 事件; -
調(diào)用 history.pushState()或history.replaceState()不會(huì)觸發(fā) popstate 事件 -
popstate 事件只會(huì)在瀏覽器某些行為下觸發(fā),比如:點(diǎn)擊后退、前進(jìn)按鈕(或者在 JavaScript 中調(diào)用 history.back()、history.forward()、history.go()方法) -
a 標(biāo)簽的錨點(diǎn)也會(huì)觸發(fā)該事件
對(duì) pushState 和 replaceState 行為的監(jiān)聽(tīng)
如果想監(jiān)聽(tīng) pushState 和 replaceState 行為,可以通過(guò)在方法里面主動(dòng)去觸發(fā) popstate 事件,另一種是重寫history.pushState,通過(guò)創(chuàng)建自己的eventedPushState自定義事件,并手動(dòng)派發(fā),實(shí)際使用過(guò)程中就可以監(jiān)聽(tīng)了。具體做法如下:
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 的區(qū)別
route 就是一條路由,它將一個(gè) URL 路徑和一個(gè)函數(shù)進(jìn)行映射。而 router 可以理解為一個(gè)容器,或者說(shuō)一種機(jī)制,它管理了一組 route。
概括為:route 只是進(jìn)行了 URL 和函數(shù)的映射,在當(dāng)接收到一個(gè) URL 后,需要去路由映射表中查找相應(yīng)的函數(shù),這個(gè)過(guò)程是由 router 來(lái)處理的。
動(dòng)態(tài)路由和靜態(tài)路由
-
靜態(tài)路由
靜態(tài)路由只支持基于地址的全匹配。
-
動(dòng)態(tài)路由
動(dòng)態(tài)路由除了可以兼容全匹配外還支持多種”高級(jí)匹配模式“,它的路徑地址中含有路徑參數(shù),使得它可以按照給定的匹配模式將符合條件的一個(gè)或多個(gè)地址映射到同一個(gè)組件上。
動(dòng)態(tài)路由一般結(jié)合角色權(quán)限控制使用。
動(dòng)態(tài)路由的存儲(chǔ)有兩種方式:
-
將路由存儲(chǔ)到前端 -
將路由存儲(chǔ)到數(shù)據(jù)庫(kù)
動(dòng)態(tài)路由的好處:
-
靈活,無(wú)需手動(dòng)維護(hù) -
存儲(chǔ)到數(shù)據(jù)庫(kù),增加安全性
實(shí)現(xiàn)一個(gè)路由
一個(gè)簡(jiǎn)單的Router應(yīng)該具備哪些功能
-
以 Vue為例,需要有 <router-link>鏈接、<router-view>容器、component組件和path路由路徑:
<div id="app">
<h1>Hello World</h1>
<p>
<!-- 使用 router-link 組件進(jìn)行導(dǎo)航 -->
<!-- 通過(guò)傳遞 to 來(lái)指定鏈接 -->
<!-- <router-link> 將呈現(xiàn)一個(gè)帶有正確 href屬性的<a>標(biāo)簽 -->
<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>
-
綜上,一個(gè)簡(jiǎn)單的 Router 應(yīng)該具備以下功能: -
容器(組件) -
路由 -
業(yè)務(wù)組件 & 鏈接組件
不借助第三方工具庫(kù),如何實(shí)現(xiàn)路由
不借助第三方工具庫(kù)實(shí)現(xiàn)路由,我們需要思考以下幾個(gè)問(wèn)題:
-
如何實(shí)現(xiàn)自定義標(biāo)簽,如vue的 <router-view>,React的<Router> -
如何實(shí)現(xiàn)業(yè)務(wù)組件 -
如何動(dòng)態(tài)切換路由
準(zhǔn)備工作
1、根據(jù)對(duì)前端路由 history 模式的理解,將大致過(guò)程用如下流程圖表示:
-
Custom elements(自定義元素) :一組JavaScript API,允許我們定義 custom elements及其行為,然后可以在界面按照需要使用它們。 -
Shadow DOM(影子DOM) :一組JavaScript API,用于將封裝的“影子”DOM樹(shù)附加到元素(與主文檔分開(kāi)呈現(xiàn))并控制關(guān)聯(lián)的功能。通過(guò)這種方式,可以保持元素的功能私有。 -
HTML template(HTML模版) : <template>和<slot>可以編寫不在頁(yè)面顯示的標(biāo)記模板,然后它們可以作為自定義元素結(jié)構(gòu)的基礎(chǔ)被多次重用。
另外還需要注意 Web Components 的生命周期:
connectedCallback:當(dāng) custom element 首次被插入文檔DOM時(shí),被調(diào)用
disconnectedCallback:當(dāng) custom element 從文檔DOM中刪除時(shí),被調(diào)用
adoptedCallback:當(dāng)custom element 被移動(dòng)到新的文檔時(shí),被調(diào)用
attributeChangedCallback:當(dāng) custom element 增加、刪除、修改自身屬性時(shí),被調(diào)用
3、Shadow DOM
-
open:shadow root 元素可以從 js 外部訪問(wèn)根節(jié)點(diǎn) -
close :拒絕從 js 外部訪問(wèn)關(guān)閉的 shadow root 節(jié)點(diǎn) -
語(yǔ)法: const shadow = this.attachShadow({mode:closed}); -
Shadow host:一個(gè)常規(guī)DOM節(jié)點(diǎn),Shadow DOM 會(huì)被附加到這個(gè)節(jié)點(diǎn)上 -
Shadow tree:Shadow DOM 內(nèi)部的 DOM 樹(shù) -
Shadow boundary:Shadow DOM 結(jié)束的地方,也是常規(guī)DOM開(kāi)始的地方 -
Shadow root:Shadow tree 的根節(jié)點(diǎn) -
Shadow DOM 特有的術(shù)語(yǔ): -
Shadow DOM的重要參數(shù)mode: -
通過(guò)自定義標(biāo)簽創(chuàng)建容器組件、路由、業(yè)務(wù)組件和鏈接組件標(biāo)簽,使用
CustomElementRegistry.define()注冊(cè)自定義元素。其中,Custom elements 的簡(jiǎn)單寫法舉例:
<my-text></my-text>
<script>
class MyText extends HTMLElement{
constructor(){
super();
this.append(“我的文本”);
}
}
window.customElements.define("my-text",MyText);
</script>
-
組件的實(shí)現(xiàn)可以使用 Web Components,但是這樣有缺點(diǎn),我們沒(méi)有打包引擎處理 Web Components組件,將其全部加載過(guò)來(lái)。
為了解決以上問(wèn)題,我們選擇動(dòng)態(tài)加載,遠(yuǎn)程去加載一個(gè) html 文件。html文件里面的結(jié)構(gòu)如下:支持模版(template),腳本(template),腳本(script),樣式(style),非常地像vue。組件開(kāi)發(fā)模版如下:
<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>
-
監(jiān)聽(tīng)路由的變化:
popstate可以監(jiān)聽(tīng)大部分路由變化的場(chǎng)景,除了pushState 和 replaceState。
pushState 和 replaceState可以改變路由,改變歷史記錄,但是不能觸發(fā)popstate事件,需要自定義事件并手動(dòng)觸發(fā)自定義事件,做出響應(yīng)。
-
整體架構(gòu)圖如下:
8. 組件功能拆解分析如下:
-
鏈接組件 — CustomLink(c-link)
當(dāng)用戶點(diǎn)擊<c-link>標(biāo)簽后,通過(guò)event.preventDefault();阻止頁(yè)面默認(rèn)跳轉(zhuǎn)。根據(jù)當(dāng)前標(biāo)簽的to屬性獲取路由,通過(guò)history.pushState("","",to)進(jìn)行路由切換。
// <c-link to="/" class="c-link">首頁(yè)</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)
主要是收集路由信息,監(jiān)聽(tīng)路由信息的變化,然后加載對(duì)應(yīng)的組件
-
路由 — CustomRoute(c-route)
主要是提供配置信息,對(duì)外提供getData 的方法
// 優(yōu)先于c-router注冊(cè)
// <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);
-
業(yè)務(wù)組件 — CustomComponent(c-component)
實(shí)現(xiàn)組件,動(dòng)態(tài)加載遠(yuǎn)程的html,并解析
完整代碼實(shí)現(xiàn)
index.html:
<div class="product-item">測(cè)試的產(chǎn)品</div>
<div class="flex">
<ul class="menu-x">
<c-link to="/" class="c-link">首頁(yè)</c-link>
<c-link to="/about" class="c-link">關(guān)于</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");
// 觸發(fā)歷史更新
// 事件代理
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) {
// 觸發(fā)原事件
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">首頁(yè)</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);
// 優(yōu)先于c-router注冊(cè)
// <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對(duì)應(yīng)的內(nèi)容
this.#addElement(shadow, cInfos);
}
#addElement(shadow, info) {
// 添加模板內(nèi)容
if (info.template) {
shadow.appendChild(info.template.content.cloneNode(true));
}
// 添加腳本
if (info.script) {
// 防止全局污染,并獲得根節(jié)點(diǎn)
var fun = new Function(`${info.script.textContent}`);
// 綁定腳本的this為當(dāng)前的影子根節(jié)點(diǎn)
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);
// 獲取子節(jié)點(diǎn)的路由信息
this.#routes = Array.from(routeNodes).map(node => node.getData());
// 查找默認(rèn)的路由
const defaultRoute = this.#routes.find(r => r.default) || this.#routes[0];
// 渲染對(duì)應(yīng)的路由
this.#onRenderRoute(defaultRoute);
// 監(jiān)聽(tīng)路由變化
this.#listenerHistory();
}
// 渲染路由對(duì)應(yīng)的內(nèi)容
#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_"));
}
// 監(jiān)聽(tīng)路由變化
#listenerHistory() {
// 導(dǎo)航的路由切換
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);
// 動(dòng)態(tài)加載組件并解析
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校驗(yàn)
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");
// 緩存內(nèi)容
this.caches[path] = {
template,
script,
style
}
return this.caches[path];
}
往期推薦
最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...
