目前主流的前端 SPA 框架如:React/Vue 是通過 Hash 和 History 兩種方式實(shí)現(xiàn)無刷新路由。
無刷新更新頁面本質(zhì)上是改變頁面的DOM,而不是跳轉(zhuǎn)到新頁面。
一、需要解決的問題:
1革娄、如何改變 URL 不引起頁面刷新倾贰。
Hash 模式:更新 window.location。
History 模式:通過 pushState 或 replaceState 方法改變?yōu)g覽器的 URL拦惋。
2、如何監(jiān)控 URL 的變化安寺。
在 Hash 模式下可以通過監(jiān)聽 Hashchange 事件來監(jiān)控 URL 的變化厕妖。
在 History 模式只有瀏覽器的前進(jìn)和后退會(huì)觸發(fā) popstate 事件, History API 提供的 pushState 和 replaceState 并不會(huì)觸發(fā)相關(guān)事件挑庶。故需要劫持 pushState / replaceState 方法言秸,再手動(dòng)觸發(fā)事件。
既然 History 這么麻煩迎捺,那為什么還要用 History 模式呢举畸?
來先看下完整 URL 的組成:
protocol://hostname:port/pathname?search#hash
- protocol:通信協(xié)議,常用的有http凳枝、https抄沮、ftp、mailto等岖瑰。
- hostname:主機(jī)域名或IP地址叛买。
- port:端口號(hào),可選蹋订。省略時(shí)使用協(xié)議的默認(rèn)端口率挣,如http默認(rèn)端口為80。
- pathname:路徑由零或多個(gè)"/"符號(hào)隔開的字符串組成露戒,一般用來表示主機(jī)上的一個(gè)目錄或文件地址椒功。
- search:查詢,可選智什。用于傳遞參數(shù)动漾,可有多個(gè)參數(shù),用"&“符號(hào)隔開撩鹿,每個(gè)參數(shù)的名和值用”="符號(hào)隔開谦炬。
- hash:信息片斷字符串,也稱為錨點(diǎn)节沦。用于指定網(wǎng)絡(luò)資源中的片斷键思。
可以看到 Hash 前面固定有一個(gè)井號(hào) "#",即不美觀甫贯,也不符合一般我們對(duì)路由認(rèn)知吼鳞,如:
https://www.test.com/#/home
https://www.test.com/#/about
而 History 就可以解決這個(gè)問題,它可以直接修改 pathname 部分的內(nèi)容:
https://www.test.com/home
https://www.test.com/about
3叫搁、如何根據(jù) URL 改變頁面內(nèi)容赔桌。
文章開頭說了供炎,無刷新更新頁面本質(zhì)上是改變頁面的DOM,而不是跳轉(zhuǎn)到新頁面疾党。 我們也知道了如何監(jiān)控 URL 的變化音诫,那最簡單粗暴的方式就是直接通過 innerHTML 改變 DOM 內(nèi)容。
當(dāng)然主流的 SPA 框架如:React/Vue 是通過 虛擬DOM(Virtual DOM) 結(jié)合優(yōu)化后的 diff 策略 實(shí)現(xiàn)最小 DOM 操作來更新頁面雪位。
關(guān)于 Virtual DOM 和直接 DOM 操作哪個(gè)性能更高竭钝?
二、路由的實(shí)現(xiàn)
這里就以 History 模式為例雹洗,用 Typescript實(shí)現(xiàn)香罐,Hash 模式可以以此類推。
1时肿、路由的需求和解決思路
-
如何生成路由
創(chuàng)建一個(gè) Router 類庇茫,傳入一個(gè)類似 Vue-router 的路由參數(shù)數(shù)組 routes 來配置路由:const routes = [ { path: '/', redirect: '/home', }, { path: '/home', page: home, }, { path: '/about', page: about, }, { path: '/about/me', page: aboutMe, } // ... ]; export { routes };
-
如何跳轉(zhuǎn)地址
使用 History API 提供的 pushState 和 replaceState 方法:// 本質(zhì)上只是改變了瀏覽器的 URL 顯示 window.history.pushState({}, '', '/someurl'); window.history.replaceState({}, '', '/someurl');
-
如何監(jiān)聽 URL 變化
由于pushState 和 replaceState 并不會(huì)觸發(fā)相應(yīng)事件,故需劫持 pushState 和 replaceState 方法螃成,手動(dòng)觸發(fā)事件:bindHistoryEventListener(type: string): any { const historyFunction: Function = (<any>history)[type]; return function() { const newHistoryFunction = historyFunction.apply(history, arguments); const e = new Event(type); (<any>e).arguments = arguments; // 觸發(fā)事件, 讓 addEventListener 可以監(jiān)聽到 window.dispatchEvent(e); return newHistoryFunction; }; };
然后就可以監(jiān)聽相關(guān)事件了
window.history.pushState = this.bindHistoryEventListener('pushState'); window.addEventListener('pushState', () => { // ... }); window.history.replaceState = this.bindHistoryEventListener('replaceState'); window.addEventListener('replaceState', () => { // ... });
-
/about 和 /about/me 是兩個(gè)不同的頁面
轉(zhuǎn)換 pathname 為數(shù)組旦签,再判斷數(shù)組長度來區(qū)分:// 瀏覽器 URL 的 pathname 轉(zhuǎn)化為數(shù)組 // browserPath 為 window.location.pathname const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/'); // routes的 path 屬性轉(zhuǎn)化為數(shù)組 // route 為 routes 遍歷后的單個(gè)元素 const routeQueryArray: Array<string> = route.path.substring(1).split('/'); // 對(duì)兩者比長度 if (routeQueryArray.length !== browserPathQueryArray.length) { return false; }
-
/blogs/:id 可以動(dòng)態(tài)匹配 /blogs/1、 /blogs/99
轉(zhuǎn)換 pathname 為數(shù)組锈颗,字符串判斷以冒號(hào) ":" 開頭顷霹,則為動(dòng)態(tài)屬性,把其加入到全局變量 $route 中:for (let i = 0; i < routeQueryArray.length; i++) { if (routeQueryArray[i].indexOf(':') === 0) { // :id 可以用 $router.id 訪問 (<any>window).$route[routeQueryArray[i].substring(1)] = pathQueryArray[i]; } }
-
路由有的地址會(huì) 跳轉(zhuǎn) / 重新定向 到其他地址上
在路由參數(shù)中約定 redirect 屬性為 跳轉(zhuǎn) / 重新定向 的目標(biāo)地址击吱,查找中再次遇到 redirect 屬性則重新查找新的目標(biāo)地址淋淀,直到找到最終地址:// Router 類 的 redirect 方法 if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') { this.redirect(this.routes[index].redirect); } else { // 更新 URL 為最終的地址 window.history.pushState({}, '', window.location.origin + this.routes[index].path); // 然后執(zhí)行更新頁面邏輯 ... }
2、History 路由的實(shí)現(xiàn)
1覆醇、路由參數(shù) routes.ts:
// 該數(shù)組會(huì)作為參數(shù)傳給路由器的實(shí)例朵纷,其中 page 參數(shù)接收一個(gè) Page 對(duì)象,該對(duì)象包含一些頁面更新的方法永脓,可以是 innerHTML 也可以是 虛擬 DOM 更新袍辞,這里不重要,只要知道可以調(diào)用它的方法更新頁面就行
// 甚至可以把 page 參數(shù)改為接收 HTML 字符串常摧,路由器直接把這些 HTML 字符串通過 innerHTML 更新進(jìn)頁面
const routes = [
{
// 地址
path: '/',
// redirect 為要重新定向的地址
redirect: '/home',
},
{
path: '/home',
page: homePage,
},
{
path: '/about',
page: aboutPage,
},
{
path: '/about/me',
page: aboutMePage,
},
{
path: '/blogs/:id',
page: blogsPage,
},
{
path: '/404',
page: pageNotFound,
},
];
export { routes };
2搅吁、路由 router.ts:
// 路由參數(shù)就是 Route 的數(shù)組
interface Route {
path: string,
page?: Page,
redirect?: string,
}
// 路由器接收的參數(shù)
interface Config {
// 內(nèi)容區(qū)容器 ID
container: HTMLElement,
routes: Route[],
}
class Router {
// 頁面需要更新的區(qū)域
container: HTMLElement;
routes: Route[];
constructor(config: Config) {
this.routes = config.routes;
this.container = config.container;
// 先執(zhí)行一次,初始化頁面
this.monitor();
// 劫持 pushState
window.history.pushState = this.bindHistoryEventListener('pushState');
window.addEventListener('pushState', () => {
this.monitor();
});
window.addEventListener('popstate', () => {
this.monitor();
});
}
// 根據(jù)路由地址查找相應(yīng)的參數(shù)
monitor(): void {
let index: number = this.routes.findIndex((item: Route) => {
return this.verifyPath(item, window.location.pathname);
});
// 找到結(jié)果
if (index >= 0) {
if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
// 重新定向
this.redirect(this.routes[index].redirect);
} else {
// 不需重新定向落午,執(zhí)行更新頁面的方法
this.updatePage(index);
}
} else {
// 沒找到結(jié)果跳轉(zhuǎn)到 /404 地址
window.history.pushState({}, '', '/404');
console.log('404!');
}
}
// 重新定向
redirect(redirectPath: string): void {
let index: number = this.routes.findIndex((item: Route) => {
return redirectPath === item.path;
});
// 定向到的地址還是 redirect 則繼續(xù)找最終 path
if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
this.redirect(this.routes[index].redirect);
} else {
// 更新 URL 為最終的地址
window.history.pushState({}, '', window.location.origin + this.routes[index].path);
this.updatePage(index);
}
}
// 更新頁面
updatePage(index: number): void {
// 向全局變量 $route 加入動(dòng)態(tài)屬性
const pathQueryArray: Array<string> = window.location.pathname.substring(1).split('/');
const routeQueryArray: Array<string> = this.routes[index].path.substring(1).split('/');
for (let i = 0; i < routeQueryArray.length; i++) {
if (routeQueryArray[i].indexOf(':') === 0) {
(<any>window).$route[routeQueryArray[i].substring(1)] = pathQueryArray[i];
}
}
// 這里假設(shè) Page 有 create 方法可以更新頁面內(nèi)容谎懦,而不用糾結(jié)它的具體實(shí)現(xiàn)
this.routes[index].page.create(this.container);
}
// 對(duì)比路由地址
verifyPath(route: Route, browserPath: string): boolean {
const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/');
const routeQueryArray: Array<string> = route.path.substring(1).split('/');
// 先核對(duì)長度
if (routeQueryArray.length !== browserPathQueryArray.length) {
return false;
}
for (let i = 0; i < routeQueryArray.length; i++) {
// 判斷是否以冒號(hào)開頭, 如 :id
// 不是, 則將其與路由 path進(jìn)行比對(duì)
if (routeQueryArray[i].indexOf(':') !== 0) {
if (routeQueryArray[i] !== browserPathQueryArray[i]) {
return false;
}
}
}
return true;
}
// 劫持 pushState / popState
bindHistoryEventListener(type: string): any {
const historyFunction: Function = (<any>history)[type];
return function() {
const newHistoryFunction = historyFunction.apply(history, arguments);
const e = new Event(type);
(<any>e).arguments = arguments;
// 觸發(fā)事件, 讓 addEventListener 可以監(jiān)聽到
window.dispatchEvent(e);
return newHistoryFunction;
};
};
}
export { Router };
3、使用路由器
import { routes } from 'routes.js';
import { Router } from 'router.js';
new Router({
// 更新頁面 div#app 中的內(nèi)容
container: document.getElementById('app'),
routes: routes,
});