这个月会实现一下Vue, Vuex, 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( '../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);
|
Vue在use VueRouter的时候会调用VueRouter的install方法,并传入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
| 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; } }, });
Object.defineProperty(Vue.prototype, '$route', { get() { return 'this is $route'; }, });
Object.defineProperty(Vue.prototype, '$router', { get() { return this._routerRoot._router; }, });
Vue.component('router-view', { render() { return <div>this is router-view</div>; }, }); 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-view、router-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
|
function createMatcher(routes) { function addRoute() {}
function match() {}
return { addRoute, match, }; }
export default createMatcher;
|
match
接下来就是实现match方法了。前面说过match是用来匹配path所对应的route对象。所以他肯定有个path参数,然后通过一个路由映射表来筛选出对应的route对象。那就开始着手编码吧!
1 2 3 4
| function match(location) { return pathMap[location] }
|
这个时候你肯定会有个疑惑,这个pathMap哪里来的呢?上面我已经说过pathMap是一个路由映射表。那就开始着手编码吧!
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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
| 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
| 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方法,并传入route。addRouteRecord根据传入的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
| 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
| function addRoute(routes) { createRouteMap(routes, pathList, pathMap); }
|
你会发现这里的createRouteMap增加了2个参数,所以createRouteMap方法就需要去兼容了。那么我们就开始动手吧!
1 2 3 4 5 6 7 8 9
|
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
| 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
| 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
| 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,那么这个方法是干嘛的呢?用于路由的跳转,根据传入的path从pathMap中筛选出对应的route,这个方法会触发下面讲到的listen方法。这个方法触发后,会修改根实例的_route。修改之后,router-view就会响应式的改变,以达到刷新路由渲染页面的目的。因为调用transitionTo方法会有多种途径。一种是主动调用push方法等,需要主动修改浏览器地址栏hash值,一种是页面初始化调用,这个时候又需要监听hashchange等事件,所以transitionTo增加第二个参数用于回调。这样每个调用transitionTo后,可执行自己的逻辑。
话不多说上代码
1 2 3 4 5 6 7 8 9 10 11 12
| export default class History { constructor(router) { + this.router = router }
+ transitionTo(location, callback) { + const r = this.router.match(location) + console.log(r) + callback && callback() + } }
|
上面简短的代码是不是就实现了上面描述中的几个功能了。
还有两点功能没实现。
我们先来讲怎么响应式刷新。那么怎样才能实现router-view响应式的刷新呢?我们根据倒推的模式,router-view是根据根实例的_route做刷新的。所以需要增加个current对象用来表示当前路由。上代码🐶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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
|
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
| 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进行处理,返回个包含path、matched字段。matched字段包含了,从匹配到的一级路由一直到最后一级路由。router-view也是根据这一数组进行父组件到子组件的渲染的。match方法中也用到的这个方法。下面再讲match方法。接下来我们就来实现createRoute方法。
1 2 3 4 5 6 7 8 9 10 11 12 13
| export function createRoute(record, location) { const matched = []; while (record) { matched.unshift(record); record = record.parent; }
return { matched, ...location, }; }
|
第一个参数record其实就是上文createRouteMap中addRouteRecord使用到的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
| class VueRouter { constructor(options) { this.matcher = createMatcher(options.routes); }
match(location) { return this.matcher.match(location); }
}
|
提醒下,这里的matcher上面讲过了。用来返回match跟addRoute方法。
然后再完善下上面写过的createMatcher
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| + 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; > }); } }
|
init中history.listen其实也是非常的简单,就是添加订阅。当调用transitionTo后,触发下订阅的事件。并传入location对应的route。话不多说,上代码🐶
1 2 3 4 5 6 7 8 9 10 11 12
| 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怎么实现。原来它是根据$route的matched匹配到的组件进行层层渲染的。
举个🌰,就举上面打印出来的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
| export default { functional: true,
render(h, {parent, data}) { console.log(parent, 'parent') } }
|
修改install中rotuer-view组件定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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; 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
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
| 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
|
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
|
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写了三遍才开始写文章的)。但是读起来好像还是不是很顺。