Vue-Router原理的深入探索


之前Vue-Router对于我而言是非常好用的Vue的路由管理工具,多用于单页面应用中,但对于其底层的原理以及如何自己实现类似的管理工具没有太多的思考💭,最近深度读了下Router的源码,更好地理解它的内部实现逻辑和底层原理。

Vue-Router的使用

简单回忆下使用Router的步骤

  1. 安装包: npm i vue-router -S
  2. 声明引入: 在main.js中引入,import VueRouter from 'vue-router'
  3. 安装插件: Vue.use(VueRouter)
  4. 创建及配置: 创建路由对象并且配置路由规则
    let router = new VueRouter({toutes: [{path: '/home', component: Home}]})
  5. 传递实例: 将路由对象传递给Vue实例 router: router
  6. 文件留坑: 在App.vue文件中留坑 <router-view></router-view>

除了基本使用还有动态路由匹配、嵌套路由和编程式导航以及重定向等多种使用详情,可以查看Vue-Router | 官方文档

单页面SPA和多页面MPA

在深度研究Vue-Router之前,先分析下单页面应用的特点,对单、多页面应用还不太了解的可以移步 《前端:你要懂的单页面和多页面》这篇文章,但原文有点小问题。总结大概就是:

  • SPA—— 单个页面应用程序,仅有一张web页面;页面跳转仅刷新局部资源,公共资源仅需加载一次;应用于APP或客户端
  • MPA—— 多页面跳转刷新所有资源,每个资源选择性重复加载;常用于PC端官网、购物电商网站

SPA vs. MPA

综上所述☝️,可以发现SPA的特点就是单个页面应用程序,加载页面的时候,不全部加载,只更新一个指定容器的内容。因此,SPA的核心之一是——更新视图不重新请求页面

Vue-Router实现原理

⭐️ Vue-Router的原理核心是:更新视图不重新请求页面
Vue-Router在实现单页前端路由时,提供了一种方式:Hash模式,History模式,Abstract模式,根据模式参数决定采用哪种方式

Router运行模式:

  • hash: 使用URL哈希值来作路由
  • history: 依赖HTML5历史记录API和服务器配置。查看HTML5历史记录模式。
  • abstract:支持所有Javascript运行环境,如Node.js服务端。

VueRouter的构造方法(src/index.js):

import { HashHistory } from './history/hash'
import { HTML5History } from './history/html5'
import { AbstractHistory } from './history/abstract'

    // ... more

    constructor(options: RouterOptions = {}) {
        // ... more

        // 默认hash模式
        let mode = options.mode || 'hash'

        // 是否降级处理
        this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false

        // 进行降级处理
        if (this.fallback) {
            mode = 'hash'
        }

        if (!inBrowser) {
            mode = 'abstract'
        }
        this.mode = mode

        // 根据不同的mode进行不同的处理
        switch (mode) {
            case 'history':
                this.history = new HTML5History(this, options.base)
                break
            case 'hash':
                this.history = new HashHistory(this, options.base, this.fallback)
                break
            case 'abstract':
                this.history = new AbstractHistory(this, options.base)
                break
            default:
                if (process.env.NODE_ENV !== 'production') {
                    assert(false, `invalid mode: ${mode}`)
                }
        }
    }

可以看出,根据mode确定类型,首先会判断是否支持history,然后根据 fallback 来确定是否要降级。然后,根据不同的 mode,分别实例化不同的 history 。 (HTML5HistoryHashHistoryAbstractHistory

(一)Hash模式

默认的模式,浏览器url中#后面的内容,包含#。
hash是URL中的锚点,代表的是网页中的一个位置,单单改变#后的部分,浏览器只会加载相应位置的内容,不会重新加载页面。

  • #是用来指导浏览器动作的,对服务器端完全无用,HTTP请求中,不包含#。
  • 每一次改变#后的部分,都会在浏览器的访问历史中增加一个记录,使用”后退”按钮,就可以返回上一个位置。

原理: Hash模式通过锚点值的改变,根据不同的值,渲染指定DOM位置的不同数据。

(二)History模式

HTML5 History API提供了一种功能,能让开发人员在不刷新整个页面的情况下修改站点的URL,就是利用 history.pushState API 来完成 URL 跳转而无须重新加载页面。在不支持history.pushState的浏览器,会自动回退到hash模式。

由于hash模式会在url中自带#,如果不想要很丑的 hash,我们可以用路由的 history模式,只需要在配置路由规则时,加入"mode: 'history'",充分利用上面的history.pushState API,不需要重新加载页面
是否回退到hash模式可以通过fallback配置项来控制,默认值是true

// main.js
const router = new VueRouter({
  mode: 'history',
  routes: [...]
})

❓ 有时,history模式下也会出现问题:

  • hash模式下:baidu.com/#/id=5 请求地址为 baidu.com, 没有问题
  • history模式下:baidu.com/id=5 请求地址为 baidu.com/id=5,如果后端没有对应的路由处理,就会返回404错误⚠️

解决: 为了应对这种情况,需要后台配置支持——在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是我们app 依赖的页面。

export const routes = [
  {path: "/", name: "homeLink", component:Home}
  {path: "/register", name: "registerLink", component: Register},
  {path: "/login", name: "loginLink", component: Login},
  {path: "*", redirect: "/"}
]

此处就设置任意匹配,如果URL输入错误或者是URL匹配不到任何静态资源,就自动跳到到首页。

history的目录

History类的基本关系图
History类的定义位于base.js,其中还定义了一系列方法,hashhtml5模式分别继承了这些方法,并实现了自己特有的逻辑。从外部调用的时候,会直接调用到 this.history , 然后,由于初始化对象的不同,而进行不同的操作。

(三)Abstract模式

abstract模式是使用一个不依赖于浏览器的浏览历史虚拟管理后端。
根据平台差异可以看出,在 Weex 环境中只支持使用 abstract 模式。 不过,vue-router 自身会对环境做校验,如果发现没有浏览器的 API,vue-router 会自动强制进入 abstract 模式,所以 在使用 vue-router 时只要不写 mode 配置即可,默认会在浏览器环境中使用 hash 模式,在移动端原生环境中使用 abstract 模式。
当然,也可以明确指定在所有情况下都使用 abstract 模式。

源码结构

Router的源码目录
其中,VueRouter对象,就在vue-router的入口文件src/index.js
VueRouter 原型上定义了一系列的函数,我们日常经常会使用到。主要有 : gopushreplacebackforward
以及一些导航守护 : beforeEachbeforeResolveafterEach 等等
上面html 中使用到的 router-view ,以及经常用到的 router-link 则存在 src/components 目录下。

Vue开发插件的方式

Vue.js 要求插件应该有一个公开方法 install。这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象。
在 install 方法里面,便可以做相关的处理:

  • 添加全局方法或者属性
  • 添加全局资源:指令/过滤器/过渡等,
  • 通过全局 mixin 方法添加一些组件选项,
  • 添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现。
  • 一个库,提供自己的 API,同时提供上面提到的一个或多个功能

install的实现逻辑:
1) 防止重复安装
2) 通过全局 mixin 注入一些生命周期的处理
3) 挂载变量到原型上
4) 注册全局组件

install.js 的完整代码:

import View from './components/view';
import Link from './components/link';

export let _Vue;

// 插件安装方法
export function install(Vue) {
    // 防止重复安装
    if (install.installed && _Vue === Vue) return;
    install.installed = true;

    _Vue = Vue;

    const isDef = v => v !== undefined;

    // 注册实例
    const registerInstance = (vm, callVal) => {
        let i = vm.$options._parentVnode;
        if (
            isDef(i) &&
            isDef((i = i.data)) &&
            isDef((i = i.registerRouteInstance))
        ) {
            i(vm, callVal);
        }
    };

    // 混入生命周期的一些处理
    Vue.mixin({
        beforeCreate() {
            if (isDef(this.$options.router)) {
                // 如果 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) || this;
            }
            // 注册实例
            registerInstance(this, this);
        },
        destroyed() {
            registerInstance(this);
        }
    });

    // 挂载变量到原型上
    Object.defineProperty(Vue.prototype, '$router', {
        get() {
            return this._routerRoot._router;
        }
    });

    // 挂载变量到原型上
    Object.defineProperty(Vue.prototype, '$route', {
        get() {
            return this._routerRoot._route;
        }
    });

    // 注册全局组件
    Vue.component('RouterView', View);
    Vue.component('RouterLink', Link);

    // 定义合并的策略
    const strats = Vue.config.optionMergeStrategies;
    // use the same hook merging strategy for route hooks
    strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate =
        strats.created;
}

push方法解读

在进行路由跳转的时候,通常都是使用this.$router.push(...)的形式进行调用

export default class VueRouter {
    // ... more

    push(location: RawLocation, onComplete?: Function, onAbort?: Function) {
        this.history.push(location, onComplete, onAbort);
    }
}

src/index.js中的VueRouter对象上有一个push方法,但是直接转发到了this.history.push(location, onComplete, onAbort),引起前面提到了,根据history初始化对象不同做不同的处理。

hash模式下:(mode === hash

export class HashHistory extends History {
    // ...more

    // 跳转到
    push(location: RawLocation, onComplete?: Function, onAbort?: Function) {
        const { current: fromRoute } = this;
        this.transitionTo(
            location,
            route => {
                pushHash(route.fullPath);
                handleScroll(this.router, route, fromRoute, false);
                onComplete && onComplete(route);
            },
            onAbort
        );
    }
}

// 切换路由
// 会判断是否支持pushState ,支持则使用pushState,否则切换hash
function pushHash(path) {
    if (supportsPushState) {
        pushState(getUrl(path));
    } else {
        window.location.hash = path;
    }
}

history模式下:(mode === history

export class HTML5History extends History {
    // ...more

    // 增加 hash
    push(location: RawLocation, onComplete?: Function, onAbort?: Function) {
        const { current: fromRoute } = this;
        this.transitionTo(
            location,
            route => {
                pushState(cleanPath(this.base + route.fullPath));
                handleScroll(this.router, route, fromRoute, false);
                onComplete && onComplete(route);
            },
            onAbort
        );
    }
}

都调用了trasitionTo,两种模式下push的区别在于:一个调用 pushHash , 一个调用 pushState,而其他的 goreplacegetCurrentLocation 都是类似的实现方式。

路由匹配

在进行路由跳转的时候,通常有以下四种方式:

// 字符串
router.push('home');
// 对象
router.push({ path: 'home' });
// 命名的路由
router.push({ name: 'user', params: { userId: '123' } });
// 带查询参数,变成 /register?plan=private
router.push({ path: 'register', query: { plan: 'private' } });

但是push的具体对象与routes之间如何进行匹配,就涉及到了路由匹配的问题。

路由匹配的具体步骤主要有:

1) 实例化的时候,创建匹配器 ,并生成路由的映射关系 。匹配器中包含 match 方法
2) push 的时候,调用到 match 方法
3) match 方法里面,从路由的映射关系里面,通过编译好的正则来判定是否匹配,返回最终匹配的路由对象
4) transitionTo 中,拿到匹配的路由对象,进行路由跳转


Author: Casey Lu
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Casey Lu !
评论
 Previous
四月天的一纸絮叨 四月天的一纸絮叨
这一个月的时间真心是飞速,回顾了三月底的flag,我觉得还算是满意的,至少没有让自己失望,感觉充实无比~吼吼吼 小有成长,空间辽阔 这个月算是一个过渡的月份,从学习状态到开发状态的切换,投身到真实的项目开发中,节奏比我想象中的要快,但是自己
2020-04-30
Next 
常见反爬虫策略的探究 常见反爬虫策略的探究
上周末百度三面面试官问的一个我最感兴趣的问题莫过于“对于淘宝京东之类的电商网站,防止商品价格被爬取,你会有哪些设计思路 ”在此之前,cc只是会通过代理IP手段来绕过一些反爬虫设置进行网站爬虫,但是没有思考过这样具体的应用场景。这篇Blog将
2020-04-15
  TOC