前端路由原理与实现

Hash 模式

Hash 模式路由本质是利用了 HTML 的锚点不跳转的特性,使用 hashchange 事件监听锚点的变换实现路由的切换行为。

公共的部分:

先来写一个 HTML 模板:

1
2
3
4
5
6
7
8
9
10
<nav>
<ul id="nav">
<li onclick="route.push('/')">index</li>
<li onclick="route.push('/page1')">page1</li>
<li onclick="route.push('/page2')">page2</li>
</ul>
</nav>
<article>
<div id="view"></div>
</article>

然后写一下 config 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const view = document.querySelector("#view");
const config = {
mode: "history",
routes: [
{
path: "/",
template: "<div>index<div>",
},
{
path: "/page1",
template: "<div>page1<div>",
},
{
path: "/page2",
template: "<div>page2<div>",
},
],
view,
};

写一下构造函数:

1
2
3
4
5
6
7
8
9
10
11
function Router(config) {
if (!(this instanceof Router)) {
return new Router(config);
}
this.mode = config.mode;
this.routes = {};
this.current = "";
this.view = config.view;
this.initRoutes();
this.listenAddressBar();
}

对于 initRoutes 我们需要绑定路由地址和相应的模板处理的映射,这里只是把简单的指定的模板挂载到进指定的挂载点:

1
2
3
4
5
6
7
Router.prototype.initRoutes = function () {
for (const _i of config.routes) {
this.routes[_i.path] = () => {
this.view.innerHTML = _i.template;
};
}
};

listenAddressBar 需要监听 load 事件和 hashchange 事件,用来触发 hash 路由的处理:

1
2
3
4
Router.prototype.listenAddressBar = function () {
window.addEventListener("load", this.handleRoute.bind(this));
window.addEventListener("hashchange", this.handleRoute.bind(this));
};

定义 handleRoute 来处理事件的回调:

1
2
3
4
5
6
7
8
Router.prototype.handleRoute = function () {
this.current = window.location.hash.slice(1) || "/";
try {
this.routes[this.current]();
} catch (err) {
throw TypeError(`unknown mode: ${this.mode}`);
}
};

提供一个 push 方法用来做跳转:

1
2
3
Router.prototype.push = function (to) {
location.href = "#" + to;
};

hash 模式完。

History 模式

history 模式有一个问题需要解决一下:

正常使用 Restful 路由做请求会发生跳转,也就是我们需要阻止请求到服务端。vue-router 关于 history 模式的文档中提到了 history.pushState,MDN 一搜,这个函数可以改变地址栏的信息同时,不发生跳转,可以完美解决这个问题。

可是这里又引入一个新问题,如果当前路由使用 F5 刷新一定会向后端发送一次请求,而后端一定不存在这个 Controller,这种情况该怎么办?

其实作为 SPA 应用,只需要要求每次服务端请求,不理会路由信息,返回同样的 HTML 页面就可以了,vue-router 也提示了,这种方式需要自己实现 404 页面,因为页面如果不存在,服务端依然会返回同一单页 HTML。

我们用 node 模拟一下 Web 服务器处理:

1
2
3
4
5
6
7
8
9
10
11
12
const { readFileSync } = require("fs");
var http = require("http");

http
.createServer(async (request, response) => {
const page = readFileSync("./index.html");
response.writeHead(200, { "Content-Type": "text/html" });
response.end(page);
})
.listen(8081);

console.log("Server running at http://127.0.0.1:8081/");

每次请求都返回 index.html,防止出现后端的 404 错误。

然后我们实现一下 Restful 请求。

对于 history 模式,只需要对 handleRoute,listenAddressBar 与 push 方法做一下扩展即可:

push:

1
2
3
4
5
6
7
8
9
10
11
12
13
Router.prototype.push = function (to) {
switch (this.mode) {
case "hash":
location.href = "#" + to;
break;
case "history":
history.pushState(null, "", this.current);
this.handleRoute(); /* 路由的变换需要手动触发处理函数 */
break;
default:
throw TypeError(`unknown mode: ${this.mode}`);
}
};

handleRoute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Router.prototype.handleRoute = function () {
switch (this.mode) {
case "hash":
this.current = window.location.hash.slice(1) || "/";
break;
case "history":
this.current = window.location.pathname;
history.pushState(null, "", this.current);
break;
default:
return;
}
try {
this.routes[this.current]();
} catch (err) {
throw TypeError(`unknown mode: ${this.mode}`);
}
};

listenAddressBar:

1
2
3
4
5
6
7
8
9
10
11
Router.prototype.listenAddressBar = function () {
window.addEventListener("load", this.handleRoute.bind(this));
switch (this.mode) {
case "hash":
window.addEventListener("hashchange", this.handleRoute.bind(this));
case "history":
window.addEventListener("popstate", this.handleRoute.bind(this));
default:
throw new Error(`unknown mode: ${this.mode}`);
}
};

这么写有大量重复代码,且耦合性过高,难扩展。可以优化一下,做个继承然后用工厂模式按需创建对象即可,本文重点在于解释前端路由原理,这么写可以直观的对比两种路由的区别,此处也就不做优化了。

下面贴出完整 JS 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
const view = document.querySelector("#view");
const config = {
mode: "hash",
routes: [
{
path: "/",
template: "<div>index<div>",
},
{
path: "/page1",
template: "<div>page1<div>",
},
{
path: "/page2",
template: "<div>page2<div>",
},
],
view,
};

function Router(config) {
if (!(this instanceof Router)) {
return new Router(config);
}
this.mode = config.mode;
this.routes = {};
this.current = "";
this.view = config.view;
this.initRoutes();
this.listenAddressBar();
}

Router.prototype.initRoutes = function () {
for (const _i of config.routes) {
this.routes[_i.path] = () => {
this.view.innerHTML = _i.template;
};
}
};

Router.prototype.listenAddressBar = function () {
window.addEventListener("load", this.handleRoute.bind(this));
switch (this.mode) {
case "hash":
window.addEventListener("hashchange", this.handleRoute.bind(this));
case "history":
window.addEventListener("popstate", this.handleRoute.bind(this));
default:
throw new Error(`unknown mode: ${this.mode}`);
}
};

Router.prototype.handleRoute = function () {
switch (this.mode) {
case "hash":
this.current = window.location.hash.slice(1) || "/";
break;
case "history":
this.current = window.location.pathname;
history.pushState(null, "", this.current);
break;
default:
return;
}
try {
this.routes[this.current]();
} catch (err) {
throw new Error(`unknown mode: ${this.mode}`);
}
};

Router.prototype.push = function (to) {
switch (this.mode) {
case "hash":
location.href = "#" + to;
break;
case "history":
history.pushState(null, "", this.current);
this.handleRoute(); /*路由的变换需要手动触发处理函数*/
break;
default:
throw Error(`unknown mode: ${this.mode}`);
}
};

const route = new Router(config);

https://gitee.com/kilicmu/analog_front_end_route可以获取文章代码

以上~

查看评论