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