源码实现系列之vue-router

源码实现系列之vue-router

这个月会实现一下Vue, Vuex, vue-router。我会以倒推的模式边开发边写文章。话不多说开始跟着我一起撸。仓库地址

本文只是实现了一个基础版本的vue-router.本文所写的代码,不会每个地方都做异常判断。实现一个能够体现vue-router核心逻辑即可。

我大致捋了下vue-router的流程图如下:
vue-router思维导图

在写源码之前,我先展示下routes的数据结构,在根据这个数据结构来进行vue-router的开发

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
const routes = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/about',
name: 'About',
component: () =>
import(/* webpackChunkName: "about" */ '../views/About.vue'),
children: [
{
path: 'a',
name: 'about.a',
component: {
render: (h) => <div>this is a</div>,
},
},
{
path: 'b',
name: 'about.b',
component: {
render: (h) => <div>this is b</div>,
},
},
],
},
];

安装 install

回忆下我们平时代码里用到vue-router必定会有的两行代码

1
2
3
import VueRouter from 'vue-router';

Vue.use(VueRouter);

Vueuse VueRouter的时候会调用VueRouterinstall方法,并传入Vue构造函数。

所以我们接下来第一步就是创建一个VueRouter构造函数,并实现一个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
40
41
// vue-router/index.js
const install = (Vue) => {
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
this._routerRoot = this;
this._router = this.$options.router;
} else {
this._routerRoot = this.$parent && this.$parent._routerRoot;
}
},
});

// FIXME:
Object.defineProperty(Vue.prototype, '$route', {
get() {
return 'this is $route';
},
});

Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._routerRoot._router;
},
});

// FIXME:
Vue.component('router-view', {
render() {
return <div>this is router-view</div>;
},
});
// FIXME:
Vue.component('router-link', {
render() {
return <div>this is router-link</div>;
},
});
};

export default install;

vue-router也会与vuex一样,通过Vue.mixin方法添加beforeCreate生命周期,通过在这个生命周期函数,向每个组件实例挂载_routerRoot根实例对象,根实例会多挂载一个_router对象,这样子组件就可以通过_routerRoot._router来获取路由实例对象了。同时会往Vue.prototype上挂载$router $route,方便每个子组件获取。

并挂载全局组件router-viewrouter-link组件;

实例

写完install方法后,我们接下来开始动手VueRouter。初始化时,该在constructor中写些什么呢?初始化当然是为了后续的一些方法做准备了。他将包含以下几点

  • 创建matcher对象
    • addRoute: 用于动态添加路由
    • match: 重中之重,用于根据path匹配出对应的route对象,用于router-view的渲染
  • 根据对应的mode创建对应的history对象(我这里开发的router默认使用HashHistory
1
2
3
4
5
6
7
8
9
class VueRouter {
constructor(options) {
this.matcher = createMatcher(options.routes);

this.history = new HashHistory();
}

init() {}
}

createMatcher

那么createMatcher到底该怎么实现?还是反推的模式,它肯定返回上面列的两个方法addRoute match方法。那么就先实现下大致的样子吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// create-matcher.js

function createMatcher(routes) {
function addRoute() {}

function match() {}

return {
addRoute,
match,
};
}

export default createMatcher;

match

接下来就是实现match方法了。前面说过match是用来匹配path所对应的route对象。所以他肯定有个path参数,然后通过一个路由映射表来筛选出对应的route对象。那就开始着手编码吧!

1
2
3
4
// create-matcher.js
function match(location) {
return pathMap[location]
}

这个时候你肯定会有个疑惑,这个pathMap哪里来的呢?上面我已经说过pathMap是一个路由映射表。那就开始着手编码吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// create-matcher.js
function createMatcher(routes) {
const { pathList, pathMap } = createRouteMap(routes);
function addRoute() {}

function match(locaiton) {
return pathMap[locaiton];
}

return {
addRoute,
match,
};
}

match方法中并不会那么简单的返回route对象就ok了。肯定是经过一个函数保证过返回matched path等字段。这里先忽略,等讲到history的时候再讲解。

createRouteMap

你可以看到代码中调用了createRouteMap,那么这个方法该怎么实现呢?首先我们已知这个方法是用来创建pathMap的,那么先搭一个大致的函数框架

1
2
3
4
5
6
7
8
9
10
11
12
// create-route-map.js
function createRouteMap(routes) {
const pathList = [];
const pathMap = Object.create(null);

return {
pathList,
pathMap,
};
}

export default createRouteMap;

大致会是这个样子。执行返回路由映射表。那么该怎么创建pathList pathMap对象呢?当然用传入的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
// create-route-map.js
function createRouteMap(routes) {
const pathList = [];
const pathMap = Object.create(null);

routes.forEach(route => {
addRouteRecord(route, pathList, pathMap)
})
return {
pathList,
pathMap,
};
}

function addRouteRecord (route, pathList, pathMap) {
const path = route.path
const record = {
path,
component: route.component
}
if (!pathList.includes(path)) {
pathList.push(path)
pathMap[path] = record
}
}
export default createRouteMap;

上面我通过遍历routes,调用addRouteRecord方法,并传入routeaddRouteRecord根据传入的route构建pathList pathMap,我想大家看代码应该就理解这段代码是什么意思了。当然上面只是非常非常基础版本的。还有一些场景没考虑到,比如传入的route对象还有children,就需要继续遍历他的children并调用addRouteRecord方法。那就开始着手编码吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// create-route-map.js
function addRouteRecord(route, pathList, pathMap, parent) {
const path = parent ? `${parent.path}/${route.path}` : route.path;
const record = {
path,
component: route.component,
parent,
};
if (!pathList.includes(path)) {
pathList.push(path);
pathMap[path] = record;
}

if (route.children) {
route.children.forEach((o) => {
addRouteRecord(o, pathList, pathMap, record);
});
}
}

你会发现addRouteRecord多了个parent参数,是因为有子路由。给record增加parent属性是因为方便后面父子组件递归渲染。

match 实现完了,就该实现下addRoute。那么addRoute又该怎么实现呢?其实非常的简单,就是给pathList pathMap增加routes对象罢了。那么我们就开始动手吧!

1
2
3
4
// create-matcher.js
function addRoute(routes) {
createRouteMap(routes, pathList, pathMap);
}

你会发现这里的createRouteMap增加了2个参数,所以createRouteMap方法就需要去兼容了。那么我们就开始动手吧!

1
2
3
4
5
6
7
8
9
// create-route-map.js

function createRouteMap(routes, oldPathList, oldPathMap) {
- const pathList = [];
+ const pathList = oldPathList || [];
- const pathMap =Object.create(null);
+ const pathMap = oldPathMap || Object.create(null);
// ...
}

只需要对原先创建pathList pathMap对象进行兼容即可。

History

constructor中的matcher对象创建讲完之后,接下来我们来讲讲constructor中的HashHistory

HashHistory该怎么实现?new HashHistory后做了什么呢?

因为history模式有多种,我们需要实现一个base版的history,然后HashHistory基于这个基类进行扩展。

1
2
3
4
// history/base.js
export default class History {
constructor() {}
}
1
2
3
4
5
6
7
8
import History from './base';

export default class HashHistory extends History {
constructor(router) {
super(router);
this.router = router;
}
}

init

init 哪里调用

这个init会在哪里调用呢?当然是安装的时候调用。所以我们在install的时候加上init的调用。

init为什么要传入根实例呢?因为需要监听当前url对应的路由变化。当他变化之后,会主动将根实例的_route赋值成当前的根路由。那根实例的_route哪来的呢?可能有人忘记了。可以翻到上面install的章节,那里讲过了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// install.js
const install = (Vue) => {
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
this._routerRoot = this;
this._router = this.$options.router;
+ this._router.init(this)
} else {
this._routerRoot = this.$parent && this.$parent._routerRoot;
}
},
});
}

加号位置就是我添加的代码。调用下init初始化下VueRouter

那么init方法中做了哪些事情呢?

  • 挂载当前url对应的路由组件
  • 监听路由的变化(hashchange等事件)

话不多说,进入编码模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// vue-router/index.js
import HashHistory from './history/hash';
import install from './install';
import createMatcher from './create-matcher';

class VueRouter {
constructor(options) {
// ...
}
init(app) {
const history = this.history;
const setupListener = () => {
history.setupListener();
};
history.transitionTo(history.getCurrentLocation(), setupListener);
history.listen((route) => {
app._route = route;
});
}
}

init中做了哪些工作

transitionTo

init中调用了history中的transitionTo,那么这个方法是干嘛的呢?用于路由的跳转,根据传入的pathpathMap中筛选出对应的route,这个方法会触发下面讲到的listen方法。这个方法触发后,会修改根实例的_route。修改之后,router-view就会响应式的改变,以达到刷新路由渲染页面的目的。因为调用transitionTo方法会有多种途径。一种是主动调用push方法等,需要主动修改浏览器地址栏hash值,一种是页面初始化调用,这个时候又需要监听hashchange等事件,所以transitionTo增加第二个参数用于回调。这样每个调用transitionTo后,可执行自己的逻辑。

话不多说上代码

1
2
3
4
5
6
7
8
9
10
11
12
// history/base.js
export default class History {
constructor(router) {
+ this.router = router
}

+ transitionTo(location, callback) {
+ const r = this.router.match(location)
+ console.log(r)
+ callback && callback()
+ }
}

上面简短的代码是不是就实现了上面描述中的几个功能了。
还有两点功能没实现。

  • 怎么响应式刷新
  • 怎么实现匹配路由(match)

我们先来讲怎么响应式刷新。那么怎样才能实现router-view响应式的刷新呢?我们根据倒推的模式,router-view是根据根实例的_route做刷新的。所以需要增加个current对象用来表示当前路由。上代码🐶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// history/base.js
export default class History {
constructor(router) {
this.router = router
+ this.current = createRoute(null, {
+ path: '/'
+ })
}

transitionTo(location, callback) {
const r = this.router.match(location)
+ this.current = r
callback && callback()
console.log(r)
}
}

可是光设置current还不能实现router-view响应式刷新。因为route-view是根据$route做响应式的。还记得在install的时候设置过$route吗?我们将它修改下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// install.js

const install = (Vue) => {
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
// ...
this._router.init(this)
+ Vue.util.defineReactive(this, '_route', this._router.history.current);
} else {
this._routerRoot = this.$parent && this.$parent._routerRoot;
}
},
});

Object.defineProperty(Vue.prototype, '$route', {
get() {
- return 'this is $route';
+ return this._routerRoot._route;
},
});
}

这样 我们一旦修改current,页面的$route就会响应式更新了。刷新下页面试试看吧🐶。

竟然报错了。提示createRoute is not defined

抄下上面贴过的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// history/base.js
export default class History {
constructor(router) {
this.router = router
+ this.current = createRoute(null, {
+ path: '/'
+ })
}

transitionTo(location, callback) {
const r = this.router.match(location)
+ this.current = r
callback && callback()
console.log(r)
}
}

我们来讲讲createRoute的作用。他的作用是将匹配到的route进行处理,返回个包含pathmatched字段。matched字段包含了,从匹配到的一级路由一直到最后一级路由。router-view也是根据这一数组进行父组件到子组件的渲染的。match方法中也用到的这个方法。下面再讲match方法。接下来我们就来实现createRoute方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
// history/base.js
export function createRoute(record, location) {
const matched = [];
while (record) {
matched.unshift(record);
record = record.parent;
}

return {
matched,
...location,
};
}

第一个参数record其实就是上文createRouteMapaddRouteRecord使用到的record。其中包含了parent字段就是在这个时候用到的。所以/about/a就可以生成matched: [{path: '/about', component: componentAbout}, {path: '/about/a', component: componentAboutA}]了。再次提醒下,matched用于router-view的层层渲染。话不多说,上代码🐶

1
2
3
4
5
6
7
8
9
10
11
12
13
// vue-router/index.js
class VueRouter {
constructor(options) {
this.matcher = createMatcher(options.routes);
// ...
}

match(location) {
return this.matcher.match(location);
}

// ...
}

提醒下,这里的matcher上面讲过了。用来返回matchaddRoute方法。

然后再完善下上面写过的createMatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// create-matcher.js
+ import { createRoute } from './history/base';

function createMatcher(routes) {
const { pathList, pathMap } = createRouteMap(routes);
// ...

function match(locaiton) {
- return pathMap[locaiton];
+ return createRoute(pathMap[locaiton], {
+ path: locaiton,
+ });
}

// ...
}

看着好像没什么问题了,再刷新下页面。

提示history.setupListener is not a function

我们再把思绪拉回到init方法中调用的history.setupListener

setupListener其实非常的简单,就是添加监听事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default class HashHistory extends History {
constructor(router) {
super(router);
this.router = router;
}

getCurrentLocation() {
return window.location.hash.slice(1);
}

+ setupListener() {
+ window.addEventListener('hashchange', () => {
+ this.transitionTo(this.getCurrentLocation());
+ });
+ }
}

监听到hashchange后,主动调用下transitionTo去切换路由。

然后我们再刷新下页面,又报错 excuse me???

提示: TypeError: history.listen is not a function

listen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VueRouter {
constructor(options) {
// ...
this.history = new HashHistory(this);
}

init(app) {
const history = this.history;
// ...
history.transitionTo(history.getCurrentLocation(), setupListener);
> history.listen((route) => {
> app._route = route;
> });
}
}

inithistory.listen其实也是非常的简单,就是添加订阅。当调用transitionTo后,触发下订阅的事件。并传入location对应的route。话不多说,上代码🐶

1
2
3
4
5
6
7
8
9
10
11
12
// history/base.js
export default class History {
transitionTo(location, callback) {
const r = this.router.match(location);
// ...
this.cb && this.cb(r)
}

listen(cb) {
this.cb
}
}

这个时候再刷新下页面。打印下.vue文件里的this.$route看了下好像没问题😆。

上文说过router-view是根据$route层层渲染的,既然$route都冇问题了,那就开始编写router-view组件吧。

router-view

在我看router-view源码之前,我根本不知道router-view怎么实现。原来它是根据$routematched匹配到的组件进行层层渲染的。
举个🌰,就举上面打印出来的matched来讲好了,app.vue中的router-view会渲染matched的第一项中的component对应的about.vue组件,about.vue中的router-view会渲染matched中第二个component对应的about/a组件。可能上代码更简单易懂🐶,那我就开始开发了(本文都是边开发边写文章的)。

router-view组件是用的函数式组件,因为函数式组件无状态 (没有响应式数据),也没有实例 (没有 this 上下文)

1
2
3
4
5
6
7
8
// components/router-view.js
export default {
functional: true,

render(h, {parent, data}) {
console.log(parent, 'parent')
}
}

修改installrotuer-view组件定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// install.js
import RouterView from './components/router-view';

const install = (Vue) => {
- Vue.component('router-view', {
- render() {
- return <div>this is router-view</div>;
- },
- });
+ Vue.component('router-view', RouterView);

};

export default install;

上面代码写完之后,好像一切正常,打印出来也是正常的。接下来就根据我上面说的,他是根据$route.matched来渲染的去实现它吧。

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
export default {
functional: true,

render(h, { parent, data }) {
console.log(parent, 'parent');
const route = parent.$route;
let depth = 0;
// 判断是否渲染过,如果没有渲染过,就渲染对应matched里的组件,并将该组件data.routerView = 1。以达到不会重复渲染。
while (parent) {
if (parent.$vnode && parent.$vnode.data.routerView) {
depth++;
}
parent = parent.$parent;
}

data.routerView = 1;

const record = route.matched[depth];

if (!record) {
return h();
}

return h(record.component, data);
},
};

while的作用是判断是否渲染过,如果没有渲染过,就渲染对应matched里的组件,并将该组件data.routerView = 1。以达到不会重复渲染。刷新下页面看看吧。好像子页面都出来了。

router-link非常的简单。我这里就实现下比较常见的一些操作。比如参数tag,to

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
props: {
tag: {
type: String,
default: 'a',
},
},

render() {
const tag = this.tag;
return <tag>{this.$slots.default}</tag>;
},
};

我这里先写个比较简单的架子。非常的简单将文字展示在tag

然后再修改下install中的引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// install.js
import RouterLink from './components/router-link';

const install = (Vue) => {
- Vue.component('router-link', {
- render() {
- return <div>this is router-link</div>;
- },
- });
+ Vue.component('router-link', RouterLink);

};

export default install;

刷新下页面试试。页面一切展示正常🐶。这就大功告成了吗?当然没有啦,还有router-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
// router-link.js

export default {
props: {
tag: {
type: String,
default: 'a',
},

+ to: {
+ type: String,
+ required: true,
+ },
},

+ methods: {
+ handler() {
+ this.$router.push(this.to);
+ },
+ },

render() {
const tag = this.tag;
- return <tag>{this.$slots.default}</tag>;
+ return <tag onClick={this.handler}>{this.$slots.default}</tag>;
},
};

tag增加点击事件。我上面的例子将to定义成String类型了。其实这个to可以是对象类型。我的例子只要能体现出router-link的功能就行了。

接下来刷新下页面试试看。

报了个错: TypeError: this.$router.push is not a function

原来我push忘记写了。那就在vue-router实例中加一个吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vue-router/index.js

class VueRouter {
constructor(options) {
// ...
this.history = new HashHistory(this);
}

+ push(location) {
+ this.history.transitionTo(location, () => {
+ window.location.hash = location;
+ });
+ }
}

调用下history.transitionTo进行路由的匹配替换,触发router-view渲染后,还有在回调中主动修改下hash地址。

刷新下页面。一切正常。再点击router-link标签。emmmm 一切正常。(重复点击一个link跳转没有处理,我就不处理。我觉得没有必要,我的目的就是能表达我对router的理解就够了)

最后

非常感谢你能读完这篇文章。我已经非常努力写这篇文章了(vue-router写了三遍才开始写文章的)。但是读起来好像还是不是很顺。

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×