删繁就简,手写vue-router核心源码

786 阅读7分钟

前端路由发展:从多页面应用到单页面应用

多页面应用:最开始的网页是多页面的,一个完整的网页应用有多个完整的html构成,通过a标签对应到不同url,服务器端来根据URL的不同返回不同的页面,那些页面在服务端都是实实在在存在的。

这个时候前端能做的事情很有限,前后端还不能完全分离,直到 Ajax 的出现,前端能够胜任更多更复杂的事,前后端的职责越来越清晰,在业务不断发展的过程中,由于前端项目变得越来越复杂,所以我们要考虑拆分出前端应用部分,使之成为一个能独立开发、运行的应用,而非依赖于后端渲染出HTML的多页面应用。单页面应用应运而生。

单页面应用,顾名思义就是一个WEB项目只有一个 HTML 页面,一旦页面加载完成,SPA 不会因为用户的操作而进行页面的重新加载或跳转。 取而代之的是利用 JS 动态的变换 HTML 的内容,从而来模拟多个视图间跳转。

说到底路由是根据不同的URL来展示不同的内容或页面,而路由的本质 就是建立起url和页面之间的映射关系。

发送请求到渲染页面的过程:
多页面应用由后端控制路由,即后端路由:

单页面应用由前端控制路由,即前端路由:

前端路由实现原理

单页面应用(SPA)的核心之一是: 更新视图而不重新请求页面。

简单的说,就是在保证只有一个html页面,且与用户交互时不刷新和跳转页面的同时,为SPA中的每个视图展示形式匹配一个特殊的url,在刷新,前进,后退,和seo时均通过这个特殊的url来实现。

为实现这一目标,我们需要做到以下二点:

  1. 改变url且不让浏览器向服务器发送请求
  2. 可以监听到url变化。

为了达到这一目的,浏览器提供了 hash 和 history 两种模式。

两种路由模式

hash

hash指的是出现在#号后面的内容,虽然出现在 URL 中,但不会被包含在 http 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面。hash的改变会触发hashchange事件,浏览器的前进后退也能对其进行控制,所以在h5的history模式出现之前,基本都是使用hash模式来实现前端路由。

相关api

window.location.hash = 'hash字符串'; // 用于设置 hash 值

let hash = window.location.hash; // 获取当前 hash 值

// 监听hash变化,点击浏览器的前进后退会触发
window.addEventListener('hashchange', function(event){ 
    let newURL = event.newURL; // hash 改变后的新 url
    let oldURL = event.oldURL; // hash 改变前的旧 url
},false)
history

在 HTML5 之前,浏览器就已经有了 history 对象。但在早期的 history 中只能用于多页面的跳转,正如早期我们编写路由时,总会用到如下api控制页面跳转。

history.go(-1);       // 后退一页
history.go(2);        // 前进两页
history.forward();     // 前进一页
history.back();      // 后退一页

在 HTML5 的规范中,history 新增了以下几个 API:

history.pushState();         // 添加新的状态到历史状态栈
history.replaceState();      // 用新的状态代替当前状态
history.state                // 返回当前状态对象

关于History API,具体可参考MDN

由于 history.pushState() 和 history.replaceState() 可以改变 url 同时,不会刷新页面,即浏览器不会立即向后端发送请求。所以在 HTML5 中的 histroy 具备了实现前端路由的能力。

那么如何监听url的这种改变呢?需要先理解一下 onpopstate 这个 API

后端配置支持

通常在单页面应用中使用history模式路由,需要后台配置支持。因为在单页客户端应用中,如果后台没有正确的配置,当用户在浏览器直接访问oursite.com/user/id,默认会由服务器检索这个文件,检索不到就会返回 404,这并不是我们想要的。 所以呢,你要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是 app 依赖的页面。

 // nginx配置示例
 location / {
   try_files $uri $uri/ /index.html;
 }

总结起来hash和history模式,都能够在不向服务端发送请求的前提下改变路由,并且能通过响应api监听路由变化,都具备了实现前端路由的能力,主要的区别如下:

路由模式当前路由设置路由监听路由变化
hashlocation.hashlocation.hash = 'aaa'onhashchange
historylocation.pathnamepushState/replaceStateonpopstate

vue-router示例

现代前端项目多为单页Web应用(SPA),在单页Web应用中路由是其中的重要环节。每个现代前端框架都有与之对应的路由实现,例如 vue-router、react-router 等。

本文从一个 vue-router 示例应用开始,介绍前端路由的基本实现原理及实现方式。

vue-router是Vue.js的官方路由器,它与Vue.js内核深度集成,使得用Vue.js构建单页应用变得轻而易举。

vue-router就是WebApp的链接路径管理系统。 vue-router是Vue.js官方的路由插件,它和vue.js是深度集成的,适合用于构建单页面应用。

  1. 创建一个简单的vue项目vue create vuerou
  2. 下载 npm i vue-router -S
  3. 编写router.js
import Vue from 'vue';
// 1. 引入VueRouter
import VueRouter from 'vue-router';

import Home from '../views/Home.vue'

// 2. 安装插件
Vue.use(VueRouter); //挂载属性

// 3. 创建路由对象并配置路由规则
let router = new VueRouter({
    routes: [
        { path: '/home', component: Home }
    ]
});

// 4. 导出路由对象
export default router
  1. main.js引入route
import Vue from 'vue'
import App from './App.vue'

// 5. 引入router对象
import router from './router'

//new Vue 启动
new Vue({
    // 6. 将其路由对象传递给Vue的实例
    router, //可以简写router
    render: h => h(App)
}).$mount('#app')
  1. 在app.vue中写入 router-view 标签
//app.vue中
<template>
  <div id="app">
    <router-view/>
  </div>
</template>

至此一个最简单的vue-router示例项目完成了。

实现自己的vue-router插件

分析vue-router插件原理:

新建myrouter文件夹,新建index.js文件,在这里实现vue-router逻辑。

  1. 新建一个vueRouter类。实现createMap方法,用一个map对象来建立 path 和component 的对应关系。createMap方法的结果储存在routesMap属性上。
class vueRouter{
    constructor(options){
        this.mode=options.mode || 'hash';
        // 数据结构思维
        this.routes = options.routes || [];
        this.routesMap = this.createMap(this.routes);
        
    }
    createMap(routes){
        return routes.reduce((memo, current)=>{
            memo[current.path]=current.component;
            return memo;
        }, {});
    }
}
  1. vueRouter类新增一个history属性,指向了当前路由,首次加载路由以及每次路由变化时需要更新history的值。结合上文分析的hash以及history的current值。
class HistoryRouter{
    constructor(){
        this.current=null;
    }
}
class vueRouter{
    constructor(options){
        this.mode=options.mode || 'hash';
        // 数据结构思维
        this.routes = options.routes || [];
        this.routesMap = this.createMap(this.routes);
        this.history = new HistoryRouter();
        
    }
    createMap(routes){
        return routes.reduce((memo, current)=>{
            memo[current.path]=current.component;
            return memo;
        }, {});
    }
    init(){
        if(this.mode === 'hash'){
            location.hash?'':location.hash='/';
            window.addEventListener('load', ()=>{
                this.history.current=location.hash.slice(1);
            })
            window.addEventListener('hashchange', ()=>{
                this.history.current = location.hash.slice(1);
            })
        } else {
            location.pathname? '':location.pathname = "/";
            window.addEventListener('load',()=>{
                this.history.current = location.pathname
            })
            window.addEventListener("popstate",()=>{
                this.history.current = location.pathname
            })
        }
    }
}

至此走完了流程图里的前三步,即:

接下来需要将current的变化和视图绑定起来。即需要监听current变量,并且在current变量变化时,更新对应的视图。这不就是一个典型的双向绑定嘛,还在vue源码在构建的时候特意留出了一个util函数defineReactive,可以用来支持双向绑定,对vue双向绑定原理还不清楚的童鞋可以参考本人的另一篇文章了解vue源码,从0到1实现自己的mvvm框架,当然对双向绑定原理足够熟悉的童鞋也可以实现一个自己的双向绑定函数。

还有一点需要注意的是,vueRouter是作为vue的插件来发挥作用的,所以 vueRouter必须提供一个install函数用来给插件注册,对vue的插件机制还不熟悉的童鞋可以参考官方文档

  1. vueRouter通过全局混入注入组件选项,在组件的生命周期里,完成current的双向绑定。并暴露两个只读属性,方便操作router
    至此,代码如下:
vueRouter.install=function(vue){
    // 单例模式。保持全局只有一个对象,考虑代码健壮性
    if(vueRouter.install.installed) return;
    vueRouter.install.installed=true;
    vue.mixin({
        beforeCreate:function() {
            // main -> app.vue ->  组件
            // 判断是否是根实例
            if(this.$options &&  this.$options.router){
                this._root=this; 
                this._router=this.$options.router;
                vue.util.defineReactive(this, 'current', this._router.history);
            }else if(this.$parent){
                // app.vue 挂载 父级的_root
                // console.log(this)
                this._root=this.$parent._root;
            }
            // 变量权限思维:设置两个只读属性,用于操作router
            Object.defineProperty(this,'$router', {
                get(){
                    return this._root._router;
                }
            });
            Object.defineProperty(this,'$route', {
                get(){
                    return this._root._router.history.current;
                }
            })
        },
    })
}

事情到这个只完成了一半,我们对current变量进行了双向绑定,vue中的双向绑定机制会在current变量改变的时候触发视图更新,也就是拿到el宿主节点进行重新渲染,打开app.vue查看el对应的#app节点下对应了router-view组件,用来渲染最高级路由匹配到的组件。

  1. 所以我们在自定义实现的vuerouter中也要实现router-view组件。
    // current 监视了 current变量-> current改变时重新渲染 -> render获取到新的current值 -> 渲染对应current组件
    vue.component('router-view',{
        render(h){
            let current = this._self._root._router.history.current;
            let routesMap = this._self._root._router.routesMap;
            return h(routesMap[current]);
        }
    })

查看vue-router API,还有一个经常用到的组件,提供了类似a标签的超链接跳转功能,用来在不同的视图组件中进行跳转。

  1. 在自定义实现的vuerouter扩展router-link组件功能
    vue.component('router-link',{
        props:{
            to:String
        },
        render(h){
            let mode = this._self._root._router.mode;
            let to = mode === "hash"?"#"+this.to:this.to
            return h('a',{attrs:{href:to}},this.$slots.default)
            // return h('a',{},'首页')
        }
    })

至此,包含最基础功能的vuerouter代码已经成型,完整代码如下:

class HistoryRouter{
    constructor(){
        this.current=null;
    }
}
class vueRouter{
    constructor(options){
        this.mode=options.mode || 'hash';
        // 数据结构思维
        this.routes = options.routes || [];
        this.routesMap = this.createMap(this.routes);

        this.history = new HistoryRouter();
        this.init();
        this.afterEach=()=>{};
    }
    init(){
        if(this.mode === 'hash'){
            location.hash?'':location.hash='/';
            window.addEventListener('load', ()=>{
                this.history.current=location.hash.slice(1);
            })
            window.addEventListener('hashchange', ()=>{
                this.history.current = location.hash.slice(1);
            })
        }else{
            location.pathname? '':location.pathname = "/";
            window.addEventListener('load',()=>{
                this.history.current = location.pathname
            })
            window.addEventListener("popstate",()=>{
                this.history.current = location.pathname
            })
        }
    }
    createMap(routes){
        return routes.reduce((memo, current)=>{
            memo[current.path]=current.component;
            return memo;
        }, {});
    }
}

vueRouter.install=function(vue){
    // 单例模式。保持全局只有一个对象,考虑代码健壮性
    if(vueRouter.install.installed) return;
    vueRouter.install.installed=true;
    vue.mixin({
        beforeCreate:function() {
            // main -> app.vue ->  组件
            // 判断是否是根实例
            if(this.$options &&  this.$options.router){
                this._root=this; 
                this._router=this.$options.router;
                vue.util.defineReactive(this, 'current', this._router.history);
            }else if(this.$parent){
                // app.vue 挂载 父级的_root
                // console.log(this)
                this._root=this.$parent._root;
            }
            // 变量权限思维:设置两个只读属性,用于操作router
            Object.defineProperty(this,'$router', {
                get(){
                    return this._root._router;
                }
            });
            Object.defineProperty(this,'$route', {
                get(){
                    return this._root._router.history.current;
                }
            })
        },
    })
    // current 监视了 current变量-> current改变时重新渲染 -> render获取到新的current值 -> 渲染对应current组件
    vue.component('router-view',{
        render(h){
            let current = this._self._root._router.history.current;
            let routesMap = this._self._root._router.routesMap;
            return h(routesMap[current]);
        }
    })
    vue.component('router-link',{
        props:{
            to:String
        },
        render(h){
            let mode = this._self._root._router.mode;
            let to = mode === "hash"?"#"+this.to:this.to
            return h('a',{attrs:{href:to}},this.$slots.default)
            // return h('a',{},'首页')
        }
    })
}

export default vueRouter
  1. 测试myrouter插件是否生效
    编译vuerou文件夹中的router配置文件,将引入vueRouter插件的语句替换为import VueRouter from '../myrouter',重新运行项目,查看路由对应的已经生效。

写在最后

本文主要为理解vueRouter源码提供一个最基础的框架和思路,知其然也知其所以然,你需要对vue生态有着较为深入的理解,如vue的插件机制,vue的双向绑定原理,后端路由和前端路由,hash模式相关api以及history模式相关api。

为了方便阅读理解,本文代码已经托管gitee

文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注