vue源码学习--手写vue-router
# 前言
要实现一个
vue-router
,首先就要清楚vue-router
是一个vue的插件,而vue注册插件是有一套自己的流程的。
# vue.use
首先要认识vue.use
这个函数。在vue官方插件的使用过程中我们会发现,诸如vue-router
,vuex
之类的插件都调用了一次use
函数。
那vue.use
到底干了什么事情呢。
先来看看vue.use
的源码
Vue.use = function (plugin: Function | Object) {
// plugin 插件一般来说是一个实例对象
const installedPlugins = (this._installedPlugins || (this._installedPlugins = [])) // 创建一个数组,用来存储已经安装的插件,避免重复安装
if (installedPlugins.indexOf(plugin) > -1) { // 此处进行判断,如果已经安装,直接返回
return this
}
// additional parameters
const args = toArray(arguments, 1)
args.unshift(this) // 将this也就是Vue添加的数组中的最前边
if (typeof plugin.install === 'function') { // 执行插件的 install 方法,将Vue拿到插件中
plugin.install.apply(plugin, args)
} else if (typeof plugin === 'function') {
plugin.apply(null, args)
}
installedPlugins.push(plugin) // 将安装好的插件放入数组中保存起来
return this
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# myRouter
根据上面源码的简单阅读我们可以了解到,这个函数接受一个参数plugin
,也就是我们传入的插件
。他的类型是一个函数
或者一个对象
。
在第一行里他去获取了vue
原型对象上的installedPlugins
属性,这个应该就是存储当前vue实例注册的插件
的数组
。然后他又判断了一次当前传入的这个插件是否存在于这个数组中,如果存在则直接retuen(这也就是为什么vue的插件重复注册只生效一次的原因。)
下面的代码就是去调用这个插件了,如果传入的是一个函数
,则直接调用
。如果传入的是对象
则调用这个对象的install
方法。最后将插件push
进数组。
以上就是vue.use
的源码了。知道了这些我们就可以开始去写我们自己的插件了。
在使用官方提供的router
插件的过程中我可以知道,vue-router
这个文件暴露出来的肯定是一个对象
,所以肯定是有一个install函数
,以及一个名为router的构造函数或者类
。
这样我们插件的基本轮廓就出来了
let Vue
class myRouter {
}
//vue在调用install的时候会传入一个vue构造函数,方便我们调用vue的各种api
const install = function (_vue) {
Vue = _vue
}
export default {
myRouter,
install
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 路由表文件
其次创建一个router.js
存放路由表以及其他一些对插件的操作。
import vue from 'vue'
import MyRouter from '../myRouter'
import home from '../components/home'
import about from '../components/about'
vue.use(MyRouter)
const routers = [
{
path: '/',
component: home
},
{
path:'/about',
component:about
}
]
export default new MyRouter.myRouter({
routers,
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 引入并挂在router
然后在main.js
里引入这个router.js
,将构造出来的router
对象传入vue的options
里。
import Vue from 'vue'
import App from './App.vue'
import router from './router/index'
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App),
}).$mount('#app')
2
3
4
5
6
7
8
9
10
11
# install
接着去写我们的插件,在使用过程中可以发现,在任何组件中我们都可以通过$router
这个对象拿到router的内容。这一步肯定是要放到install
函数里去做的。这一步很简单,直接将构造出来的router
对象挂载到vue构造函数
的原型对象
上就行了。但是有一个问题,我们在插件中如何拿到构建出来的router这个对象呢。
仔细回想一下,官方有提示过,插件的对象必须传入跟实例的配置对象中才能通过this拿到。
new Vue({
router,//也就是这一步
render: h => h(App),
}).$mount('#app')
2
3
4
知道了这个就可以去找办法了。什么东西能在跟实例初始化的时候被调用呢。
vue提供了一个api
叫做mixin
,相信大家都不陌生。vue.mixin
--全局混入器。这个api会在每个组件初始化的时候都执行一次。那有了它我们就可以轻松的拿到传入的router并且挂载到vue原型对象上了。
const install = function (_vue) {
Vue = _vue
/**
* 将用户传入的router挂载到组件实例上,但是此处我们是拿不到用户传入的router的。
* 而全局混入器就能帮助我们拿到每个组件实例,因为他会在每个组件实例化的时候被执行。
* 我们的跟组件同样也会触发mixin,而routers是在跟组建的配置项传入的,所以就可以拿到了。
*/
Vue.mixin({
beforeCreate() {
//可以选择给每个组件都挂载一个,也可以选择直接挂载到vue构造函数的原型对象上
//这里为挂载到每个组件上
// if (this.$options && this.$options.router) {
// this.$router = this.$options.router
// } else {
// this.$router = this.$parent && this.$parent.$router
// }
//这里为挂载到构造函数的原型对象上,$options就是new vuew时传入的对象
if (this.$options && this.$options.router) {
Vue.prototype.$router = this.$options.router
}
},
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
到这一步未知,就已经完成了一个插件的基本内容。
# router-link router-view
接下来继续去分析,vue-router
的两个关键组件router-link router-view
。
第一个会被渲染成a标签,点击渲染指定的组件。第二个就是一个视图容器,渲染对应的组件
const install = function (_vue) {
Vue = _vue
/**
* 将用户传入的router挂载到组件实例上,但是此处我们是拿不到用户传入的router的。
* 而全局混入器就能帮助我们拿到每个组件实例,因为他会在每个组件实例化的时候被执行。
* 我们的跟组件同样也会触发mixin,而routers是在跟组建的配置项传入的,所以就可以拿到了。
*/
Vue.mixin({
beforeCreate() {
//可以选择给每个组件都挂载一个,也可以选择直接挂载到vue构造函数的原型对象上
//这里为挂载到每个组件上
// if (this.$options && this.$options.router) {
// this.$router = this.$options.router
// } else {
// this.$router = this.$parent && this.$parent.$router
// }
//这里为挂载到构造函数的原型对象上
if (this.$options && this.$options.router) {
Vue.prototype.$router = this.$options.router
}
},
})
// Vue.property.$router = _vue.$options.router
//注册全局组件 router-link router-view
//Vue.component函数创建一个组件,第一个参数为组件名称,第二个参数为组件的配置项。注意render函数必须return。
Vue.component('router-link', {
props: {
to: {
type: String,
require: true//表示必传
}
},
render(createElement) {
/**
* createElement函数也就是h函数,用于创建dom。第一个参数为创建的dom名称,即标签名
* 第二个参数为一个配置项
*/
return createElement('a', {
attrs: {
href:this.to,
},
}, this.$slots.default[0].text)
}
})
Vue.component('router-view', {
render(createElement) {
return createElement('div',{},'我是router-view')
}
})
}
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
这样两个组件就创建好了。
现在我们去实现myRouter
这个类。
class myRouter {
constructor(options = {}) {
// this.current = '/' //当前路由 ,非响应式
Vue.util.defineReactive(this, 'current', '/')
this.routers = options.routers //传入的路由表
this.query = {}//初始化query参数
this.params = {}//初始化params参数
this.mode = options.mode || 'hash' //路由模式
this.init()//初始化函数
}
init() {
if (this.mode === 'hash') {
//监听网页初始化,给current附上初值,因为拿到的哈希时带#号的,而用户传入的路由表是不带#号的,所以要去掉#号
window.addEventListener('load', () => {
location.hash = '/'
this.current = location.hash.slice(1)
})
//添加哈希路由监听函数
window.addEventListener('hashchange', () => {
this.current = location.hash.slice(1)
})
} else {
console.log(123123)
//网页初始化的时候给current初值为'/'
window.addEventListener('load', () => {
this.current = '/'
})
//添加history路由的监听函数
window.addEventListener('popstate', (e) => {
this.current = location.pathname
})
}
}
//
// push(url) {
// location.hash = url
// }
}
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
类里面需要创建一个current
属性,表示为当前url
。后续我们就需要通过这个属性去渲染指定的组件。
mode
表示路由模式,前端的路由模式分为hash和history两种。这个后面再细说。
在constructor
构造器中可以拿到用户传入的路由表。
也就是这个玩意儿
现在有了当前路由,也有了路由表,是不是就可以在router-view
里去渲染和当前路由对应的组件了。
所以要改写一下router-view
组件
Vue.component('router-view', {
render(createElement) {
// current必须是响应式的才会在发生变化的时候触发render函数
let current = this.$router.current //获取当前路由
let routers = this.$router.routers
//拿到当前路由去路由表里查找对应的组件然后渲染
let component = routers.find(d => d.path === current)
return createElement(component.component)
}
})
2
3
4
5
6
7
8
9
10
上面在创建current
的时候用到了Vue.util.defineReactive
这个api。因为只有current
时响应式的才会在他改变的时候去触发router-view
的render
函数,去重新渲染视图。
至此我们的vue-router
基本上就已经做完了,能实现基本的功能了。
init函数就是根据当前路由模式去监听相应的路由事件。在网页初始化的时候给一个默认值。
# 了解前端路由
接下来了解一下前端路由hash模式和history模式的区别。
# hash路由
在浏览器输入一个url的时候会向服务器发送一个http请求,请求新的内容。这样做会有种种弊端。 所以衍生出了前端路由。前端路由就是只js监听当前路由的变化然后去执行某些指定的操作。 而要做到这一点首先必不可少的就是当url发生变化了不能刷新浏览器。hash路由就完美的做到了这一点。
hash路由其实指的就是hash值(#后面的内容)。这一块内容发生变化是不会出发浏览器的更新操作的。通过这一点前端就可以做到修改路由执行js,和服务器无关联了。
hash模式下,需要用到的api有location.hash,hashchange
。前者是直接修改当前哈希值,也就是我们的url值。后者则是在哈希值发生变化的时候触发
我们通过赋值相应的hash值,就可以做到修改浏览器地址栏的路由,通过将修改过后的值赋给current,就可以触发router-view的render函数,渲染新的组件。这就是vue-router的核心原理。
hash路由非常的好用且易懂,但是有一个缺点,那就是路由上会带#号,这样的路由看上去非常的不美观。所以后来又衍生出了history
模式。
# history路由
history模式:利用了 HTML5 History Interface 中新增的 pushState()
和 replaceState()
方法。(需要特定浏览器支持)
这两个方法应用于浏览器的历史记录栈,在当前已有的 back()、forward()、go()
方法的基础之上,这两个方法提供了对历史记录进行修改
的功能。当这两个方法执行修改时,只能改变当前地址栏的 URL
,但浏览器不会
向后端发送请求,也不会触发popstate
事件的执行
因此可以说,hash 模式和 history 模式都属于浏览器自身的特性,Vue-Router 只是利用了这两个特性(通过调用浏览器提供的接口)来实现前端路由。
通过pushState
和replaceState
修改url并不会触发popstate
监听的执行,同时也不会触发网页的重载。只要做到了这一点我们就可以顺理成章和hash类似的前端路由了。
在使用hsah
的时候我们可以直接给location.hash
赋值,但是在history
模式下我们必须使用pushState
和replaceState
来修改当前的url
。
pushState()
方法,接收三个参数
a state object, a title (which is currentlyignored), and (optionally) a URL
state
对象保存的是被pushState
页面的信息的一个拷贝
,也就是说以后你要用到的信息,都可以放到这个对象中。
url
是可选的,负责改变浏览器的地址栏中显示的url
,如果没有指定url
,你点击前进后退按钮页面还是会变化,只是浏览器的地址栏上显示的url会一直保持不变。
replaceState()
方法,与pushState
方法相同,主要用于改变当前历史记录
中记录的当前页面的state对象
和url信息
。
onpopstate
事件,每次点击浏览器的前进和后退按钮,就会触发window
的Onpopstate事件
。
最后使用history.state````获取当前所在页面的
state对象,也就是在上面
pushState```中保存的。
pushState、replaceState
的区别
pushState()
可以创建历史,可以配合popstate事件
,而replaceState()
则是替换掉当前的URL
,不会产生历史。
# router-link
由此我们的router-link
就不能像上面那样写了,为了兼容两种模式,我们得组织a标签的自动跳转,从而自己去修改url的值。
Vue.component('router-link', {
data() {
return {
name: 'router-link'
}
},
props: {
to: {
type: String,
require: true//表示必传
}
},
methods:{
pushUrl(e){
if (this.$router.mode == 'hash') {
location.hash = this.to
} else {
history.pushState(null, '', this.to)
this.$router.current = this.to
}
e.preventDefault()
}
},
render(createElement) {
/**
* createElement函数也就是h函数,用于创建dom。第一个参数为创建的dom名称,即标签名
* 第二个参数为一个配置项
*/
return createElement('a', {
attrs: {
href:this.to,
},
on:{
click:this.pushUrl
}
}, this.$slots.default[0].text)
}
})
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
如果为hash模式
,直接给location.hash赋值
,这样就会触发hashchange函数
,从而给current赋值
,这样就即修改了url又重新渲染
了视图。
如果为history模式
,则需要通过history.pushState
函数添加
一个路由历史
,并且手动将current
的值修改
。因为pushState
是不会
触发popstate
的监听的。只有浏览器的前进后退
才会
触发。我们添加了历史之后同时也兼容了浏览器的前进后退动作。因为我们将所到之处的路由都添加进了路由栈里。
# 总结
最后总结一下:
vue-router的实现是利用了浏览器的hash
和history
两种路由模式。
hash就是url#号后面的值,通过location.hash
可以直接修改,通过hashchange
方法可以监听到他的变化。
history模式下路由是正常的路由不带#号,他提供了pushState
和replaceState
两个api去修改url的值并且不会触发网页重载
,不会向服务器发送请求。但是这两个方法不会触发popstate
函数。而浏览器的前进后退可以触发。所以这种模式有一个弊端,刷新网页之后,浏览器会像服务器请求资源,而我们push进去的路由是由前端自定义的,可能在服务器上并不存在这个资源目录。所以会出现404
。这个时候就需要后端通过nginx配置
重定向到首页了。
vue-router就是通过监听路由的变化,然后拿到当前路由值去路由表里找到对应的组件最后渲染到router-view中。 最后附上完整的自己写的vue-router源码
let Vue
class myRouter {
constructor(options = {}) {
// this.current = '/' //当前路由 ,非响应式
Vue.util.defineReactive(this, 'current', '/')
this.routers = options.routers //传入的路由表
this.query = {}//初始化query参数
this.params = {}//初始化params参数
this.mode = options.mode || 'hash' //路由模式
this.init()//初始化函数
}
init() {
if (this.mode === 'hash') {
//监听网页初始化,给current附上初值,因为拿到的哈希时带#号的,而用户传入的路由表是不带#号的,所以要去掉#号
window.addEventListener('load', () => {
location.hash = '/'
this.current = location.hash.slice(1)
})
//添加哈希路由监听函数
window.addEventListener('hashchange', () => {
this.current = location.hash.slice(1)
})
} else {
console.log(123123)
//网页初始化的时候给current初值为'/'
window.addEventListener('load', () => {
this.current = '/'
})
//添加history路由的监听函数
window.addEventListener('popstate', (e) => {
this.current = location.pathname
})
}
}
//
// push(url) {
// location.hash = url
// }
}
const install = function (_vue) {
Vue = _vue
/**
* 将用户传入的router挂载到组件实例上,但是此处我们是拿不到用户传入的router的。
* 而全局混入器就能帮助我们拿到每个组件实例,因为他会在每个组件实例化的时候被执行。
* 我们的跟组件同样也会触发mixin,而routers是在跟组建的配置项传入的,所以就可以拿到了。
*/
Vue.mixin({
beforeCreate() {
//可以选择给每个组件都挂载一个,也可以选择直接挂载到vue构造函数的原型对象上
//这里为挂载到每个组件上
// if (this.$options && this.$options.router) {
// this.$router = this.$options.router
// } else {
// this.$router = this.$parent && this.$parent.$router
// }
//这里为挂载到构造函数的原型对象上
if (this.$options && this.$options.router) {
Vue.prototype.$router = this.$options.router
}
},
})
// Vue.property.$router = _vue.$options.router
//注册全局组件 router-link router-view
//Vue.component函数创建一个组件,第一个参数为组件名称,第二个参数为组件的配置项。注意render函数必须return。
Vue.component('router-link', {
data() {
return {
name: 'router-link'
}
},
props: {
to: {
type: String,
require: true//表示必传
}
},
methods:{
pushUrl(e){
if (this.$router.mode == 'hash') {
location.hash = this.to
} else {
history.pushState(null, '', this.to)
this.$router.current = this.to
}
e.preventDefault()
}
},
render(createElement) {
/**
* createElement函数也就是h函数,用于创建dom。第一个参数为创建的dom名称,即标签名
* 第二个参数为一个配置项
*/
return createElement('a', {
attrs: {
href:this.to,
},
on:{
click:this.pushUrl
}
}, this.$slots.default[0].text)
}
})
Vue.component('router-view', {
render(createElement) {
// current必须是响应式的才会在发生变化的时候触发render函数
let current = this.$router.current //获取当前路由
let routers = this.$router.routers
let component = routers.find(d => d.path === current)
return createElement(component.component)
}
})
}
export default {
myRouter,
install
}
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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124