前言
本文根据vue-router源码的思路手写了一个“山寨版”的vue-router,采用hash模式去编写代码,主要实现了一些核心特性,有时间也可以继续迭代代码。
根据vue-router的使用方式去分析
我们会引入vue-router包,然后使用Vue.use(VueRouter),说明vue-router是一个插件,那它自然应该拥有一个install()方法。然后用户可以配置一个路由表对象,然后传入这个路由表对象作为参数实例化了VueRouter,将得到的实例化对象传到Vue根实例的选项上。
import VueRouter from 'vue-router';
import Vue from 'vue';
import Home from '../views/Home';
import Setting from '../views/Setting';
const routes = [
{
path: '/',
component: Home,
},{
path: '/index',
component: Home,
children: [{
path: 'a',
component: {
render: h => h('p', 'aa'),
}
}]
},{
path: '/setting',
component: Setting,
}
];
let router = new VueRouter({
routes,
mode: 'hash',
})
Vue.use(VueRouter);
export default router;
复制代码
import Vue from 'vue';
import App from './App.vue';
import router from './router/index'
window.vm = new Vue({
el: '#app',
render: h => h(App),
router,
})
复制代码
我们可以在模版上使用关于路由的一些组件:router-link、router-view,说明vue-router还注册了全局组件。
<template>
<div>
<router-link to="/index">首页</router-link>
|
<router-link to="/setting">设置</router-link>
<router-view></router-view>
</div>
</template>
复制代码
除此之外,我们还可以在任意组件访问到$router
和$route
,所以这两个属性应该是被放到Vue的原型上,并且是只读属性,所以应该是使用object.defineProperty()代理到Vue的原型上。
console.log(this.$router);
console.log(this.$route);
复制代码
前端路由实现原理
前端路由主要指url地址发生了变化,但是不用刷新整个页面去实现局部页面的无感刷新,用户感觉是在不同的两个页面,但实际上是在同一个页面。
我们需要考虑两个问题:
- 保证url地址改变了,但是页面不能刷新;
- 如何去监听url地址改变。
一般有两种模式去实现,hash和history。(本文只考虑实现hash模式)
hash
Hash 模式其实就是通过改变 URL 中 # 号后面的 hash 值来切换路由,因为在 URL 中 hash 值的改变并不会引起页面刷新,再通过 hashchange 事件来监听 hash 的改变从而控制页面组件渲染。
history
它提供了 pushState 和 replaceState 两个方法,使用这两个方法可以改变 URL 的路径还不会引起页面刷新,同时它也提供了一个 popstate 事件来监控路由改变,但是 popstate 事件并不像 hashchange 那样改变了就会触发。
- 通过浏览器前进后退时改变了 URL 会触发 popstate 事件
- js 调用 historyAPI 的 back、go、forward 等方法可以触发该事件
vue-router插件
这个便是vue-router插件的基本结构。
import install from "./install";
class VueRouter{
constructor(options){
let routes = options.routes || [];
}
}
VueRouter.install = install;
export default VueRouter
复制代码
install()
Vue.use(VueRouter)后首先会调用VueRouter的install方法,并且传入Vue作为参数。在这里注册了全局组件router-link和router-view。使用Vue.mixin混入保证每个组件都可以访问到_routerRoot属性,也就是根节点,因为初始router是放在根实例选项上的,我们需要借助根实例去实现每个组件都可以访问到router。利用VUe先父后子的渲染原则,让子去获取父的_routerRoot属性,就可以做到每个组件都能访问_routerRoot。
import RouterLink from './components/router-link'
import RouterView from './components/RouterView'
export let Vue = null;
const install = function(_Vue){
// install 方法内部通常定义一些全局的内容,如全局组件、全局指令、全局混入还有vue原型方法
Vue = _Vue;
Vue.component('router-link', RouterLink);
Vue.component('router-view', RouterView);
// 让每个组件都可以获取到router
Vue.mixin({
beforeCreate(){
// 初始router放在根实例选项上,因此初始能访问到router的是根实例
if(this.$options.router){
// 根
this._routerRoot = this;
this._router = this.$options.router;
this._router.init(this); // 传入根实例 调用插件的初始方法
}else{
// 儿子
this._routerRoot = this.$parent && this.$parent._routerRoot;
}
// 最后所有的组件都能通过属性_routerRoot访问到根实例
}
})
}
export default install;
复制代码
router-link组件
这个组件主要是用来跳转路由,to可以指定目标位置,tag表示需要将该组件渲染为什么标签。实际上就是利用了$router.push()跳转方法,这个方法是可以在任意组价中使用的。
export default {
name: 'router-link',
props: {
to: {
type: String,
required: true,
},
tag: {
type: String,
},
},
render(h){
let tag = this.tag || 'a';
return h(tag, {
on: {
click: this.clickHandler,
},
attrs: {
href: this.$router.mode == 'hash' ? "#"+this.to : this.to,
}
}, this.$slots.default);
},
methods: {
clickHandler(e){
e.preventDefault();
this.$router.push(this.to);
}
}
}
复制代码
router-view组件
router-view组件只是一个占位符,不需要用到数据、不需要用到生命周期、不需要实例化组件,因此考虑采用函数式组件来实现该组件,函数式组件不用实例化组件构造器函数、没有this、没有生命周期、没有数据data,所以函数式组件的性能更优。
export default {
name: 'router-view',
functional: true,
render(h, context){
// context: 函数式组件渲染上下文
// this.$route 有matched属性 这个属性有几个内容就依次的将它赋予到对应的router-view上(嵌套路由才会有多个)
let {parent, data} = context;
// parent 是当前父组件
// data 是这个组件上的一些标识
let route = parent.$route;
let depth = 0;
data.routerView = true; // 标识路由属性,然后当前组件的$vnode.data.routerView就为true
// 找到当前router-view是位于第几层的子路由
while(parent){
if(parent.$vnode && parent.$vnode.data.routerView){
depth++;
}
parent = parent.$parent;
}
// 注意$vnode和_vnode的区别
let record = route.matched[depth];
if(!record){
return h();
}
return h(record.component, data)
}
}
复制代码
$router
和$route
属性
const install = function(_Vue){
...
// 使用defineProperty保证它是只读的
Object.defineProperty(Vue.prototype, '$route', {
get(){
return this._routerRoot._route; // 当前匹配的记录
}
})
Object.defineProperty(Vue.prototype, '$router', {
get(){
return this._routerRoot._router; // 路由实例
}
})
}
复制代码
init()
接着在根实例的beforeCreate钩子里面会传入根实例作为参数执行_router.init(vm),因为我们在初始化VueRouter的时候会用到根实例。这里主要是将页面跳转到路由初始位置,并且给根实例添加_route属性,当当前路由对象发生改变时,app._route也会更新,同时设置路由监听。在根实例摧毁后撤销路由监听。
class VueRouter{
constructor(options){
let routes = options.routes || [];
// 创建历史管理 (两种模式:hash、history)
this.mode = options.mode || 'hash';
switch(this.mode){
case 'hash':
this.history = new HashHistory(this);
break;
case 'history':
this.history = new BrowserHistory(this);
break;
}
init(app){ // 这个app指代根实例
// 需要根据当前路径 实现一下初始页面跳转的逻辑
const history = this.history;
// 跳转路径 会进行匹配操作 根据路径获取对应的记录
let setupHashListener = () => {
history.setupListener();
}
// 跳转路径 进行监控
history.transitionTo(history.getCurrentLocation(), setupHashListener())
history.listen((route) => {
app._route = route;
})
// transitionTo 跳转逻辑 hash \ browser 都有
// getCurrentLocation hash \ browser 实现不一样
// setupListener hash 监听
app.$once('hook:destroy', this.history.teardown);
}
}
复制代码
new VueRouter()
实例化VueRouter的时候只做了两件事:
- 创建匹配器;
- 创建历史管理对象。
会将用户传的路由表对象生成路由映射表,路由表只是方便用户使用,而路由映射表才是我们正真需要的。用户如果采用嵌套路由的话,那么用户提供的路由表将是树形结构的,对于我们来说,还需要将路由表进一步转变成{key: value}
结构的映射表。
class VueRouter{
constructor(options){
let routes = options.routes || [];
// 创建匹配器的过程 1、匹配功能 match 2、可以添加匹配 动态路由添加 addRoutes 权限
this.matcher = createMatcher(routes);
// 创建历史管理 (两种模式:hash、history)
this.mode = options.mode || 'hash';
switch(this.mode){
case 'hash':
this.history = new HashHistory(this);
break;
case 'history':
this.history = new BrowserHistory(this);
break;
}
this.beforeHooks = [];
}
}
复制代码
createMatcher()
主要是要根据路由表对象生成一个pathList数组,还有一个pathMap对象。
export default function createMatcher(routes){
// pathList 会把所有的路由组成一个数组 ['/', '/idnex', '/index/a', '/setting']
// pathMap {/: {}, /index: {}, /setting: {}}
// pathList, pathMap构成了闭包引用
let {pathList, pathMap} = createRouteMap(routes);
// 路由匹配 通过用户输入的路径,获取对应的匹配记录
function match(location){
let record = pathMap[location]; // 获取对应记录
return createRoute(record, {
path: location
})
}
// 动态添加路由(参数必须是一个符合 routes 选项要求的数组)
function addRoutes(routes){
// 把传入的新路由对象解析后加入到老 pathMap 对象里
createRouteMap(routes, pathList, pathMap)
}
// 动态添加路由(添加一条新路由规则)
function addRoute(){ }
// 获取所有活跃的路由记录列表
function getRoutes(){
return pathMap;
}
return {
match,
addRoutes,
addRoute,
getRoutes,
}
}
复制代码
在createRouteMap()的时候,它还能接受oldPathList\oldPathMap参数,主要是为了方便后续的动态添加路由,如果不传这两参数的话,默认为初始化路由映射表。在addRouteRecord()处理了嵌套路由的情况,它会遍历子路由然后进行递归,通过record上的parent属性找到父路由。
const addRouteRecord = function(route, pathList, pathMap, parentRecord){
let path = parentRecord ? `${parentRecord.path}/${route.path}` : route.path;
// 根据当前的路由产生一条记录
let record = {
path,
component: route.component,
parent: parentRecord,
}
// 防止用户编写路由时有重复的
if(!pathMap[path]){
pathMap[path] = record;
pathList.push(path);
}
// 将子路由也放到对应的pathList,pathMap
if(route.children){
route.children.forEach(r => {
addRouteRecord(r, pathList, pathMap, record)
})
}
}
export function createRouteMap(routes, oldPathList, oldPathMap){
let pathList = oldPathList || [];
let pathMap = oldPathMap || [];
routes.forEach(route => {
addRouteRecord(route, pathList, pathMap);
});
return {
pathList,
pathMap,
}
}
复制代码
history对象
为了方便用户使用,无论使用哪种模式实现的历史管理,都应该保证他们大部分的方法是相同的,只是实现方式上有所差异。共同的逻辑写到一个父类上,子类再实现各自不同的逻辑。,例如transitionTo()方法写在父类上,而getCurrentLocation()、setupListener()方法在每个子类上的实现方式不同,由子类各自实现。
Base父类
current是整个VueRouter的核心,后续会将它设置为响应式数据,更新页面就靠它了。
export default class History{
constructor(router){
this.router = router;
// 代表当前路径匹配出来的记录 {path: '/', matched: []} (vue-router的核心了)
this.current = createRoute(null, {
path: '/'
})
}
// 大致做三件事:1.更新当前路由对象;2.更新url;3.更新组件渲染
transitionTo(location, complete){
// 通过路径拿到对应的记录 有了记录之后 就可以找到对象的匹配
let current = this.router.match(location)
// 匹配的个数和路径都是相同的,不需要再跳转了
if(this.current.path === location && this.current.matched.length === current.matched.length){
return;
}
// 用最新的匹配到的结果,去更新视图
this.current = current; // 这个current只是改变当前的,他的变化不会更新_route
this.cb && this.cb(current); // 在cb里面会将app._route改变,就会触发响应式,视图就会更新
// 当路径变化后,current属性会进行更新操作
complete && complete();
}
listen(cb){
this.cb = cb;
}
}
复制代码
根据路径和当前记录创建一个完整匹配对象,主要是为了处理嵌套路由的情况,它需要根据当前记录找到父路由记录。例如路径/setting/user
,你不能说只渲染子路由user对应的组件就行了,肯定还需要找到setting路由对应的组件。
export const createRoute = (record, location) => {
let matched = [];
if(record){
while(record){
matched.unshift(record);
record = record.parent; // 通过当前记录找到所有父亲
}
}
return {
...location,
matched
}
}
复制代码
HashHistory
这里主要就做了两件事:获取当前位置;监听路由变化。
// 确保url地址有 '/' hash值
const ensureSlash = () => {
if(window.location.hash){
return;
}
window.location.hash = '/';
}
export default class HashHistory extends History{
constructor(router){
super(router);
this.router = router;
this.teardownListenerQueue = [];
ensureSlash();
}
getCurrentLocation(){
return window.location.hash.slice(1);
}
setupListener(){
const hashChagneHandler = () => {
console.log('hash变化')
// 再次执行匹配操作
this.transitionTo(this.getCurrentLocation())
};
window.addEventListener('hashchange', hashChagneHandler);
this.teardownListenerQueue.push(() => {
window.removeEventListener('hashchange', hashChagneHandler);
})
}
// 卸载 (方便垃圾回收)
teardown(){
this.teardownListenerQueue.forEach(fn => {
fn();
});
this.teardownListenerQueue = [];
}
push(location){
window.location.hash = location;
}
}
复制代码
更新页面
页面更新很简单,只需要在根实例中设置_route
属性为响应式数据,借助vue的响应式系统实现页面更新。渲染watcher会收集_route
对应的dep依赖,当这个依赖也就是_route
发生改变时,依赖会通过notify()通知它相应的watcher执行,其中包含渲染watcher也会执行,渲染watcher执行完毕,页面就更新了。
const install = function(_Vue){
Vue.mixin({
beforeCreate(){
if(this.$options.router){
// 根
this._routerRoot = this;
this._router = this.$options.router;
this._router.init(this); // 传入根实例 调用插件的初始方法
Vue.util.defineReactive(this, '_route', this._router.history.current)
}else{
// 儿子
this._routerRoot = this.$parent && this.$parent._routerRoot;
}
}
})
...
}
复制代码
路由钩子
这里只实现了一个钩子beforeEach(),所以也是只针对它进行讲解。将所有的钩子放到beforeHooks数组里面,这面的钩子将会依次执行。
class VueRouter{
constructor(options){
...
this.beforeHooks = [];
}
...
beforeEach(fn){
this.beforeHooks.push(fn);
}
}
复制代码
改写路由父类里面的transitionTo方法,在实现路由跳转之前会先执行完毕beforeEach钩子函数。runQueue()的时候可以理解为这是一种闭包加循环的写法,它只在执行完当前钩子后才去执行下一个钩子,这也是路由钩子beforEach()里面必须调用next()方法的原因,你不调用next(),它就不往下走了。
transitionTo(location, complete){
...
// 调用钩子函数
let queue = this.router.beforeHooks;
const iterator = (hook, next) => {
hook(current, this.current, next);
}
const runQueue = (queue, iterator, complete) => {
function next(index){
if(index >= queue.length){
return complete();
}
let hook = queue[index];
iterator(hook, () => {
next(index+1);
})
}
next(0);
}
runQueue(queue, iterator, () => {
// 用最新的匹配到的结果,去更新视图
this.current = current; // 这个current只是改变当前的,他的变化不会更新_route
this.cb && this.cb(current); // 在cb里面会将app._route改变,就会触发响应式,视图就会更新
// 当路径变化后,current属性会进行更新操作
complete && complete();
})
}
复制代码
效果
项目地址:mini-vue-router