手动实现hash模式前端路由
最近一直在进行vue-router的学习,也思考了vue-router的一些实现原理,在上一篇关于前端路由和后端路由的学习帖子中我对前端后端路由进行了总结,并且对前端路由的实现方式和
hash方式实现路由的原理进行了介绍,今天我就在这个基础上通过原生代码实现了一个简单的前端路由。
1. 功能
1.1 url改变实现组件切换
介绍:通过hash模式下的改变url进行组件切换,因为是hash实现的所以url的改变不会真正的向服务器发送请求。

1.2 类似router-view标签的"组件"渲染
介绍:类似vue-router中router-view标签的功能,切换url后,"组件"会在这个标签中被渲染。当然,这里的组件也不是严格意义上的vue组件,只是自己模拟出来的而已。

1.3 实现路由注册
介绍:通过路由注册函数register注册路由绑定组件,将url和"组件"关联起来。

1.4 错误url输出404
介绍:输入的url如果是未注册的路由,统一输出404NotFound。

2. 实现方法
2.1 router类的定义
创建了一个router类,基础属性有routes,这是一个对象,用来存放所有注册过的路由,对象的键名就代表的是注册的路由路径,而键值则是一个函数changeComponent(),这个函数用来根据组件名进行组件切换,后面会针对这个函数进行介绍。

2.2 router类的方法
定义了router类的基本属性后,接着就是编码router的基本方法,当前demo中路由主要有3个基本方法,分别是init()、refresh()、register,下面将对这三个方法逐一介绍。
初始化方法init()
其实上篇文章也提到,hash路由的核心就是通过监听onhashchange事件,获取到当前hash值的改变,再根据这个值进行组件的渲染或刷新。结合当前的demo,初始化的核心部分就是给onhashchange事件绑定refresh()方法。代码如下图:

register()
就像在vur-router中一样,如果希望能够根据url进行组件切换,那么我们就要先对路由进行一个注册,所以register()函数接收一个对象,对象中包含了两个属性,path和component。当接收到这个对象后,将path添加到路由集合this.routes中作为键名,将组件切换函数changeComponent()作为键值。这样子后续刷新函数refresh()只需要调用this.routes[url]就可以完成组件的切换了。代码如下图:

refresh()
在路由注册中提到,通过路由注册,已经将路由路径以及组件切换方法绑定到了this.routes上了,那么本函数只需要在hashchange事件触发时进行调用,获取到location.hash中的hash值,根据这个值判断路由集合中是否存在这个路径,如果存在调用this.routes[url]绑定的方法进行组件切换渲染,如果不存在,则统一提示404NotFound。代码如下图:

2.3 组件切换函数
前文提到,路由注册中是将组件切换函数作为键值,然后提供给refresh()函数调用实现组件切换和渲染的。该方法接收一个String类型的参数即组件名称,然后根据这个组件名称去组件数组中查询,如果数组中存在该组件,则获取该组件的模板然后再router-view中进行渲染,如果不存在该组件,则弹窗输出“没有该组件”。代码如下图:

2.4 模拟组件
其实本文中使用到的全是原生js,并没有使用vue,所以其中的“组件”切换也是一个模拟的形式,具体的实现方式是新建了一个template.js的js文件,其中存放的是各个组件的模板字符串,也可以说在本文中的“组件”只有template而没有script,然后再创建了一个对象数组,里面存放了各个组件的名字和他们的模板字符串,最后组件切换函数就可以在这个模拟出来的"组件"数组中查找“组件”,并获取到这个"组件"的模板字符串并进行渲染了。代码如下图:


3. 实现细节
上一节讲述了前端路由的大致实现思路,那么接下来讲一讲在实现前端路由的过程中碰到的一些问题以及对这些问题的解析和思考。
3.1 init()中的this指向问题
首先是在初始化中的一段代码,前文提过,init()主要做的工作就是进行事件监听,那么针对onhashchange事件,为其绑定的回调函数其实就是上文提到的刷新函数refresh(),很简单,就是当url的hash值改变时进行刷新,代码如下:
//完整版
//完成事件监听,在hash值改变时和load事件完成时进行refresh刷新
router.prototype.init = function () {
addEventListener('load', this.refresh.bind(this));
addEventListener('hashchange', this.refresh.bind(this));
}
//未绑定this版本
router.prototype.init = function () {
addEventListener('load', this.refresh);
addEventListener('hashchange', this.refresh);
}
那么问题来了,在第一次实现的过程中,我直接为hashchange事件绑定了this.refresh方法,但是经过试验发现无论我如何改变hash值都不会完成组件切换,经过各种打log调试,最后发现,当hash值改变后触发回调函数refresh时,此时刷新函数中的this指向的是window。
其实这里有心的朋友肯定知道了,这是关于函数调用时this指向的一个问题,当事件触发后调用了refresh这个方法的是浏览器,因为是浏览器对这个事件进行的监听,也是当事件触发后浏览器调用的刷新函数,此时相当于是window.refresh,所以this指向的是浏览器,而我们希望的是this指向我们的路由实例,因为我们是在路由实例中注册的路由信息,路由实例中才存放了所有路由信息的集合routes。
相信写到这里各位已经知道了,在js中改变this指向的方法无外乎call、apply、bind,那么结合实际情况,我们在这里需要的是为事件绑定一个函数,那么我们需要的是在绑定this后返回一个函数,所以这里会使用bind进行this绑定。
3.2 路由注册时回调方法的绑定问题
通过前文可知在路由注册时我们需要为当前注册的路由绑定一个组件切换的方法,具体的绑定代码如下:
/**
* 路由注册函数:进行路由注册
* @param {String} obj.path 注册的路径
* @param {function} obj.component 组件名称,根据组件名称将组件切换的方法绑定给routes[obj.path]
*/
//完整版
router.prototype.register = function (obj) {
this.routes[obj.path] = function(){changeComponent(obj.component)};
}
//初版
router.prototype.register = function (obj) {
this.routes[obj.path] = changeComponent(obj.component);
}
相信基础扎实的同学已经看出来了如果我按照初版的写法,会出现什么错误,没错就是组件切换的方法会立即执行,而实际却并没有将这个方法绑定给我们的路由集合。这与我们的初衷是相违背的,不过解决方法也很简单,只需要在外层给他包裹上一个function即可。
4. 总结
其实大部分同学如果完整的看完都会发现这个前端路由的demo真的挺简单的,那么将我所做的工作概括一下就是如下几点:
- 创建
router类,实现了初始化、路由注册、刷新三个方法。 - 通过
hashchange事件的监听在url的hash值改变时进行刷新 - 通过一个简单的模拟出来的"组件"数组实现了一个简单的按照组件名字进行组件切换渲染的方法。
当然了,这个其实只是最最基本的前端路由,离vue-router的嵌套路由、命名路由、导航守卫等高级用法还想去甚远,下一步的话希望能了解history模式下的前端路由原理,同样进行一个基本的实现,加深自己的理解吧。
5. 代码
hash-router.html代码如下:
<!-- hash-router.html -->
<!-- author:刘伟C -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<script src="../js/template.js"></script>
<script src="../js/hash-router.js"></script>
<title>hash路由模拟实现</title>
</head>
<body>
<div id="content">
<!-- 点击a标签进行hash路由跳转 -->
<a href="#/shopping">购物车</a>
<a href="#/food">菜品信息</a>
<a href="#/review">评论信息</a>
<!-- 切换的组件在这里渲染 -->
<div id="router-view"></div>
</div>
<script type="text/javascript">
/**
* 模拟组件数组
* @params {string} name 组件名字
* @params {template} component 组件模板
*/
let components = [{
name: 'shopping',
component: templateShopping
},
{
name: 'review',
component: templateReview
},
{
name: 'food',
component: templateFood
},
];
//新建一个路由集合
let routers = new router();
//为这个路由集合注册路由
routers.register({
path: '/shopping',
component: 'shopping'
});
routers.register({
path: '/food',
component: 'food'
});
routers.register({
path: '/review',
component: 'review'
});
</script>
</body>
</html>
hash-router.js代码如下:
/**
* 创建router类
* routes:路由集合对象,键名是路径,键值是回调函数
* currentUrl:当前url
*/
function router() {
this.routes = {};
this.currentUrl = "";
this.init();
}
//完成事件监听,在hash值改变时和load事件完成时进行refresh刷新
router.prototype.init = function () {
addEventListener('load', this.refresh.bind(this));
addEventListener('hashchange', this.refresh.bind(this));
}
//refresh刷新,根据当前url获取this.rotes[currentUrl]的回调函数进行组件展示
router.prototype.refresh = function () {
this.currentUrl = location.hash.slice(1);
//load时,router-view不需要渲染任何组件
if(location.hash==""){
document.getElementById("router-view").innerHTML = "";
return;
}
if (this.routes[this.currentUrl]) {
this.routes[this.currentUrl]();
} else {
document.getElementById("content").innerHTML = "404 Not Found";
}
}
/**
* 路由注册函数:进行路由注册
* @param {String} obj.path 注册的路径
* @param {function} obj.component 组件名称,根据组件名称将组件切换的方法绑定给routes[obj.path]
*/
router.prototype.register = function (obj) {
this.routes[obj.path] = function(){changeComponent(obj.component)};
}
/**
* 组件切换函数:根据组件名称进行切换
* @param {String} name 组件名称
*/
function changeComponent(name) {
for (let i = 0; i < components.length; i++) {
if (components[i].name == name) {
document.getElementById("router-view").innerHTML = components[i].component;
break;
}
if (i == (components.length - 1)) {
alert("没有该组件");
}
}
}
template.js代码如下:
//template.js
let templateShopping=`
<li>鱼香肉丝X1</li>
<li>青椒肉丝X2</li>
<li>番茄炒蛋X3</li>`;
let templateReview=`
<p>青椒肉丝</p>
<li>好吃</li>
<li>好吃不贵</li>
<p>鱼香肉丝</p>
<li>太甜了</li>
<li>一般</li>
`;
let templateFood=`
<p>菜品名:番茄炒蛋</p>
<p>单价:20元</p>
<p>菜品名:鱼香肉丝</p>
<p>单价:30元</p>
`;