1.Web路由
1.1 后端路由
Web路由的概念简单来说就是根据不同URL渲染不同的页面。在前后端不分离的时代,路由往往指的是后端路由(服务端路由),即当服务端接收到客户端发来的 HTTP 请求,就会根据所请求的相应 URL,进行文件读取,数据库读取等操作,使用模板引擎将相应结果与模板结合后进行渲染,将渲染完毕的页面发送给客户端。
优缺点
- 优点:seo友好,爬虫爬取到的页面就是最终的渲染页面。
- 缺点:每次发起请求都要刷新页面,用户体验不好,服务器压力大。
1.2 前端路由
说到前端路由,必须先提一下Ajax与SPA。Ajax技术的兴起促使了 SPA—单页面应用的出现,由于Ajax可以做到页面的局部更新,因此单页应用页面的交互和页面的跳转都是无刷新的,无刷新就意味着无需处理html文件的请求,因此用户体验很好。但相应的,由于页面数据需要通过Ajax获取,因此爬虫获取到的html只是模板而不是最终的渲染页面,因此会不利于seo。为了实现单页应用,所以就有了前端路由。
前端路由的概念简单来讲就是,当路由发生变化,不请求服务端,而是通过js的方式修改dom(组件替换),并发送Ajax获取数据来达到页面跳转的效果。因此实现前端路由有两个关键点:
- 如何改变url不让浏览器向服务器发送请求。
- 如何监听到url的变化,并执行对应的操作
这里就要引出实现前端路由的两种路由模式:hash模式和history模式
2.前端路由的实现模式
2.1 hash模式
概念
hash 就是指 url 后的 # 号以及后面的内容
特点
hash模式有以下几个特点
- hash值的变化不会导致浏览器向服务器发送请求,不会引起页面刷新。
- hash值变化会触发hashchange事件。
-
hash值改变会在浏览器的历史中留下记录,使用浏览器的
后退
按钮,就可以回到上一个hash值。 - hash永远不会提交到服务端,即使刷新页面也不会。
由此可见hash模式的特点完全可以满足前端路由的实现需求,所以在 H5 的 history 模式出现之前,基本都是使用 hash 模式来实现前端路由。
优缺点
优点:
- 1、兼容性好,支持低版本和IE浏览器。
- 2、实现前端路由无需服务端的支持。
缺点:
- URL带#,路径丑
2.2 history模式
概念
在 HTML5 之前,浏览器就已经有了 history 对象来控制页面历史记录跳转,主要有以下方法。
history.forward():前进
history.back():后退
history.go(n):加载历史列表中的某个具体的页面
在 HTML5 的规范中,history 新增了以下几个 API:pushState(追加) 和 replaceState(替换),通过这两个 API 可以改变 url 地址且不会发送请求,同时还新增popstate 事件。通过这些API就能用另一种方式来实现前端路由,其实现原理跟与hash模式 实现类似,只是用了 HTML5 的实现,单页面应用的 url 不会多出一个#,会更加美观。
关于History模式有两点需要说明:
-
history模式如何监听路由变化
history模式下,浏览器的前进后退(history.back(), history.forward()等)会触发popstate 事件,但pushState,replaceState 并不会触发popstate事件。因此要实现路由变化的侦听,我们需要重写这两个方法,可以通过事件中心(EventBus)添加事件通知,这里不具体展开,感兴趣的小伙伴可以参考这里。 -
history模式需要后端支持
由于history模式没有 # 号,所以当用户手动刷新或直接通过url进入应用时,浏览器还是会给服务器发送请求。但服务端无法识别这个 url ,因此为了避免出现这种情况,history模式需要服务端的支持,即服务端需要把匹配不到的所有路由都重定向到根页面。
优缺点
优点:
- 路径好看
缺点:
- 1、兼容性差,不能兼容IE9。
- 2、需要服务端支持。
3.实现vue-router
介绍完前端路由的概念及其实现模式,接下来我们尝试实现vue-router插件,具体包括vue-router类,两个全局组件:router-link,router-view以及install方法。
3.1 实现router类
我们使用Hash模式来实现,因此vue-router具体要做的核心点就是要添加hashchange和load事件的事件侦听,在回调中根据当前url从路由表中取出对应的路由组件,提供给router-view渲染。因此一个首要的问题就是:如何根据url从路由表中取出组件?
一个基础的思路是,我们只需在侦听到url变化时,拿到当前的hash值,然后遍历路由表找到路径为当前hash值的选项的component即可。不过这样做的问题也很明显,就是无法处理嵌套路由,如果我们在路由表中配置了嵌套路由,则单靠hash值是无法匹配到子代路由的。要解决这个问题,我们可以用一个matched数组来存储从父代到子代匹配过程中的各级组件,这样各级router-view组件只需按需渲染即可。
说到这里,又会引出另一个问题,如何能做到在url变化时router-view也能响应式的更新。这里可以利用vue响应式数据的特点,我们知道单文件组件中data中的数据都是响应式的,当数据更新时,所有用到该数据的地方都会响应式的更新。而这里router-view组件显然会用到matched数组,因此我们只需将matched变为响应式数据即可。具体来说就是使Vue.util.defineReactive这个api,它可以定义一个对象的响应属性,用法如下:
Vue.util.defineReactive(obj,key,value,fn)
obj: 目标对象,
key: 目标对象属性;
value: 属性值
我们用它将matched定义为router实例的一个响应式属性,这样即可实现matched变化时,router-view也会响应式的渲染。这里还要注意,使用该方法要用到vue实例,如何拿到vue实例?我们可以在vue-router的install方法中拿到并保存,关于这一点后面会解释。接下来我们按照以上思路,首先实现router类。
// 用于在Install方法中保存vue实例
let Vue
class myRouter{
constructor (options){
this.$options = options
// 保存当前hash值,即匹配路径
this.current = window.location.hash.slice(1) || '/' // 给初值
// 保存匹配过程中的各级路由信息
Vue.util.defineReactive(this, 'matched', [])
// match方法可以递归遍历路由表,获得匹配关系
this.match()
// 添加侦听事件,事件回调中用到this,因此要绑定上下文
window.addEventListener('hashchange', this.onHashChange.bind(this))
window.addEventListener('load', this.onHashChange.bind(this))
}
onHashChange () {
// 更新匹配路径
this.current = window.location.hash.slice(1)
this.matched = []
this.match()
}
/**
* @description 遍历路由表,保存匹配关系
*/
match(routes) {
// 默认遍历总路由表
routes = routes || this.$options.routes;
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
// 严格匹配根路径
if (route.path === "/" && this.current === "/") {
this.matched.push(route);
break;
// 当前路由包含于url 则推入matched数组并递归遍历其子路由
} else if (route.path !== "/" && this.current.includes(route.path)) {
this.matched.push(route);
if (route.children) {
this.match(route.children);
}
break;
}
}
}
}
3.2 实现两个全局组件
vue-router有两个全局组件分别是:
- router-link 路由跳转
- router-view 路由占位符
我们分别来实现
router-link
router-link用来进行路由跳转,他的实现比较简单,因为其本质其实就是a标签,因此实现router-link只需渲染一个a标签即可。但要注意的是,由于此时是运行时环境,无法进行模板编译,所以不能使用模板语法,我们可以使用render函数。
具体实现思路是,使用render渲染一个a标签,herf属性对应router-link的to属性,标签内容就是用户写在router-view中的内容,我们可以通过插槽(this.$slots
)来获取,并将其添加在实际的a标签中。
export default {
props: {
to: {
type: String,
required: true
}
},
render (h) {
return h('a', { attrs: { href: '#' + this.to } }, this.$slots.default)
}
}
router-view
router-view用来渲染路由组件,我们前面实现的router中已经添加了对路由匹配关系的处理,他会根据当前url将各级匹配关系存入matched数组中,router-view如何根据matched数组按需渲染呢?
其实,对于一个嵌套路由来说,每一级路由都有一个router-view与之对应,即router-view也一定是嵌套的,因此router-view只需知道自身所处的层级,具体来说就是matched数组中的第几项即可。实现这一点我们可以给每一个router-view添加一个标记变量和一个深度计数变量,router-view判断自己的父节点有没有这个标记,有则说明自己是子代路由,则深度加一同时继续向上判断直到不存在父节点。这样最终每个router-view都会得到自己所处的层级,只需根据这个层级从matched数组获取对应的路由组件并渲染即可。下面根据以上思路来实现,注意同样不能使用模板语法,要使用render函数。
export default {
render(h) {
// 标记自己是父级router-view
this.$vnode.data.routerView = true;
// 统计深度
let depth = 0;
let parent = this.$parent;
// 获取自己的深度
while (parent) {
const vnodeData = parent.$vnode && parent.$vnode.data;
if (vnodeData && vnodeData.routerView) {
depth++;
}
}
// 不断向上查找
parent = parent.$parent;
}
let component = null;
// 获取当前层级对应的路由
const route = this.$router.matched[depth];
// 获取path对应的component
if (route) {
component = route.component;
}
return h(component);
}
};
3.3 实现install方法
vue-router是个vue插件,我们前面提到过vue插件的实现原理。它要暴露一个install方法,用全局混入(Vue.mixin)的方式混入beforeCreate生命周期,这会使得所有的组件的beforeCreate钩子都会触发该行为。我们在beforeCreate中将router实例挂载到vue原型上,便于在任何地方通过vue原型直接调用router。如何做到这一点呢?
我们在使用vue-router时会在main.js中创建Vue根实例,引入并挂载router选项,也就是说只有Vue根实例才有router这个选项。因此我们只需在beforeCreate钩子中判断当前组件有没有router选项即可,有则说明这是vue-router根实例,将router其挂载到vue原型即可。
前面实现router类时说过,我们要在install方法中保存vue实例,为什么可以这样做呢?vue插件之所以要暴露一个install方法,是因为我们使用vue.use()方法注册组件时会调用install方法,并将vue作为参数传入,因此可以在install方法中保存vue实例。
此外,install方法还要注册前面实现的两个全局组件。
接下来根据以上思路具体实现:
myRouter.install = function (_Vue){
// 保存vue实例
Vue = _Vue
Vue.mixin({
beforeCreate () {
// 确保根实例的时候才执行,因为只有根实例才有router这个选项。
if (this.$options.router) {
Vue.prototype.$router = this.$options.router
}
}
})
//注册组件
Vue.component('router-link', Link)
Vue.component('router-view', View)
}
至此,基于hash模式的丐版vue-router的已经完成。
水平有限,欢迎指正😁。
参考:
https://juejin.cn/post/6844903695365177352#heading-15
https://juejin.cn/post/6854573222231605256#heading-14