实现一个简单的router
前言
简单来说router的作用就是根据路径的不同展示不同的组件。router有两种常见的模式:hash模式和history模式。接下来就开始简单的实现以下吧。
hash模式
- 实现hash模式的主要就是通过监听
onhashchange
这个事件,然后通过location.hash
来获取当前的路径,并根据当前的路径进行匹配要展示的组件。
编写html文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<button id="btn1">/user</button>
<button id="btn2">/profile</button>
<div id="view"></div>
</div>
<script src="./hash.js"></script>
</body>
</html>
- 在
html
文件中,有两个按钮btn1
和btn2
来进行路径跳转。然后还有一个id为view
的div。这个view
就相当于一个存放匹配到的组件的容器。同时html
文件引入一个hash.js
文件。
编写hash.js文件
// 获取view,btn1和btn2
const view = document.querySelector("#view");
const btn1 = document.querySelector("#btn1");
const btn2 = document.querySelector("#btn2");
function push(path) {
location.hash = path;
}
btn1.addEventListener("click", function (e) {
push("/user/123");
});
btn2.addEventListener("click", function (e) {
push("/profile");
});
// 后续进行实现
function processRoutes() {}
// 监听手动刷新页面
window.addEventListener("DOMContentLoaded", processRoutes);
// 监听hash变化
window.addEventListener("hashchange", processRoutes);
- 目前为止,在
hash.js
文件中,主要就是对按钮进行事件绑定,以及实现push
方法,而push
方法就是通过改变location.hash
实现改变路径。同时我们监听了DOMContentLoaded
和hashchange
事件。在这两个事件中,我们主要就是进行路由匹配的操作。 - 在进行路由匹配之前,先创建一些路由对象。
创建路由对象
const Home = () => {
view.innerHTML = '<h1>Home</h1>'
}
const User = () => {
view.innerHTML = '<h1>User</h1>'
}
const Profile = () => {
view.innerHTML = '<h1>Profile</h1>'
}
const routes = [
{
path: '/',
component: Home
},
{
path: '/user/:id',
component: User
},
{
path: '/profile',
component: Profile
}
]
- 有了路由对象,我们就可以通过匹配
path
和location.hash
来决定要展示的组件。
实现processRoutes函数
// 全局变量,用来保存当前的route对象
let currentRoute = null
function processRoutes() {
// 获取当前路径
// 当前的location.hash有可能为空字符串,当为空字符串时设置为/
// 因为location.hash获取到的值是会带有#,所以这里通过slice(1)去除掉#
const curPath = !location.hash ? "/" : location.hash.slice(1);
// 构建当前路由对象
const curRoute = {
path: curPath,
params: {},
};
// 进行路由匹配
const matchRoute = routes.find((route) => {
// 将路径以/进行切割为数组
// /user/:id -> ['', 'user', ':id']
const curPathArr = curPath.split("/");
const routePathArr = route.path.split("/");
// 如果两个路径数组长度不一致,则不匹配
if (curPathArr.length !== routePathArr.length) return false;
for (let i = 0; i < routePathArr.length; i++) {
// 如果是以:开头的,则认为是params参数
if (routePathArr[i].startsWith(":")) {
// routePathArr[i].slice(1) :id -> id
// curPath: /user/123 routePath: /user/:id
// 将当前的id作为key,curPathArr[i]作为value
// params: { id: 123 }
curRoute.params[routePathArr[i].slice(1)] = curPathArr[i];
} else if (routePathArr[i] !== curPathArr[i]) {
// 如果当前两项不匹配则直接返回false
return false;
}
}
// 如果前面没有return的话,则证明当前的路由是匹配的。
return true;
});
if (matchRoute) {
matchRoute.component();
currentRoute = curRoute;
}
}
实现效果
history模式
- 在实现history模式之前,因为history模式下,刷新浏览器会出现
not found
的情况。所以我们需要先简单的搭建一个服务,让页面刷新时还是返回html文件。 - 同时为了方便,还想要实现,当我们改变代码时,浏览器能够自动刷新,这样就不需要我们进行手动刷新了。
使用koa搭建一个服务
- 创建服务之前,首先需要安装
koa ws chokidar
pnpm i koa ws chokidar
创建index.html文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<button id="btn1">/user</button>
<button id="btn2">/profile</button>
<div id="view"></div>
</div>
<script>
// 监听onload事件
window.onload = function () {
// 创建一个webSocket对象
const ws = new WebSocket("ws://localhost:8080");
ws.onerror = function () {
console.log("error");
};
// 监听message事件
ws.onmessage = function (msg) {
if (msg.data === "reload") {
// 当接收到mes.data === 'reload'时,直接刷新浏览器即可
window.location.reload();
}
};
};
</script>
<script src="./history.js"></script>
</body>
</html>
创建server.js文件
const fs = require("fs");
const http = require("http");
const chokidar = require("chokidar");
const WebSocket = require("ws");
const Koa = require("koa");
const app = new Koa();
const server = http.createServer(app.callback());
const wss = new WebSocket.Server({ server });
// 全局变量用于保存ws
let gws;
wss.on("connection", function (ws) {
// 将ws赋值给gws
gws = ws;
});
// 监听文件的变化
chokidar
.watch(".", {
// 忽略node_modules文件的变化
ignored: "node_modules",
})
.on("change", function () {
// 当文件发生变化时,向客户端发送'reload'
gws?.send("reload");
});
app.use((ctx, next) => {
const { url } = ctx;
let response;
if (url.endsWith(".js")) {
// 如果请求路径是以.js结尾的,直接返回history.js文件
response = fs.readFileSync("./history.js", "utf-8");
ctx.type = "application/javascript";
ctx.body = response;
} else {
// 其余情况都返回index.html文件
response = fs.readFileSync("./index.html", "utf-8");
ctx.type = "text/html";
ctx.body = response;
}
});
server.listen(8080, () => {
console.log("服务器启动成功");
});
- 可以看到,当我们修改文件时,浏览器会自动进行刷新。
实现history.js文件
- 实现history模式,主要就是通过html5提供的
pushState
,replaceState
API,让我们可以添加路径的同时不刷新浏览器。同时通过history.go history.back history.forward
可以进行上一个页面或者下一个页面的跳转,而且使用这几个api时,会触发onpopstate
事件。但是注意通过pushState或者replacestate是不会触发onpopstate
事件的。 - 这里采用了一种比较投机取巧的方式。可能会有问题,但是目前还没发现。(如果发现的话,也可以告诉一下我)。
function push(path) {
const state = {
path,
params: {},
};
history.pushState(state, "", path);
history.pushState(state, "", path);
history.go(-1);
}
- 在push方法中,调用了两次
pushState
方法,然后调用history.go(-1)
。因为调用了go
方法,所以会触发onpopstate
回调。所以就可以和实现hash
模式一样,在回调中进行路由的匹配处理。这里就不在详细解释了。代码如下。
const view = document.querySelector("#view");
let currentRoute = null;
const Home = () => {
view.innerHTML = "<h1>Home</h1>";
};
const User = () => {
view.innerHTML = "<h1>User</h1>";
};
const Profile = () => {
view.innerHTML = "<h1>Profile</h1>";
};
const btn1 = document.querySelector("#btn1");
const btn2 = document.querySelector("#btn2");
btn1.addEventListener("click", (e) => {
push("/user/123/abc/456");
});
btn2.addEventListener("click", (e) => {
push("/profile");
});
const routes = [
{
path: "/",
component: Home,
},
{
path: "/user/:id/abc/:name",
component: User,
},
{
path: "/profile",
component: Profile,
},
];
function push(path) {
const state = {
path,
params: {},
};
history.pushState(state, "", path);
history.pushState(state, "", path);
history.go(-1);
}
function processRoutes(e) {
const curRoute = !history.state ? { path: "/", params: {} } : history.state;
const curPath = curRoute.path.slice(1).split("/");
const matchRoute = routes.find((route) => {
// /users/:username/posts/:postId
// 先以/切割
// 判断是否以:开头,如果是则该值为params参数
const routeArr = route.path.slice(1).split("/");
if (routeArr.length !== curPath.length) return false;
for (let i = 0; i < routeArr.length; i++) {
if (routeArr[i].startsWith(":")) {
if (!route.params) {
route.params = {
[routeArr[i].slice(1)]: curPath[i],
};
} else {
route.params[routeArr[i].slice(1)] = curPath[i];
}
} else if (routeArr[i] !== curPath[i]) {
return false;
}
}
return true;
});
if (matchRoute) {
currentRoute = curRoute;
matchRoute.component();
} else {
currentRoute = null;
}
}
window.addEventListener("DOMContentLoaded", processRoutes);
window.addEventListener("popstate", processRoutes);