前言
vue的知识体系种,router是极其重要的一环,因为这是vue实现单页面应用的基础根基。下面,我们分两步来看vue-router,首先我们先讲解vue-router原理所需要的前置知识,接着我们动手自己实现一个vue-router。
前端路由
在 Web 前端单页应用 SPA(Single Page Application)中,路由描述的是 URL 与 UI 之间的映射关系,这种映射是单向的,即 URL 变化引起 UI 更新(无需刷新页面)。
在vue-router中,可以通过两种方式来实现前端路由的变化,分别为hash和history。我们依次看一下,并且写个例子验证。
hash实现
hash 是 URL 中 hash (#) 及后面的那部分,常用作锚点在页面内进行导航,最重要的是改变 URL 中的 hash 部分不会引起页面刷新。
我们可以通过 hashchange 事件监听 URL 的变化,改变 URL 的方式只有这几种:
我们通过下面的例子,来看下hash实现路由的原理:
<body>
<h3>hash路由变化实例</h3>
<div id="routerView"></div>
<hr>
<ul>
<li><a href="#/home">home</a></li>
<li><a href="#/about">about</a></li>
</ul>
</body>
<script>
window.addEventListener('DOMContentLoaded', () => {
if(!location.hash) {
location.hash = '/';
} else {
routerView.innerHTML = location.hash;
}
});
window.addEventListener('hashchange', () => {
routerView.innerHTML = location.hash;
})
</script>
上面的代码很简单,只要注意一下几点:
- 我们通过a标签的href属性来改变URL的hash值。当然,触发浏览器的前进后退按钮也可以,或者在控制台输入window.location赋值来改变hash都是可以的。也都会触发hashchange。
- 我们监听hashchange事件。一旦事件触发,就改变routerView的内容,若是在vue中,这改变的应当是router-view这个组件的内容。
- 为何又监听了load事件?这时因为页面第一次加载完不会触发 hashchange,因而用load事件来监听hash值,再将视图渲染成对应的内容。
history实现
由于html5标准的发布,history的api增加了两个API。pushState 和 replaceState。通过这两个 API 可以改变 url 地址且不会发送请求。同时还有popstate 事件。通过这些就能用另一种方式来实现前端路由了,但原理都是跟 hash 实现相同的。
用了 HTML5 的实现,单页路由的 url 就不会多出一个#,变得更加美观。但因为没有 # 号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求。为了避免出现这种情况,所以这个实现需要服务器的支持,需要把所有路由都重定向到根页面。
我们主要说几个注意点:
- 通过pushState/replaceState或标签改变 URL 不会触发页面刷新,也不会触发popstate方法。所以我们可以拦截 pushState/replaceState的调用和标签的点击事件来检测 URL 变化,从而触发router-view的视图更新。
- 通过浏览器前进后退改变 URL ,或者通过js 调用history的back,go,forward方法,都会触发 popstate 事件,所以我们可以监听popstate来触发router-view的视图更新。
所以,我们其实是需要监听popstate以及拦截pushState/placeState以及a的点击去实现监听URL的变化。
我们通过下面的例子,来看下history实现路由的原理:
<body>
<h3>history路由变化实例</h3>
<div id="routerView"></div>
<hr>
<ul>
<li><a href="/home">home</a></li>
<li><a href="/about">about</a></li>
</ul>
</body>
<script>
window.addEventListener('DOMContentLoaded', () => {
routerView.innerHTML = location.pathname;
const linkList = document.querySelectorAll('a[href]');
linkList.forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
history.pushState(null,'', el.getAttribute('href'));
routerView.innerHTML = location.pathname;
})
})
});
window.addEventListener('popstate', () => {
routerView.innerHTML = location.pathname;
})
</script>
注意一下几个重点:
- 我们监听popState事件。一旦事件触发(例如触发浏览器的前进后端按钮,或者在控制台输入history,go,back,forward赋值),就改变routerView的内容。
- 我们通过a标签的href属性来改变URL的path值。这里需要注意的就是,当改变path值时,默认会触发页面的跳转,所以需要拦截 标签点击事件的默认行为,这样就阻止了a标签自动跳转的行为, 点击时使用 pushState 修改 URL并更新手动 UI,从而实现点击链接更新 URL 和 UI 的效果。
那么,两种方式实现的路由原理的前置知识,我们基本了解了。由于使用路由,我们还需要使用Vue.use(router)这种方式,所以我们先了解一下Vue.use的原理,以及顺便看看这个源码实现。
Vue.use
使用
Vue.use的使用在官方文档说的很清楚了,我就直接复制了:
//引自官方api
Vue.use( plugin )
参数:
{Object | Function} plugin
用法:
安装 Vue.js 插件。如果插件是一个对象,必须提供 install 方法。
如果插件是一个函数,它会被作为 install 方法。
install 方法调用时,会将 Vue 作为参数传入。
该方法需要在调用 new Vue() 之前被调用。
当 install 方法被同一个插件多次调用,插件将只会被安装一次。
我们需要注意以下几点:
- 注册插件,只需要调用install方法并将Vue作为参数传入即可
- 插件的类型,可以是install方法,也可以是一个包含install方法的对象
- 插件只能被安装一次,保证插件列表中不能有重复的插件(等下看源码就知道原理了)
实例
现在,我们以实现一个自定义的button按钮为例子,来说明Vue.use的具体使用。
首先,我们先写一个简单的YButton.vue,来实现按钮的基础组件:
<template>
<div>
<button class="y-button primary">
<slot></slot>
</button>
</div>
</template>
<script>
export default {
name: 'YButton'
}
</script>
<style lang="scss" scoped>
.y-button {
display: inline-block;
line-height: 1;
cursor: pointer;
border: 1px solid #dcdfe6;
color: #606266;
text-align: center;
outline: none;
padding: 12px 20px;
margin: 0;
transition: all 0.1s;
font-weight: 500;
font-size: 16px;
border-radius: 4px;
}
</style>
接着,我们导出这个组件,并且注册install方法:
import YButton from './YButton.vue';
YButton.install = Vue => {
Vue.component(YButton.name, YButton);
};
export default YButton;
最后,我们在main.js中全局注册这个组件:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import YButton from './components/y-button'
Vue.use(YButton)
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
到此,我们就可以在其他组件中使用y-button这个组件按钮了。
源码解析
现在,我们看看vue的源码中,是如何实现Vue.use的。其实源码特别简单,如下:
// src/core/global-api/use.js
import { toArray } from '../util/index'
export function initUse (Vue: GlobalAPI) {
Vue.use = function (plugin: Function | Object) {
// 获取已经注册过的插件缓存数组
const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
// 如果插件已经注册过,直接终止返回
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
// 将类数组转换成数组,也就是将Vue.use里传递的参数转成数组
const args = toArray(arguments, 1)
// 往参数数组添加第一个参数,这个参数就是Vue实例
args.unshift(this)
// 如果插件是个对象,那么执行插件的install方法,并且将参数数组传入
if (typeof plugin.install === 'function') {
plugin.install.apply(plugin, args)
// 如果插件直接是个函数,那么直接执行插件方法,并且将参数数组传入
} else if (typeof plugin === 'function') {
plugin.apply(null, args)
}
// 将这个插件传入插件缓存数组中,避免第二次再次Vue.install同一个插件
installedPlugins.push(plugin)
return this
}
}
} 上面的源码我已经注释的非常清楚了,代码也比较简单,我们再大致的讲几点:
- 在Vue.js上新增了use方法,并接收一个参数plugin。
- 首先判断插件是不是已经被注册过,如果被注册过,则直接终止方法执行。
- 将类数组转成真正的数组,并且将Vue实例传入数组作为第一项,保证install方法被执行时第一个参数是Vue,其余参数是注册插件时传入的参数。
- 由于plugin参数支持对象和函数类型,所以通过判断plugin.install和plugin哪个是函数,即可知用户使用哪种方式注册的插件,然后执行用户编写的插件并将args作为参数传入。
- 最后,将插件添加到installedPlugins中,保证相同的插件不会反复被注册。
那么,到现在为止,我们对于vue-router的前置知识基本掌握了,接下来,我们自己去动手实现一个vue-router。
vue-router实现
首先,我们当然是通过vue-cli去创建一个脚手架,这一步比较简单,就直接省略了。然后,我们修改App.vue:
<template>
<div id="app">
<div id="nav">
<router-link to="/home">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</div>
</template>
再来看看router,我们稍作修改,尽量简化一些:
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/home',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
}
]
const router = new VueRouter({
routes
})
export default router
至于home组件和about组件,就是非常简单的一句话,就不放出来了。
现在,我们修改router,将VueRouter改为引入我们自己写的router。
import Vue from 'vue'
// 修改代码
import VueRouter from './my-router';
import Home from '../views/Home.vue'
import About from '../views/About.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/home',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
}
]
const router = new VueRouter({
routes
})
export default router
那么,到了这一步,我们前置环境就搭建成功了,现在,我们需要去实现自己的vue-router了。
刨析本质
我们在仔细看看vue是如何引入vue-router的。
- 首先,我们通过import VueRouter from 'vue-router'引入vue-router。
- 然后,通过Vue.use(VueRouter)去触发了vue-router中的install方法。
- 最后,const router = new VueRouter({...}),再把router作为参数的一个属性值,new Vue({router})。
我们看看这三步,特别是最后一步我们是new出来的一个vue-router的实例,所以,我们可以预见,VueRouter应该是一个类。所以我们这样写:
class VueRouter {
}
然后通过Vue.use()我们知道,VueRouter应该有个install方法,并且第一个参数应该是Vue实例。
// 单例
let Vue = null;
class VueRouter {
};
VueRouter.install = (v) => {
Vue = v;
}
大体框架出来了,我们把vue-router这个类导出去:
// 单例
let Vue = null;
class VueRouter {
};
VueRouter.install = (v) => {
Vue = v;
};
export default VueRouter;
加载组件
vue-router还自带了两个组件,分别是router-link和router-view,这两个组件的功能就不多说了。那这两个组件是什么时候加载的呢。
答案就是在Vue.use(VueRouter)的时候加载的,换句话说,也就是在Install中加载的。所以我们继续完善:
// 单例
let Vue = null;
class VueRouter {
};
VueRouter.install = (v) => {
Vue = v;
// 新增代码
Vue.component('router-link', {
render(h) {
return h('a', {}, 'home')
},
});
Vue.component('router-view', {
render(h) {
return h('div', {}, 'home视图')
}
})
};
export default VueRouter;
这个时候,我们的脚手架应该可以跑起来了,并且显示home视图。
加载$router
Vue.install中除了加载router-link和router-view这两个组件以外,还有很重要的一个功能。那就是加载$router
和$route
。
首先,我们需要明白,$router
和$route
有什么关系呢,又有什么区别的?(后面还会详细说明)
答案很简单,$router
是VueRouter的实例对象,也就是我们刚刚new VueRouter()这个实例。$route
是当前路由对象,也就是说$route
是$router
的一个属性。
通过main.js中的new Vue({router}),我们可以把router实例(也就是刚刚new出来的)挂载到根组件的$options
中。可是我们发现,我们在每个实例组件,都可以通过this.$router
访问到router实例。而这个,就是在install中实现的。
// 单例
let Vue = null;
class VueRouter {
};
VueRouter.install = (v) => {
Vue = v;
// 新增代码
Vue.mixin({
beforeCreate() {
// 如果是根组件
if (this.$options && this.$options.router) {
// 将根组件挂载到_root上
this._root = this;
this._router = this.$options.router
} else { // 如果是子组件
// 将根组件挂载到子组件的_root上
this._root = this.$parent && this.$parent._root;
}
}
});
// 定义$router
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._root._router;
}
})
Vue.component('router-link', {
render(h) {
return h('a', {}, 'home')
},
});
Vue.component('router-view', {
render(h) {
return h('div', {}, 'home视图')
}
})
};
export default VueRouter;
解释一下上面的代码:
- 首先,mixin的作用是将mixin的内容混合到Vue的初始参数options中。
- 为什么是beforeCreate而不是created呢?因为如果是在created操作的话,$options已经初始化好了。
- 在beforeCreate中,我们判断如果是根组件,我们将传入的实例和router分别绑定到_root和_router上。
- 如果是子组件,我们就去递归读取到根组件,绑定到_root上。
- 我们为vue的原型对象,定义
$router
,然后返回值是_root(根组件)的_router。
那么,我们还有一个问题,为什么判断当前组件是子组件,就可以直接从父组件拿到_root根组件呢?
这就需要我们了解父子组件的渲染顺序了,其实很简单,直接列出来了:
父beforeCreate-> 父created -> 父beforeMounte -> 子beforeCreate ->子create ->子beforeMount ->子 mounted -> 父mounted 可以看到,在执行子组件的beforeCreate的时候,父组件已经执行完beforeCreate了,那理所当然父组件已经有_root了。
那么至此,我们$router
已经差不多了,$route由于需要完善构造器,所以我们暂时先略过。
完善构造器
首先,我们看看,我们在new VueRouter的时候,传入了什么参数:
const router = new VueRouter({
mode: 'history',
routes
})
可以看到,暂时传入了两个,一个是mode,还有一个是routes数组。因此,我们可以这样实现构造器。
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
}
};
由于直接处理数组比较不方便,所以我们做一次转换,采用path为key,component为value的方式。
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
// 新增代码
this.routesMap = this.changeMap(this.routes);
};
// 新增代码
changeMap(routes) {
return routes.reduce((pre, next) => {
pre[next.path] = next.component;
return pre;
},{})
}
};
接下来,我们还需要在vue-router的实例中保存当前路径(在包含一些例如params信息,其实就是$route
),所以我们为了方便管理,使用一个对象来表示:
// 单例
let Vue = null;
class HistoryRoute {
constructor() {
this.current = null;
}
}
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
this.routesMap = this.changeMap(this.routes);
// 新增代码
this.history = new HistoryRoute();
};
changeMap(routes) {
return routes.reduce((pre, next) => {
pre[next.path] = next.component;
return pre;
},{})
}
};
这个时候,我们的history对象中,就保存了关于当前路径的属性current。
只是,我们这个时候的current还是null,所以,我们需要做初始化操作。
在初始化的时候,我们需要判断当前是什么模式,然后将当前路径保存到current中。
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
this.routesMap = this.changeMap(this.routes);
this.history = new HistoryRoute();
// 新增代码
this.init();
};
// 新增代码
init() {
// 如果是hash模式
if (this.mode === 'hash') {
location.hash ? void 0 : location.hash = '/';
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1);
});
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1);
})
};
// 如果是history模式
if (this.mode === 'history') {
location.pathname ? void 0 : location.pathname = '/';
window.addEventListener('load', () => {
this.history.current = location.pathname;
});
window.addEventListener('popstate', () => {
this.history.current = location.pathname;
})
}
}
changeMap(routes) {
return routes.reduce((pre, next) => {
pre[next.path] = next.component;
return pre;
},{})
}
};
完善$route
现在,我们已经在构造器中根据不同模式,将路径赋值给 history的current,那么,我们现在就可以给$route
赋值了,当然this.$route
也就是current。
VueRouter.install = (v) => {
Vue = v;
Vue.mixin({
beforeCreate() {
// 如果是根组件
if (this.$options && this.$options.router) {
// 将根组件挂载到_root上
this._root = this;
this._router = this.$options.router
} else { // 如果是子组件
// 将根组件挂载到子组件的_root上
this._root = this.$parent && this.$parent._root;
}
}
});
// 定义$router
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._root._router;
}
});
// 新增代码
// 定义$route
Object.defineProperty(Vue.prototype, '$route', {
get() {
return this._root._router.history.current
}
})
Vue.component('router-link', {
render(h) {
return h('a', {}, 'home')
},
});
Vue.component('router-view', {
render(h) {
return h('div', {}, 'home视图')
}
})
};
代码极其简单,就是在vue的原型链上再次定义了一个$route,指向current即可。
完善router-view
我们已经通过current保存到了当前的路径,那么我们现在就可以根据当前的路径,拿到对应的component,然后,将这个component渲染出来就好了。
Vue.component('router-view', {
render(h) {
// 新增代码
const current = this._root._router.history.current;
const routesMap = this._root._router.routesMap;
return h(routesMap[current]);
}
});
但是这个时候,我们页面是渲染不出来的,原因在于,current并不是响应式的,我们拿到的current是最初的,也就是null。所以,我们需要把current变成响应式,只要路径变化,我们的router-view就必须跟着变化。
我们需要用到Vue中的一个工具方法,这个方法很简单,就是把变量变成响应式。那么在什么时候去把current变成响应式呢,其实很多地方都可以,例如在第一次定义history的时候,或者在根组件渲染出来$router的时候,我们就在第一次定义history的时候去改变。
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
this.routesMap = this.changeMap(this.routes);
// this.history = new HistoryRoute();
// 新增代码
// 将history变成响应式的数据
Vue.util.defineReactive(this,"history",new HistoryRoute());
this.init();
};
其实这个方法的原理很简单,这里简单说一下,当我们第一次渲染router-view这个组件的时候,会获取到this._root._router.history这个对象,这个时候,this._root._router.history的依赖收集器(Dep)就会将router-view的watcher收集到它的subs中。所以this._root_router.history每次改变时,this._root._router.history对应的收集器dep就会通知router-view的组件依赖的wacther执行update(),从而使得router-view重新渲染。
到了这一步,你的脚手架应该能够渲染出正确的页面了。
完善router-link
首先,我们应该比较清楚router-link的使用。
<router-link to="/home">Home</router-link> |
<router-link to="/about">About</router-link>
很显然,父组件将to这个路径传进去,子组件接收。所以我们实现代码如下:
Vue.component('router-link', {
props:{
to:String
},
render(h) {
// 新增代码
const mode = this._root._router.mode;
let to = mode === "hash" ? "#" + this.to : this.to;
return h('a',{
attrs: {
href:to
}
},this.$slots.default)
},
});
这个时候,我们把router-link渲染成了a标签,到这里,你们脚手架应该点击router-link就能切换相应的组件了。
但是,相信走到这一步之后,大家点击页面发现了问题。每次切换组件,页面都会刷新一下,这是很容易理解的,因为我们只是通过a标签跳转了相应的路由。那么,我们应该怎么做才能实现完整的router呢,回顾一下最前面history模式我们所说的,我们需要去拦截a标签的默认事件,然后通过pushState去手动改变url路径。所以,我们再次改动router-link代码:
Vue.component('router-link', {
props:{
to:String
},
render(h) {
const mode = this._root._router.mode;
let to = mode === "hash" ? "#" + this.to : this.to;
return h('a',{
attrs: {
href:to
},
// 新增代码
class: 'router-link-to'
},this.$slots.default)
},
});
我们这个时候,只是为router-link增加了class,代表这个是router-link的a标签。
那么,我们还需要去拦截这些a标签,我们应该去哪拦截呢?其实很多地方,因为事件委托的便利性,我们可以在任意时间去拦截。所以,我们就在加载根组件的时候进行拦截:
Vue.mixin({
beforeCreate() {
// 如果是根组件
if (this.$options && this.$options.router) {
// 将根组件挂载到_root上
this._root = this;
this._router = this.$options.router;
// 新增代码
// 拦截router-link
this._router.mode === 'history' && document.addEventListener('click', e => {
if (e.target.className === 'router-link-to') {
// 阻止默认跳转事件
e.preventDefault();
// 手动改变url路径
history.pushState(null,'', e.target.getAttribute('href'));
// 为current赋值url路径
this._router.history.current = location.pathname;
}
})
} else { // 如果是子组件
// 将根组件挂载到子组件的_root上
this._root = this.$parent && this.$parent._root;
}
}
});
上面代码拦截比较简单,解释一下:
- 判断如果是router-link的a标签,并且是history模式,那么就阻止默认跳转事件。
- 通过history.pushState方法去手动的改变url的路径,这个方法改变url不会刷新页面,这个很重要。
- 当然这样改变url也不会触发popstate方法,所以我们手动给current赋值。
- 因为我们的history已经是动态响应的了,所以很自然,这个时候router-view里面的组件也就更新了。
到此,我们对于vue-router的实现基本完成了,附上全部的vue-router的代码:
// 单例
let Vue = null;
class HistoryRoute {
constructor() {
this.current = null;
}
}
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
this.routesMap = this.changeMap(this.routes);
// this.history = new HistoryRoute();
// 将history变成响应式的数据
Vue.util.defineReactive(this,"history",new HistoryRoute());
this.init();
};
init() {
// 如果是hash模式
if (this.mode === 'hash') {
location.hash ? void 0 : location.hash = '/';
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1);
});
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1);
})
};
// 如果是history模式
if (this.mode === 'history') {
location.pathname ? void 0 : location.pathname = '/';
window.addEventListener('load', () => {
this.history.current = location.pathname;
});
window.addEventListener('popstate', () => {
this.history.current = location.pathname;
})
}
}
changeMap(routes) {
return routes.reduce((pre, next) => {
pre[next.path] = next.component;
return pre;
},{})
}
};
VueRouter.install = (v) => {
Vue = v;
Vue.mixin({
beforeCreate() {
// 如果是根组件
if (this.$options && this.$options.router) {
// 将根组件挂载到_root上
this._root = this;
this._router = this.$options.router;
// 拦截router-link
this._router.mode === 'history' && document.addEventListener('click', e => {
if (e.target.className === 'router-link-to') {
// 阻止默认跳转事件
e.preventDefault();
// 手动改变url路径
history.pushState(null,'', e.target.getAttribute('href'));
// 为current赋值url路径
this._router.history.current = location.pathname;
}
})
} else { // 如果是子组件
// 将根组件挂载到子组件的_root上
this._root = this.$parent && this.$parent._root;
}
}
});
// 定义$router
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._root._router;
}
});
// 定义$route
Object.defineProperty(Vue.prototype, '$route', {
get() {
return this._root._router.history.current
}
})
Vue.component('router-link', {
props:{
to:String
},
render(h) {
const mode = this._root._router.mode;
let to = mode === "hash" ? "#" + this.to : this.to;
return h('a',{
attrs: {
href:to
},
class: 'router-link-to'
},this.$slots.default)
},
});
Vue.component('router-view', {
render(h) {
const current = this._root._router.history.current;
const routesMap = this._root._router.routesMap;
return h(routesMap[current]);
}
});
};
export default VueRouter;