vue-router 深入学习

4,304 阅读14分钟

概述

使用 vue 构建 单页面应用 时,离不开 vue-router 的配合。通过 vue-router, 我们可以建立 路由和组件(页面)之间的映射关系。当 切换路由 时,将 匹配的组件(页面) 渲染到对应的位置。

虽然在工作中可以很熟练的使用 vue-router,但在使用过程中常常会出现一些疑问。比如:

  1. vue-router怎么安装? 安装过程中做了什么?

  2. vue-router 是怎么工作的? 原理是什么?

  3. hash模式和history模式有什么区别?

  4. router-view 是怎样渲染成当前路由对应的组件(页面)的?

  5. 嵌套路由是怎么工作的?

  6. 路由懒加载是怎么工作的?

  7. 各个导航守卫(钩子函数) 分别在什么情况下会触发?

针对这些问题,本文会结合一个小例子,一一解答。

示例

我们先通过一个简单的小例子,来回顾一下 vue-router 的使用。

// html
<div id="app">
    <router-view></router-view>
</div>
// js

// 安装 vue-router
Vue.use(VueRouter)


// router
var router = new VueRouter({
    mode: 'history',
    routes: [{
        path: '/pageA',
        name: 'pageA'
        component: { template: '<div>pageA</div>'}
    }, {
        path: '/pageB',
        name: 'pageB'
        component: { template: '<div>pageB<router-view></router-view></div>'},
        children: [{
            path: '/pageC',
            name: 'pageC'
            component: { template: '<div>pageC</div>' }
        }]
    }]
})

// vue应用

var vm = new Vue({
    el: '#app',
    router
})

vm.$router.push('/pageA')  // 跳转 pageA
vm.$router.repalce({name: 'pageB'})  // 跳转 pageB
vm.$router.push({path: '/pageB/pageC'})  // 跳转 pageC

接下来,会结合上面 示例,对 概述中 提出的问题一一解答。

安装 vue-router

在使用 vue-router 开发 vue项目 的时候,需要先通过 Vue.use 方法来安装 vue-router插件

Vue.use 方法的 内部操作 如下:

  Vue.use = function (plugin) {
    // 判断 plugin 是否已经安装。 如果已安装, 直接返回, 不需安装。
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []));
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }

    // additional parameters
    const args = toArray(arguments, 1);
    args.unshift(this);
    // 如果 plugin 是一个对象, 且提供 install 方法
    // 执行 plugin 提供的 install 方法安装 plugin
    if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args);
    } else if (typeof plugin === 'function') {
      // 如果 plugin 是一个函数, 执行函数,安装 plugin
      plugin.apply(null, args);
    }
    // 缓存 已经安装的 plugin
    installedPlugins.push(plugin);
    return this
  };

Vue.use 方法在 安装插件(plugin) 的时候, 会调用 插件(plugin)install 方法。

待安装的插件(plugin), 如果是一个 对象,必须 显示提供install方法; 如果是一个 函数,则 自动作为install方法

vue-router 提供了 install 方法供 Vue.use 方法使用。 方法详情 如下:

function install (Vue) {
  
  ...
  
  Vue.mixin({
    // beforeCreate 钩子函数, 每一个vue实例创建的时候, 都会执行
    beforeCreate () {
      // 只有创建 根vue 实例的时候,配置项中才会有 router 选项
      if (isDef(this.$options.router)) {
        // this -> 一般为根vue实例
        this._routerRoot = this;
        // _router, 路由实例对象
        this._router = this.$options.router;
        // 初始化router实例
        this._router.init(this);
        Vue.util.defineReactive(this, '_route', this._router.history.current);
      } else {
        // this -> 一般为组件实例
        // _routerRoot,含有router的vue实例, 一般为根vue实例
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
      }
      registerInstance(this, this);
    },
    // destroyed 钩子函数,每个vue实例销毁时触发
    destroyed () {
      registerInstance(this);
    }
  });
  // this.$router, 返回根vue实例的_router属性
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  });
  // this.$route, 返回根vue实例的_route属性
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  });
  // 全局注册 RouterView 组件
  Vue.component('RouterView', View);
  // 全局注册 RouterLink 组件
  Vue.component('RouterLink', Link);
}

安装 过程中,有一些 关键步骤

  1. 全局 注册 beforeCreatedestroyed 钩子函数。

    在创建 vue实例(根vue实例/组件vue实例) 时触发 beforeCreate 钩子函数。

    如果是 根vue实例,根据传入的 router实例 定义 _router(路由实例对象) 属性, 然后对 router实例 进行初始化。在 router实例 初始化过程中,会为 根vue实例 定义 _route(路由信息对象) 属性。 接下来,会将 根vue实例_route 属性设置为 响应式属性切换路由 会导致 _route 属性 更新, 然后 触发界面重新渲染

    如果是 组件vue实例,为 组件vue实例 定义 _routerRoot 属性, 指向 根vue实例

    切换路由 导致 原路由页面对应的vue实例 需要 销毁 时, 触发 destroyed 钩子函数。

  2. Vue.prototype 定义 $router$route 属性, 设置对应的 getter

    vue实例 通过 $router$route 属性访问 路由实例对象路由信息对象 时, 实际访问的是 根vue实例_router_route 属性。

    所有的 vue实例 访问的 $router($route) 都是 同一个

  3. 注册全局组件: RouterViewRouterLink

    渲染页面 的过程中, 如果遇到 router-linkrouter-view, 会使用 全局注册 生成的 构造函数

vue-router工作原理

HTML5 中引入了 window.history.pushStatewindow.history.replaceState 方法, 它们可以分别 添加和修改浏览器的历史记录不需要重新加载页面

pushStatereplaceState 方法需配合 window.onpopstate 使用。我们可以通过 浏览器的前进、回退按钮 或者 window.history.backwindow.history.gowindow.history.forward 方法, 激活浏览器的某个历史记录。 如果 激活的历史记录 是通过 pushState方法添加 或者 被replaceState方法修改,会触发注册的 popstate 事件。

pushStatereplaceState 方法不会触发 popstate 事件。

如果浏览器不支持 pushStatereplaceState 方法, 我们也可以通过 window.location.hash = 'xxx' 或者 window.location.replace 的方式 添加和修改浏览器的历史记录, 而 不需要重新加载页面

window.location.hashwindow.location.replace 需配合 window.onhashchange 使用。只要 激活的历史记录hash 值和 当前 url 的 hash 值 不同,就会触发注册的 onhashchange 事件。

只要 url# 后面的值发生变化, 就会触发 hashchange 事件。

使用 vue-router 进行 单页面应用页面跳转 是基于 上述原理 实现的。

当需要 跳转页面 时,通过 pushState(replaceState、window.location.hash、 window.location.replace) 方法 添加或修改历史记录, 然后 重新渲染页面。 当通过 浏览器的前进、回退按钮 或者 window.history.backwindow.history.gowindow.history.forwardthis.$router.backthis.$router.gothis.$router.forward 方法 激活某个历史记录时, 触发注册的 popstate(hashchange) 事件, 然后 重新渲染页面

使用 vue-router单页面应用 进行 页面跳转控制 的流程如下:

  1. 安装 vue-router

  2. 使用 VueRouter构造函数 构建 router 实例;

    在这个过程中,主要操作如下:

    • 遍历 routeConfig.routes, 建立 路由path(name)组件(component) 之间的 映射关系

    • 根据 routeConfig.mode,为 router实例 构建相应的 history属性(mode: hash => HashHistory, mode: history => Html5History)

  3. 使用 router实例 构建 根vue实例, 触发 beforeCreate 钩子函数,对 router实例 进行 初始化;

    初始化 过程中, 主要操作如下:

    • 根据当前 url,为 根vue实例 添加 _route(路由信息对象)属性

    • window 对象注册 popstate(hashchange) 事件;

    初始化 完成以后, 将 根vue实例_route(路由信息对象)属性 设置为 响应式属性。只要 更新_route属性,就会 触发界面更新

    首次界面渲染, 如果遇到 router-view 标签,会使用 当前路由对应的组件 进行渲染。

  4. 通过 vm.$router.push 或者 vm.$router.replace 进行 页面跳转

    在此过程中, 会在浏览器中 新增一个历史记录 或者 修改当前历史记录, 然后 更新根vue实例的_route(路由信息对象)属性, 触发 界面更新

    界面重新渲染 的时候, 遇到 router-view 标签, 会使用 新路由对应的组件 进行渲染。

  5. 当使用 浏览器的前进、回退按钮 或者 vm.$router.govm.$router.backvm.$router.forward 方法 激活某个浏览器历史记录时, 触发注册的 popstatue(hashchange) 事件, 更新根vue实例的_route(路由信息对象)属性, 触发 界面更新

    界面重新渲染 的时候, 遇到 router-view 标签, 会使用 新路由对应的组件 进行渲染。

基本流程图如下

hash / history

vue-router 默认使用 hash 模式, 即 切换页面 只修改 location.hash

hash 模式下, 页面 url 的格式为: protocol://hostname: port/xxx/xxx/#/xxx

hash 模式, 也是通过 pushState(replaceState) 在浏览器中 新增(修改)历史记录。 当通过 前进或者后退 方式激活某个历史记录时, 触发 popstate 事件。 如果 浏览器不支持pushState,则通过 修改window.location.hash(window.location.replace) 在浏览器中 新增(修改)历史记录。 当通过 前进或者后退 方式激活某个历史记录时, 触发 hashChange 事件。

如果选择 history 模式, 切换页面会修改 location.pathname

history模式下,页面url的格式为 protocol://hostname: port/xxx/xxx, 更加美观。

history 模式充分利用 pushState 来完成页面跳转。 如果 浏览器不支持pushState、replaceState,则通过 window.location.assignwindow.location.replace 方式来跳转页面。 每次跳转,都会 重新加载页面

使用 history 模式需要 服务端支持,要在服务端增加一个 覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是应用依赖的页面。 否则会出现 404 异常。

http请求 中不会包含 hash 值。

router-view

vue应用 中, 一个 vue组件(component)template标签最后渲染为实际dom树结构,经历的主要过程如下:

  1. vue组件 对应的 template标签 被解析成一个 vnode节点对象

    在解析过程中,会根据 vue组件 对应的 options配置项({data, props, methods...}), 通过 Vue.extend 方法生成 vue组件 对应的构造方法 VueComponent

  2. 执行 vue组件 对应的构造方法 VueComponent,生成 vue组件实例,并进行 初始化

  3. 执行 vue组件实例render 方法,将 vue组件template模板 解析为 vnode节点树

  4. vue组件 对应的 vnode节点树 渲染为 实际的dom节点树

router-viewtemplate标签实际的页面,经历的过程和 普通vue组件 基本相同, 稍微的区别是在第一步 - 标签转化为vnode节点

遇到 router-view标签时,通过执行 全局注册的RouterViewrender 方法,可转化为 vnode节点, 主要过程如下:

  1. 获取 当前路由 所匹配的 组件对应的options配置项({data, props, methods...})

  2. 根据 组件options配置项, 通过 Vue.extend方法生成 vue组件 对应的构造方法 VueComponent

  3. 生成组件对应的 vnode节点

综上, router-viewtemplate标签最后的dom节点树 的过程如下:

注意: 上述流程仅适用于 兄弟路由 中间的切换,不适用于 父子路由切换 和使用 keep-alive父子路由切换 和使用 keep-alive 之后的路由切换会稍微 不一样

嵌套路由

嵌套路由 的具体使用详见 官网

嵌套路由 会涉及到将 多个router-view 渲染为对应的 页面(或组件)。 在上面的例子中,路由切换到 /pageB 时,外层router-view 渲染为页面 pageB内层router-view 渲染为 ;当路由切换到 /pageB/pageC 时, 外层router-view 渲染为页面 pageB内层router-view 渲染为 pageC

当我们通过 new VueRouter(...) 创建 router实例 的时候, 会 遍历routes配置项, 分别建立 route pathroute recordroute nameroute record 的映射关系, 即 pathMapnameMap。 其中, route record 是一个 路由记录对象, 会包含 pathnamecomponentsparamsquery 等信息。

遍历 routes配置项 的时候, 如果遇到了 嵌套路由, 会继续遍历 route.children, 将 子路由record子路由path、name映射关系 分别添加到 pathMapnameMap 中。 同时,在 子路由record 中添加 parent 属性,指向 父路由record, 建立 父子路由record 之间的 关联关系

当我们切换到 某个路由 时, 会根据 path(或name)pathMap(或nameMap) 中寻找 匹配的路由record,将 匹配的路由record 添加到一个 数组 中。 如果 路由recordparent 属性不为 undefined, 那么将 parent record 通过 unshift 的方式添加到 数组 中。 递归 处理 路由recordparent 属性,直到属性值为 undefined 为止。 最后我们会得到一个 路由record列表,列表中会包含 祖先路由record父路由record当前路由record

在上面的示例中,各个路由匹配的 record列表 如下:

'/pageA': [{path: '/pageA', components: ...}]

'/pageB': [{path: '/pageB', components: ...}]

'/pageB/pageC': [
    {path: '/pageB', component: ...},
    {path: '/pageB/pageC', components: ...},
]

路由切换 会触发 页面重新渲染。 页面重新渲染的时候会从 最外层的router-view 开始,然后 逐级处理 页面内部的 router-view

处理 router-view标签 的时候, 会从当前路由的 record列表 中查找 匹配的record,然后将 record.components 渲染为 实际页面vue-router会保证每一个 router-view 都能匹配到对应的 record

路由切换到 '/pathA', 匹配到的 路由record 只有 一个。先处理外层 router-view, 渲染页面 pageApageA 页面中没有 router-view,路由切换处理完毕。

路由切换到 '/pathB', 匹配到的 路由record 只有 一个。先处理外层 router-view, 渲染页面 pageBpageB 中有 router-view 需要处理,但是 record列表 中已经 没有匹配的record,只能做 空处理

路由切换到 '/pathB/pathC', 匹配到的 路由record两个。 先处理外层 router-view, 使用 record列表 中的 第一个record 渲染页面 pageBpageB 中有 router-view 需要处理,使用 record列表 中的 第二个record 渲染页面 pageCpageC 页面中没有 router-view,路由切换处理完毕。

嵌套路由 之间 相互切换 时,父页面组件会更新。 更新时,先执行 父页面组件 对应的 render 方法生成 vnode节点树。 将 vnode节点树 渲染为 dom节点树 之前, 会将 新vnode节点树原vnode节点树 做比较。 由于 新旧vnode节点树没有变化, 所以 父页面不会有任何dom操作

父路由 切换到 子路由父页面不变, 将 子页面对应的dom树 添加到 父页面的dom树 中。

子路由 切换到 父路由父页面不变, 将 子页面对应的dom树父页面的dom树 中删除。

子路由间相互切换父页面不变, 将 上一个子页面对应的dom树父页面的dom树 中删除,将 下一个子页面对应的dom树 添加到 父页面的dom树 中。

路由懒加载

路由懒加载 的具体使用详见 官网

上面的 示例 中没有用到 路由懒加载,在通过 new VueRouter 构建 router实例 的时候, routes 配置项中的每一个 route 中的 component 都是一个 普通对象构建组件所需要的各个配置项(data、props、methods等) 都已获取。

在实际的 vue单页面应用 中,路由懒加载 被广泛应用。 路由懒加载 的前提是使用 webpack打包源代码。使用 webpack打包源代码 以后,每个页面 都会被分离成一个 单独的chunk,只有在 vue应用 需要时才会 动态加载(通过动态添加script元素的方法从服务端获取页面js文件,渲染页面)

使用 路由懒加载 后, 在构建 router实例 的时候, routes 配置项中的每一个 route 中的 component 都是一个 function, 用于 动态从服务端加载页面源代码,获取 构建组件所需要的各个配置项(data、props、methods等)

切换路由 的时候, 会先 通过动态添加script元素的方式服务端 加载 页面js文件, 获取 构建页面组件需要的options配置项(data、props、render、methods等)。 然后 激活新路由(pushState 将新路由添加到浏览器历史记录中), 触发 页面更新使用获取到的 options 配置项构建新路由对应的组件实例渲染页面

路由懒加载 具体的工作流程如下:

  1. 定义 路由组件, 每一个组件都是一个 函数, 用于 动态异步加载组件

    const Foo = () => import(/* webpackChunkName: 'Foo'*/'./Foo')
    
  2. 构建 router实例, 使用 pathMap(nameMap) 收集 路由path(路由name)路由record 之间的映射关系。 路由record 中的 组件 是一个 函数,用于 异步获取构建组件需要的配置项(data、methods、render 等)

  3. 初次加载页面或路由切换激活某个路由 时, 获取 激活路由匹配的路由record

  4. 由于 路由record 中的 组件 是一个函数, 执行函数, 通过 动态添加script的方式加载组件, 返回一个 promise,状态为 pending

  5. 激活新路由动作停止,等待组件加载完成。 组件加载完成以后, 执行 js代码, 获取 组件对应的配置项(data、methods、render等), 将 步骤4 中的 promise 的状态置为 resolve。 触发 promise 通过 then 方法注册的 onResolve, 将 路由record中的组件更新为配置项对象激活新路由动作继续

  6. 通过 pushState(replaceState、go、back、forward) 激活新路由,触发 页面更新使用获取到的 options 配置项构建新路由对应的组件实例渲染页面

导航守卫

导航守卫 的具体用法详见 官网

我们还是通过文章开始提供的 示例 来说明 各个路由守卫 在什么时候触发。

当路由从 '/pageA' 切换到 '/pageB' 时,要经历如下流程:

  1. pathMap(nameMap) 中获取 新路由对应的 route record, 构建 新路由对应的路由信息对象 - route

    如果使用了 路由懒加载route recordcomponents 中的组件不是一个 普通对象,而是一个 函数,构建 组件实例 需要的 options配置项(data、methods、props等) 还没有获取到。

    此时,新路由还没有被激活,还是 原路由页面

  2. 触发 pageA 注册的 组件级路由守卫 - beforeRouteLeave。 此时,新路由还没有被激活,还是 原路由页面

  3. 触发 全局守卫 - beforeEach。 此时,新路由还没有被激活,还是 原路由页面

  4. 触发 新路由('/pageB') 注册的 路由独享守卫 - beforeEnter。 此时,新路由还没有被激活,还是 原路由页面

  5. 如果使用了 路由懒加载,会通过 动态添加script元素的方式服务端 加载 页面源文件,获取 新路由页面对应的组件配置项。 此时 新路由 对应的 route recordcomponents 中的 组件 更新为一个 普通对象

    此时, 新路由依旧没有被激活,还是 原路由页面

  6. 触发 pageB 注册的 组件级路由守卫 - beforeRouteEnter。此时, 新路由依旧没有被激活,还是 原路由页面

  7. 触发 全局守卫 - beforeResolve, 代表 构建异步路由组件所需的 options 已经获取。此时, 新路由依旧没有被激活,还是 原路由页面

  8. 更新 根vue实例_route 属性(路由信息对象)触发页面异步更新(触发_route的setter)。此时, 新路由依旧没有被激活,还是 原路由页面

  9. 触发 全局守卫 - afterEach。 此时, 新路由依旧没有被激活,还是 原路由页面

  10. 激活新路由,即通过 pushState新路由 添加到 浏览器历史记录中。 此时, 还是 原路由页面

  11. 页面更新开始渲染 pageB

    触发 组件pageBbeforeCreatecreatedbeforeMount

  12. 销毁页面pageA,触发 组件pageAbeforeDestorydestroyed

  13. 触发 组件pageBmountedpageB 渲染完成。

嵌套路由间的切换会有稍许不同, 当 '/pageB''/pageB/pageC' 之间 来回切换时, 流程如下:

  1. 等同上面。

  2. 如果从 '/pageB/pageC' 切换到 '/pageB', 触发 pageC 注册的 组件级路由守卫 - beforeRouteLeave

    如果从 '/pageB' 切换到 '/pageB/pageC', 不会 触发 pageB 注册的 组件级路由守卫 - beforeRouteLeave

  3. 触发 beforeEach

  4. 触发 组件pageB 注册的 组件级路由守卫 - beforeRouteUpdate

  5. 如果从 '/pageB' 切换到 '/pageB/pageC', 触发 /pageB/pageC 注册的 路由级路由守卫 - beforeEnter

    如果从 '/pageB/pageC' 切换到 '/pageB'不会触发beforeEnter

  6. 如果使用了 路由懒加载,需要通过 动态添加script元素的方式服务端 加载 页面源文件,获取 新路由页面对应的组件配置项

  7. 如果从 '/pageB' 切换到 '/pageB/pageC', 触发 组件pageC 注册的 组件级路由守卫 - beforeRouterEnter

    如果从 '/pageB/pageC' 切换到 '/pageB'不会触发beforeRouterEnter

  8. 触发 beforeResolve

  9. 更新 根vue实例_route 属性(路由信息对象)触发页面异步更新(触发_route的setter)

  10. 激活新路由,即通过 pushState新路由 添加到 浏览器历史记录中

  11. 页面更新开始渲染新页面

    如果从 '/pageB' 切换到 '/pageB/pageC', 触发 组件pageBbeforeUpdate, 触发 组件pageCbeforeCreatecreatedbeforeMountmounted,再触发 组件pageBupdated

    如果从 '/pageB/pageC' 切换到 '/pageB', 销毁 页面pageC,触发 组件pageBbeforeUpdate, 触发 组件pageCbeforeDestorydestroyed,再触发 组件pageBupdated

其他

  1. vue-router 使用 histroy模式 时,如果 浏览器不支持history.pushState, vue-router 会自动变为 hash模式, 使用 window.location.hash = xxxhashchange 实现 单页面应用

    如果为 history模式, 且 router.fallback 设置为 false 时, 如果 浏览器不支持history.pushState, vue-router 不会变为 hash模式。此时,只能通过 location.assignlocation.replace 方法 重新加载页面

  未完待续...