目前主流的前端 SPA 框架如:React/Vue 是通过 Hash 和 History 两种方式实现无刷新路由。
无刷新更新页面本质上是改变页面的DOM,而不是跳转到新页面。
一、需要解决的问题:
1、如何改变 URL 不引起页面刷新。
Hash 模式:更新 window.location。
History 模式:通过 pushState 或 replaceState 方法改变浏览器的 URL。
2、如何监控 URL 的变化。
在 Hash 模式下可以通过监听 Hashchange 事件来监控 URL 的变化。
在 History 模式只有浏览器的前进和后退会触发 popstate 事件, History API 提供的 pushState 和 replaceState 并不会触发相关事件。故需要劫持 pushState / replaceState 方法,再手动触发事件。
既然 History 这么麻烦,那为什么还要用 History 模式呢?
来先看下完整 URL 的组成:
protocol://hostname:port/pathname?search#hash
- protocol:通信协议,常用的有http、https、ftp、mailto等。
- hostname:主机域名或IP地址。
- port:端口号,可选。省略时使用协议的默认端口,如http默认端口为80。
- pathname:路径由零或多个"/"符号隔开的字符串组成,一般用来表示主机上的一个目录或文件地址。
- search:查询,可选。用于传递参数,可有多个参数,用"&“符号隔开,每个参数的名和值用”="符号隔开。
- hash:信息片断字符串,也称为锚点。用于指定网络资源中的片断。
可以看到 Hash 前面固定有一个井号 "#",即不美观,也不符合一般我们对路由认知,如:
https://www.test.com/#/home
https://www.test.com/#/about
而 History 就可以解决这个问题,它可以直接修改 pathname 部分的内容:
https://www.test.com/home
https://www.test.com/about
3、如何根据 URL 改变页面内容。
文章开头说了,无刷新更新页面本质上是改变页面的DOM,而不是跳转到新页面。 我们也知道了如何监控 URL 的变化,那最简单粗暴的方式就是直接通过 innerHTML 改变 DOM 内容。
当然主流的 SPA 框架如:React/Vue 是通过 虚拟DOM(Virtual DOM) 结合优化后的 diff 策略 实现最小 DOM 操作来更新页面。
关于 Virtual DOM 和直接 DOM 操作哪个性能更高?
二、路由的实现
这里就以 History 模式为例,用 Typescript实现,Hash 模式可以以此类推。
1、路由的需求和解决思路
-
如何生成路由
创建一个 Router 类,传入一个类似 Vue-router 的路由参数数组 routes 来配置路由:const routes = [ { path: '/', redirect: '/home', }, { path: '/home', page: home, }, { path: '/about', page: about, }, { path: '/about/me', page: aboutMe, } // ... ]; export { routes };
-
如何跳转地址
使用 History API 提供的 pushState 和 replaceState 方法:// 本质上只是改变了浏览器的 URL 显示 window.history.pushState({}, '', '/someurl'); window.history.replaceState({}, '', '/someurl');
-
如何监听 URL 变化
由于pushState 和 replaceState 并不会触发相应事件,故需劫持 pushState 和 replaceState 方法,手动触发事件: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; // 触发事件, 让 addEventListener 可以监听到 window.dispatchEvent(e); return newHistoryFunction; }; };
然后就可以监听相关事件了
window.history.pushState = this.bindHistoryEventListener('pushState'); window.addEventListener('pushState', () => { // ... }); window.history.replaceState = this.bindHistoryEventListener('replaceState'); window.addEventListener('replaceState', () => { // ... });
-
/about 和 /about/me 是两个不同的页面
转换 pathname 为数组,再判断数组长度来区分:// 浏览器 URL 的 pathname 转化为数组 // browserPath 为 window.location.pathname const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/'); // routes的 path 属性转化为数组 // route 为 routes 遍历后的单个元素 const routeQueryArray: Array<string> = route.path.substring(1).split('/'); // 对两者比长度 if (routeQueryArray.length !== browserPathQueryArray.length) { return false; }
-
/blogs/:id 可以动态匹配 /blogs/1、 /blogs/99
转换 pathname 为数组,字符串判断以冒号 ":" 开头,则为动态属性,把其加入到全局变量 $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]; } }
-
路由有的地址会 跳转 / 重新定向 到其他地址上
在路由参数中约定 redirect 属性为 跳转 / 重新定向 的目标地址,查找中再次遇到 redirect 属性则重新查找新的目标地址,直到找到最终地址:// 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); // 然后执行更新页面逻辑 ... }
2、History 路由的实现
1、路由参数 routes.ts:
// 该数组会作为参数传给路由器的实例,其中 page 参数接收一个 Page 对象,该对象包含一些页面更新的方法,可以是 innerHTML 也可以是 虚拟 DOM 更新,这里不重要,只要知道可以调用它的方法更新页面就行
// 甚至可以把 page 参数改为接收 HTML 字符串,路由器直接把这些 HTML 字符串通过 innerHTML 更新进页面
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:
// 路由参数就是 Route 的数组
interface Route {
path: string,
page?: Page,
redirect?: string,
}
// 路由器接收的参数
interface Config {
// 内容区容器 ID
container: HTMLElement,
routes: Route[],
}
class Router {
// 页面需要更新的区域
container: HTMLElement;
routes: Route[];
constructor(config: Config) {
this.routes = config.routes;
this.container = config.container;
// 先执行一次,初始化页面
this.monitor();
// 劫持 pushState
window.history.pushState = this.bindHistoryEventListener('pushState');
window.addEventListener('pushState', () => {
this.monitor();
});
window.addEventListener('popstate', () => {
this.monitor();
});
}
// 根据路由地址查找相应的参数
monitor(): void {
let index: number = this.routes.findIndex((item: Route) => {
return this.verifyPath(item, window.location.pathname);
});
// 找到结果
if (index >= 0) {
if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
// 重新定向
this.redirect(this.routes[index].redirect);
} else {
// 不需重新定向,执行更新页面的方法
this.updatePage(index);
}
} else {
// 没找到结果跳转到 /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 则继续找最终 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 加入动态属性
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];
}
}
// 这里假设 Page 有 create 方法可以更新页面内容,而不用纠结它的具体实现
this.routes[index].page.create(this.container);
}
// 对比路由地址
verifyPath(route: Route, browserPath: string): boolean {
const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/');
const routeQueryArray: Array<string> = route.path.substring(1).split('/');
// 先核对长度
if (routeQueryArray.length !== browserPathQueryArray.length) {
return false;
}
for (let i = 0; i < routeQueryArray.length; i++) {
// 判断是否以冒号开头, 如 :id
// 不是, 则将其与路由 path进行比对
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;
// 触发事件, 让 addEventListener 可以监听到
window.dispatchEvent(e);
return newHistoryFunction;
};
};
}
export { Router };
3、使用路由器
import { routes } from 'routes.js';
import { Router } from 'router.js';
new Router({
// 更新页面 div#app 中的内容
container: document.getElementById('app'),
routes: routes,
});