手写一个简易的vue-router插件

前言

本文根据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地址发生了变化,但是不用刷新整个页面去实现局部页面的无感刷新,用户感觉是在不同的两个页面,但实际上是在同一个页面。
我们需要考虑两个问题:

  1. 保证url地址改变了,但是页面不能刷新;
  2. 如何去监听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插件

使用路由插件.png
这个便是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()

未命名文件.png
接着在根实例的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()

new VueRouter().png
实例化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();

        })
        
    }
复制代码

效果

vueRouter-show.gif

项目地址:mini-vue-router

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享