vue-router核心原理与手写实现
# 一、核心原理
1.什么是前端路由?
在 Web 前端单页应用 SPA(Single Page Application)中,路由描述的是 URL 与 UI 之间的映射关系,这种映射是单向的,即 URL 变化引起 UI 更新(无需刷新页面)。
2.如何实现前端路由?
要实现前端路由,需要解决两个核心:
- 如何改变 URL 却不引起页面刷新?
- 如何检测 URL 变化了?
下面分别使用 hash 和 history 两种实现方式回答上面的两个核心问题。
hash 实现
hash 是 URL 中 hash (#) 及后面的那部分,常用作锚点在页面内进行导航,改变 URL 中的 hash 部分不会引起页面刷新
通过 hashchange 事件监听 URL 的变化,改变 URL 的方式只有这几种:
通过浏览器前进后退改变 URL
通过<a>标签改变 URL
通过 window.location 改变 URL
history 实现
history 提供了 pushState 和 replaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新
history 提供类似 hashchange 事件的 popstate 事件,但 popstate 事件有些不同:
通过浏览器前进后退改变 URL 时会触发 popstate 事件
通过 pushState/replaceState 或<a>标签改变 URL 不会触发 popstate 事件。
好在我们可以拦截 pushState/replaceState 的调用和<a>标签的点击事件来检测 URL 变化
通过 js 调用 history 的 back,go,forward 方法去触发该事件
所以监听 URL 变化可以实现,只是没有 hashchange 那么方便。
# JS 简单实现
<!DOCTYPE html>
<html lang="en">
<body>
<ul>
<ul>
<!-- 定义路由 -->
<li><a href="#/home">home</a></li>
<li><a href="#/about">about</a></li>
<!-- 渲染路由对应的 UI -->
<div id="routeView"></div>
</ul>
</ul>
</body>
<script>
let routerView = routeView
window.addEventListener('hashchange', () => {
let hash = location.hash
routerView.innerHTML = hash
})
window.addEventListener('DOMContentLoaded', () => {
if (!location.hash) {
//如果不存在hash值,那么重定向到#/
location.hash = '/'
} else {
//如果存在hash值,那就渲染对应UI
let hash = location.hash
routerView.innerHTML = hash
}
})
</script>
</html>
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
上面代码中 我们监听 hashchange 事件。一旦事件触发,就改变 routerView 的内容,若是在 vue 中,这改变的应当是 router-view 这个组件的内容
同理 history 模式实现
let routerView = routeView
window.addEventListener('DOMContentLoaded', onLoad)
window.addEventListener('popstate', () => {
routerView.innerHTML = location.pathname
})
function onLoad() {
routerView.innerHTML = location.pathname
var linkList = document.querySelectorAll('a[href]')
linkList.forEach((el) =>
el.addEventListener('click', function(e) {
e.preventDefault()
history.pushState(null, '', el.getAttribute('href'))
routerView.innerHTML = location.pathname
})
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
两种模式都可以利用 history.go,back,forward 来触发 hashchange 事件
# 二、vue-router 实现
我们先看一下项目中是如何引入 VueRouter 的
import VueRouter from 'vue-router'
Vue.use(VueRouter)
2
添加 VueRouter 后发生了什么变化?
- 通过 Vue.use(VueRouter) 使得每个组件都可以访问 this.$router 并拥有 this.$route 实例
- 增加了 router-view 与 router-link 两个组件
然后我们会 new 一个 VueRouter 对象
const router = new VueRouter({
mode: 'hash',
routes: [
{
path: '/page1',
name: 'page1-1',
component: 'page1.vue',
children: [
{
path: 'page1-1',
name: 'page1-1',
component: 'page1-1.vue'
}
]
},
{
path: '/login',
name: 'Login',
component: 'page3.vue',
}
]
})
// main.js中挂载
new Vue({
router
render: (h) => h(App)
}).$mount('#app')
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
按照上面的流程,我们先搭一下 VueRouter 类的框架
class VueRouter {
constructor(options) {
his.mode = options.mode || "hash"
this.routes = options.routes || []
}
install(Vue) {
Vue.$router = ...
Vue.$route = ...
Vue.component('router-link', {})
Vue.component('router-view', {})
}
}
export default VueRouter
2
3
4
5
6
7
8
9
10
11
12
13
14
15
为了方便处理, 我把 routes 的数组类型处理为 map 类型
class VueRouter {
constructor(options) {
this.routes = options.routes || []
this.routesMap = this.createRoutesMap(this.routes)
}
function createRoutesMap(routes, prefix = '') {
return routes.reduce((pre, current) => {
pre[prefix + current.path] = current.component
// 递归解析children
if (current.children && current.children.length > 0) {
pre = Object.assign(pre, createRoutesMap(current.children, prefix + current.path + '/')) // children的key为 父+ /
}
return pre
}, {})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
处理后我们的映射关系为
{
'/page1': 'page1.vue',
'/page1/page1-1': 'page1-1.vue',
'/login': 'page3.vue'
}
2
3
4
5
有了映射表,我们来监听地址栏的 url 变化, 并映射到对应的页面
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash'
this.history = { current: null }
this.init()
}
init() {
if (this.mode === 'hash') {
// 先判断用户打开时有没有hash值,没有的话跳转到 /
location.hash ? '' : (location.hash = '/')
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1)
})
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1)
})
} else {
location.pathname ? '' : (location.pathname = '/')
window.addEventListener('load', () => {
this.history.current = location.pathname
})
window.addEventListener('popstate', () => {
this.history.current = location.pathname
})
}
}
}
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
现在我们拿到了当前路由的信息了, 可以实现$router 与 Vue.$route 了
class VueRouter {
install(Vue) {
Object.defineProperty(Vue, '$router', {
get() {
return Vue.router
}
})
Object.defineProperty(Vue, '$route', {
get() {
return Vue.router.history.current
}
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
现在我们已经保存了当前路径,我们可以根据当前路径从路由表中获取对应的组件进行渲染
接下来定义 router-view 和 router-link 组件
Vue.component('router-view', {
render(h) {
let current = this._self._root._router.history.current
let routeMap = this._self._root._router.routesMap
return h(routeMap[current])
}
})
Vue.component('router-link', {
props: {
to: String
// 也可以通过 name 方式, 此处省略
},
render(h) {
let mode = this._self._root._router.mode
let to = mode === 'hash' ? '#' + this.to : this.to
return h('a', { attrs: { href: to } }, this.$slots.default)
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
到这里 VueRouter 我们就全部实现了,看一下完整代码
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
this.routesMap = this.createRoutesMap(this.routes)
this.history = { current: null }
this.init()
}
init() {
if (this.mode === 'hash') {
// 先判断用户打开时有没有hash值,没有的话跳转到 /
location.hash ? '' : (location.hash = '/')
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1)
})
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1)
})
} else {
location.pathname ? '' : (location.pathname = '/')
window.addEventListener('load', () => {
this.history.current = location.pathname
})
window.addEventListener('popstate', () => {
this.history.current = location.pathname
})
}
}
install(Vue) {
Object.defineProperty(Vue, '$router', {
get() {
return Vue.router
}
})
Object.defineProperty(Vue, '$route', {
get() {
return Vue.router.history.current
}
})
Vue.component('router-view', {
render(h) {
let current = this._self._root._router.history.current
let routeMap = this._self._root._router.routesMap
return h(routeMap[current])
}
})
Vue.component('router-link', {
props: {
to: String
// 也可以通过 name 方式, 此处省略
},
render(h) {
let mode = this._self._root._router.mode
let to = mode === 'hash' ? '#' + this.to : this.to
return h('a', { attrs: { href: to } }, this.$slots.default)
}
})
}
createRoutesMap(routes, prefix = '') {
return routes.reduce((pre, current) => {
pre[prefix + current.path] = current.component
// 递归解析children
if (current.children && current.children.length > 0) {
pre = Object.assign(
pre,
this.createRoutesMap(current.children, prefix + current.path + '/') // children的key为 父+ /
)
}
return pre
}, {})
}
}
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
以上代码和源码不一样但原理基本相同
这里 URL 变化并不会引起视图的更新, 我们可以在 install 方法里用 Vue.util.defineReactive() 来监听 history
install(Vue){
Vue.util.defineReactive(this,"myRouter",this.history)
}
2
3
这样视图就可以正常更新了
# hash 模式和 history 模式的区别
形式上:hash 模式 url 里面永远带着#号,开发当中默认使用这个模式。如果用户考虑 url 的规范那么就需要使用 history 模式,因为 history 模式没有#号,是个正常的 url,适合推广宣传;
功能上:比如我们在开发 app 的时候有分享页面,那么这个分享出去的页面就是用 vue 或是 react 做的,咱们把这个页面分享到第三方的 app 里,有的 app 里面 url 是不允许带有#号的,所以要将#号去除那么就要使用 history 模式,但是使用 history 模式还有一个问题就是,在访问二级页面的时候,做刷新操作,会出现 404 错误,那么就需要和后端人配合,让他配置一下 apache 或是 nginx 的 url 重定向,重定向到 app 的首页路由上就 ok 了。
使用场景
一般场景下,hash 和 history 都可以,除非你更在意颜值,# 符号夹杂在 URL 里看起来确实有些突兀。
如果不想要很丑的 hash,我们可以用路由的 history 模式,这种模式充分利用 history.pushState API 来完成 URL 跳转而无须重新加载页面。 Vue-router 里调用 history.pushState() 相比于直接修改 hash,存在以下优势:
- pushState() 设置的新 URL 可以是与当前 URL 同源的任意 URL;而 hash 只可修改 # 后面的部分,因此只能设置与当前 URL 同文档的 URL
- pushState() 设置的新 URL 可以与当前 URL 一模一样,这样也会把记录添加到栈中;而 hash 设置的新值必须与原来不一样才会触发动作将记录添加到栈中
- pushState() 通过 stateObject 参数可以添加任意类型的数据到记录中;而 hash 只可添加短字符串
# 总结
- 路由就是 URL 与 UI 之间的映射关系,处理好映射关系就实现了路由
- 路由分为 hash 和 history两种模式, 通过监听相应的浏览器对象事件监测 URL 变化。
- 当 URL 发生变化,用新 URL 与处理好的路由表比对,找到对应的 vue 组件进行渲染
- 不在意 URL 内容时推荐使用 hash 模式,配置少,更省事, 需要标准的 URL 时使用 history,需注意配置 ng 防止白屏