手工实现vue-router

今天是元旦放假的第一天,窝在家里,倍感无聊,所以写下本文。一是想巩固对vue-router的掌握,二是当作记录,方便你我他。

目前工作使用的技术栈是:

1
2
3
4
5
6
vue.js: JS框架
vue-router: 路由控制
vuex: 类Flux单向数据流管理
vue-i18n: 国际化
iview: UI组件库
...第三方工具库

可能因为当前业务复杂度一般,所以对vue-router所要求的功能并不是很多,像alias这种重命名就基本没有使用过,所以我只会加入日常工作中使用最多的功能,可以说是官方vue-router的阉割版(如果你对vue-router的更多功能感兴趣,可以在本文的基础上,再查阅官方vue-router的源码)。

在开发我们的vue-router(下面简称router),我们首先需要了解一下如何开发一个vue插件。

vue插件

插件通常会为 Vue 添加全局功能

使用插件

1
2
Vue.use(MyPlugin);
// Vue.use这个方法,会去调用MyPlugin.install方法

开发插件

从上面的使用来看,我们知道当执行Vue.use(MyPlugin)的时候,会执行MyPlugin.install方法,而这个方法会接受两个参数,一个参数是Vue构造器,第二个参数是一个可选的对象。

1
2
3
4
5
6
7
8
9
10
MyPlugin.install = function(Vue, options) {
// 可以使用Vue的方法
// 例如:mixin
// 对每个Vue实例的created生命周期注入了console.log
Vue.mixin({
created() {
console.log('use myplugin');
}
});
}

了解更多插件知识

然后我们再看下官方的vue-router是如何使用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// router.js
import Vue from 'vue';
import VueRouter from 'vue-router';

Vue.use(VueRouter);

const router = new VueRouter({
routes: [{
// xxx
}]
});
export default router;

// main.js
import router from './router';

new Vue({
el: '#app',
// ...
router,
});

那么我们大概可以看出VueRouter是一个含有install属性的构造函数,让我们简单设计一下:

1
2
3
4
5
6
class VueRouter {

}
VueRouter.install = function(Vue) {
// xxx
}

好,让我们一点一点来补充。Let’s go!

Router构造器

从官方文档中,Router构造器的构建选项有如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
routes: 路由定义配置

mode: 路由模式("hash","history","abstract"), 表示如何检测路由变化, 默认是hash

base: 基路径(在业务中还没有使用到,一般配置都是服务器直接配置nginx,所以这里将不会展开)

linkActiveClass: 配合router-link使用,如果当前路由与哪个router-link匹配,将会给匹配的router-link加该class类名

linkExactActiveClass: 上面的精确匹配版本(在以前使用react-router中接触过exact
比如: '/', '/hello', 如果当前路由为'/hello', 那按理说两个都会匹配到,但是如果设值了exact,则只会匹配'/hello', 这个业务中也没使用到,将不展开)

fallback: 回退,如果上面设置的mode在当前环境不支持,并且fallback为true,则会回退到hash

# 下面目前没有使用到,也不会展开
scrollBehavior: 滚动行为
parseQuery/stringifyQuery: 自定义查询字符串的解析/反解析函数

除了routes我们需要配置外,其它配置项,我们都选择默认值,下面让我们看下一个简单的routes配置项:

1
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
/* Vue组件 start */
const App = {
template: `
<div>
<h1>App</h1>
<ul>
<li><router-link to="/child1">child1 link</router-link></li>
<li><router-link to="/child2">child2 link</router-link></li>
</ul>
<br />
<router-view />
</div>
`,
};
const Child1 = {
template: '<h2>child1</h2>',
};
const Child2 = {
template: '<h2>child2</h2>',
};
/* Vue组件 end */

// 这里我们不会处理name字段
const routes = [{
path: '/',
component: App,
children: [{
path: 'child1',
component: Child1,
}, {
path: '/child2',
component: Child2,
}]
}]

上面,我们定义了三个路由,一个是根路由,两个子路由。不知道大家有没有发现两个子路由有一点点不同,它们的path一个是以’/‘开头,另一个而不是,这样做有什么区别呢?
不急,请继续往下看。

让我们对上面设计的VueRouter类,加点东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class VueRouter {
constructor(options) {
// 同上所述,这里其他配置选择默认值
const { routes } = options;
// 这个app指向Vue根实例,可以关注下,为何要绑定到Router实例上
this.app = null;
this._options = options;
}

// 初始化方法
init(vm) {
this.app = vm;
this._matcher = createMatcher(this._options.routes);
}

// 操作路由的方法
go(n) {}
back() {}
forward() {}
push(location) {}
replace(location) {}
}

createMatcher方法知道,vue-router会把我们配置的routes进行一个hash指向处理,简单的说,就是一个通过设置的path可以获取当前路由对象的hash算法。

routes -> route对象

这里参考下官方,创建createMatcher方法进行转换处理。

1
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
function createMatcher(routes) {
// 收集path,便于指向match方法的时候遍历 -> 列表便于遍历
const pathList = [];
// key为path,value为route对象,便于存取 -> hash对象便于存取
const pathMap = new Map();

routes.forEach(route => {
routeToRecord(pathList, pathMap, route);
});
/**
* 当我们监测到路由发生变化,我们把当前location
* 传进来,看是否有匹配的路由对象
*/
function match(location) {
for (const path of pathList) {
if (path '匹配' location) {
const record = pathMap.get(path);
// 这里我们需要处理下,因为这个record可能是一个子路由,我们需要把它的父获取到
// 因为我们处理router-view组件的时候需要渲染使用, 这里不清楚没关系,不急.
return {
meta: record.meta,
path: record.path || '/',
hash: record.hash || '',
// 非常重要
matched: formatMatch(record),
// query: 这里就不多说了
}
}
}
// 说明没有匹配到路由,用户友好就可以展示404
return null;
}

// 这里大概讲下, 比如,我们匹配到路由/child2,
// 这个时候,我们会得到[App的record, child2的record]
// 这是因为我们有两个router-view需要渲染,也就是处于第几层路由,如果匹配到了,说明就会有对应几个
// router-view组件需要渲染,如果只返回当前record,那其他的router-view就无法渲染出来,从而导致页面出问题
// 这里大家可以细细品味下
function formatMatch(record) {
const res = [];
while (record) {
res.unshift(record);
record = record.parent;
}
return res;
}

return {
match,
}
}

function routeToRecord(pathList, pathMap, route, parent) {
const { path } = route;
// 还记得上面我们定义的两个子路由吗
// 如果路由的path是以'/'开头的,那么它的匹配path就不需要再拼接parent的path了
const normalizedPath = path.startsWith('/') ? path : `${parent.path}/${path}`;
const record = {
// 这里我们使用了path-to-regexp库来处理path匹配
regex: PathToRegexp(normalizedPath, []),
path: normalizedPath,
// 这里我们了解到,如果我们只定义component,也会被处理到components中
// 这是为了在同一个路由下,能通过不同的name渲染router-view
// 如果我们不给router-view传name则默认渲染default
components: route.components || {
default: route.component,
},
// 非常重要
parent,
meta: route.meta || {},
};

// 和vue.js 1.0处理指令元素一样
// 使用深度优先遍历
if (route.children) {
route.children.forEach(child => {
routeToRecord(pathList, pathMap, child, record);
})
}

// 说明,如果我们重复定义相同的path,只会处理第一个
// 这个使用pathMap.has是用了hash便于搜索的优势
if (!pathMap.has(record.path)) {
pathList.push(record.path);
pathMap.set(record.path, record);
}
}

现在我们收集了routes配置,让我们看下当前pathListpathMap:

当前pathList和pathMap

这里我们已经完成了routes配置 -> pathList, pathMap, 下面我们看看如何匹配路由的。

匹配路由

这里,我们使用了第三方库history,用来对路由进行监测管理。

VueRouter类的init方法中,我们加入代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
init() {
// xxx

this._history = history.createHashHistory(); // mode为hash
// 因为一进来,是不会执行listen的回调的,所以我们需要手动设置当前location
const location = this._history.location;
// 匹配获取路由对象
const match = this._matcher.match(location);
// 这里挂载到Vue根实例上,每个Vue实例上的$route便是此
this.app._route = match;
this._history.listen((location) => {
const match = this._matcher.match(location);
this.app._route = match;
});
}

Vue实例属性

让我们来补充下install方法

1
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
let Vue;

function install(_Vue) {
// 避免执行多次Vue.use
if (Vue && Vue === _Vue) {
return;
}
Vue = _Vue;
Vue.mixin({
beforeCreate() {
const options = this.$options;
if (options.router) {
this._routerRoot = this;
this._router = options.router;
// reactive
Vue.util.defineReactive(this, '_route', {});
// 调用VueRouter的init,开始路由工作
this._router.init(this);
} else {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
}
},
});

// 这里有点不清楚,为什么$route就可以在devtools中显示,而$router却不显示
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._routerRoot._router;
}
});
Object.defineProperty(Vue.prototype, '$route', {
get() {
return this._routerRoot._route;
}
});
// 注册组件
Vue.component('RouterLink', RouterLink);
Vue.component('RouteView', RouterView);
}

这里我们算是完成万里长征的一半,剩下的一半即是我们的两个组件。

link和view组件

首先,来个简单的link组件
link组件,我们知道就是渲染我们传进去的子内容,并且点击会跳转到我们指导的to

说实话,回想当前所从事工作业务中,link组件基本没有用过,都是通过push,replace等动态方法来操作路由。

link组件

1
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
<template>
<component @click="onClick" :is="tag" :href="to">
<slot />
</component>
</template>

<script>
export default {
name: "router-link",
props: {
to: {
type: String,
required: true
},
tag: {
type: String,
default: "a"
}
},
methods: {
onClick(event) {
event.preventDefault();
this.$router.push(this.to);
}
}
};
</script>

本文最后的大头,view组件,工作机制,就是与匹配到的路由对象,渲染其的components

view组件

1
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
<!-- 官方的view是functional组件,为了更加友好,我使用单文件模式 -->
<template>
<component v-bind:is="renderComponent"></component>
</template>

<script>
export default {
name: "router-view",
data() {
return {
routerView: true,
renderComponent: null
};
},
props: {
name: {
type: String,
default: "default" // 默认渲染components.default
}
},
created() {
this.handleRender();
},
methods: {
handleRender() {
let parent = this.$parent;

// 匹配matched中的第几个,这也就是我们为什么要把父路由存到matched中
let depth = 0;
// 从当前组件一直遍历处理到根组件
while (parent && parent._routerRoot !== parent) {
if (parent.routerView) {
depth++;
}
parent = parent.$parent;
}

try {
const route = this.$route;
const match = route.matched[depth];
const components = match.components;
const component = components[this.name];
this.renderComponent = component;
} catch (e) {
this.renderComponent = null;
}
}
},
watch: {
$route() {
this.handleRender();
}
}
};
</script>

Ok, 这样我们就拥有了一个阉割版的vue-router,但是我们还有很多没有做,比如路由拦截钩子,路由生命周期钩子等。

最后

因为能力有限,难免有不足之处,如果你发现了,还请指出,谢谢。

参考代码

ToDo

  1. beforeEach
  2. afterEach
  3. 支持缓存 - keep-alive
  4. link: linkActiveClass
  5. mode: history

推荐文章