Vue源码解析系列(十七) -- Vue-router@3.x、@4.x的区别与源码解读

183 阅读12分钟

前言

Vue-router作为单独开发SPA的一个大模块,距离目前已经更新到了4.x版本,那么本文就和大家一起来梳理一下基础知识用法吧,最后我们也深入源码来看一看它的实现。在这个问题之前我们应该来谈一谈为什么要设计Vue-router这个东西。

设计理念

回到没有Router的年代,我们的项目肯定是多个页面的,那么多页面跳转采用的肯定是跳链接的解决方案,跳链接方案会带来这样的几个问题。

  • 每访问到一个资源链接,我们会发起一次http请求,去服务器拿当前资源路径下的资源,传输链路耗时。
  • http请求资源回来之后,会根据W3C规则进行解码,根据CRP那一套规则进行页面渲染,我们这里把他称之为CRP耗时,CRP耗时没有办法避免,但是可以相对应减少耗时。
  • 多次访问链接,虽然有静态资源缓存帮助我们免去http请求这一步,但是每一次的渲染都会引起重绘重排,当然这是没有办法避免的,一个链接目录下的资源是有限的,所以我们采用了现代化框架组件化开发,Router的出现更加符合规范。

基础

对于一个VueSPA项目,路由的使用通常会有几个步骤。

  • 引入加载(我们就不介绍CDN引入了)
// 使用npm安装
npm install vue-router -D

// 安装4.x版本的Router
npm install vue-router@4 -D

  • 入口文件使用(v3.x的用法与v4.x的用法会有一点点差别)
// v3.x
import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

// 创建router实例,传入routes配置
const router = new VueRouter({
    routes // 配置的路由表
})

// 挂载
const app = new Vue({
    router
}).$mount('#app')

// v4.x
// 创建
const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(), // 路由模式,其他的使用createWebHistory
  routes, // `routes: routes` 的缩写
})

// 挂载
const app = Vue.createApp({})
app.use(router)
app.mount('#app')
  • 路由表的配置
const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }

// 定义一些路由表
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
]

注意:在Vue项目中,我们可以使用$router来访问路由实例,如果路由配置表中某个路由被激活,那么我们可以使用$route来获取到这样的一个路由对象。当然在setup函数里面,我们可以使用useRouteruseRoute来获取。

import { useRouter, useRoute } from 'vue-router'

export default {
  setup() {
    const router = useRouter()
    const route = useRoute()

    function pushWithQuery(query) {
      router.push({
        name: 'search',
        query: {
          ...route.query,
        },
      })
    }
  },
}

基于上面这样子写,就可以在Vue项目中使用Vue-router了,这些都是最基本的配置,我们还必须要根据实际的业务进行一些配置。

动态路由

因为我们业务中会有这样的一个需求,展示商品 -> 根据商品展示商品详情页信息。那么这里我们基于Vue-Router的最基本的思路就是写一个页面组件,通过传参去展示。但是不同的产品我需要生成不同的页面,所以这我们会使用到动态路由

// 定义路由组件
const ShowGoodsDetail = {
  template: '<div>GoodsDetail</div>',
}

// v3.x
const router = new VueRouter({
  routes: [
    // 动态路径参数 以冒号开头
    { path: '/details/:id', component: ShowGoodsDetail }
  ]
})

// v4.x
const routes = [
  // 动态字段以冒号开始
  { path: '/details/:id', component: ShowGoodsDetail }
]

=> details/42309881817812、details/42309881817813 都会映射到同一个url上,用一个路由组件

调试相关:我们可以用this.$route.params,获取到动态id传入的是什么,更多API参考

特性:动态路由场景下如果是使用的相同的路由组件,那么组件会起到复用的效果,那么组件的生命周期函数不会触发,比如details/42309881817812 => details/42309881817813,使用的是相同的组件,如果在组件生命周期钩子中做了一些逻辑,那就不会走逻辑,会报错的,针对这样的一点我们具有两种解决方案。

  • watch监听参数:表示监听路由参数,变更后会去做一些什么。
  • beforeRouteUpdate:表示在当前路由更新之前,会去做一些什么。

捕获404路由或者匹配所有路由

v3.x里面我们可以通过*来匹配相关路由,比如。

const router = [
    { path:'*', component:ShowGoodsDetails}, // 匹配所有路由
    { path:'details-for-*', component:ShowGoodsDetails} // 匹配以`details-for-`开头的路由
]

// 获取
// 给出一个路由 { path: '/details-*' }
this.$router.push('/details-admin')
this.$route.params.pathMatch // 'admin'
// 给出一个路由 { path: '*' }
this.$router.push('/non-existing')
this.$route.params.pathMatch // '/non-existing'

v3.x里面的通配符*下,会在$route.params内部生成一个pathMatch参数,这个参数是被匹配的那一部分,在v4.x里面我们通过指定/:pathMatch(.*)*来匹配

const routes = [
  // 将匹配所有内容并将其放在 `$route.params.pathMatch` 下
  { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
  // 将匹配以 `/details-` 开头的所有内容,并将其放在 `$route.params.afterUser` 下
  { path: '/details-:afterUser(.*)', component: ShowGoodsDetails },
]

// 获取
this.$router.push({
  name: 'NotFound',
  // 保留当前路径并删除第一个字符,以避免目标 URL 以 `//` 开头。
  params: { pathMatch: this.$route.path.substring(1).split('/') },
  // 保留现有的查询和 hash 值,如果有的话
  query: this.$route.query,
  hash: this.$route.hash,
})

高级匹配

{ path: '/:orderId(\d+)' }, // 自定义正则匹配
{ path: '/:chapters(\d+)+' }, // 匹配多个重复
{ path: '/users/:id', sensitive: true }, // 允许匹配大小写路径
{ path: '/users/:id', strict: true }, //  不允许匹配大小写路径

匹配优先级:如果一个路径能匹配多个路由,优先级按照路由配置表设置顺序。

子路由

子路由配置,由一级路由里面的children字段提供,具体配置如下:

const routes = [
  {
    path: '/user/:id',
    component: User,
    children: [
      // 当 /user/:id 匹配成功
      // UserHome 将被渲染到 User 的 <router-view> 内部
      { path: '', component: UserHome },

      // ...其他子路由
    ],
  },
]

命名规则:当一级路由具有子路由的时候,那么名称应该禅让给子路由,一级路由不提倡书写name字段。

编程式导航

编程式导航中,我们可以借助实例方法来帮助我们实现路由跳转功能,<router-link>的底层原理就是创建a标签来进行跳转的,我们前面说到可以使用$router来获取路由实例,所以我们可以这样操作:

  • push
const username = 'eduardo'
// 我们可以手动建立 url,但我们必须自己处理编码
router.push(`/user/${username}`) // -> /user/eduardo
// 同样
router.push({ path: `/user/${username}` }) // -> /user/eduardo
// 如果可能的话,使用 `name` 和 `params` 从自动 URL 编码中获益
router.push({ name: 'user', params: { username } }) // -> /user/eduardo
// `params` 不能与 `path` 一起使用
router.push({ path: '/user', params: { username } }) // -> /user
  • replace
router.push({ path: '/home', replace: true })
// 相当于
router.replace({ path: '/home' })
  • history相关
// 向前移动一条记录,与 router.forward() 相同
router.go(1)

// 返回一条记录,与 router.back() 相同
router.go(-1)

// 前进 3 条记录
router.go(3)

// 如果没有那么多记录,静默失败
router.go(-100)
router.go(100)

声明式与编程式的关系

<router-link :to="..."> => router.push(...)

<router-link :to="..." replace> = router.replace(...)

路由组件的使用(router-view用来解决一个视图需要多个组件渲染的情况)

  • v3.x
<router-view class="view one"></router-view>
<router-view class="view two" name="a"></router-view>
<router-view class="view three" name="b"></router-view>

const router = new VueRouter({
  routes: [
    {
      path: '/',
      components: {
        default: Foo,
        a: Bar,
        b: Baz
      }
    }
  ]
})
  • v4.x
<router-view class="view left-sidebar" name="LeftSidebar"></router-view>
<router-view class="view main-content"></router-view>
<router-view class="view right-sidebar" name="RightSidebar"></router-view>

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    {
      path: '/',
      components: {
        default: Home, // 注意,不写name的要带上default
        // 它们与 `<router-view>` 上的 `name` 属性匹配
        LeftSidebar,
        RightSidebar,
      },
    },
  ],
})

重定向与别名

  • 重定向
const routes = [{ path: '/home', redirect: '/' }]

const routes = [{ path: '/home', redirect: { name: 'homepage' } }]

const routes = [
  {
    // /search/screens -> /search?q=screens
    path: '/search/:searchText',
    redirect: to => {
      // 方法接收目标路由作为参数
      // return 重定向的字符串路径/路径对象
      // 如果直接return "xxxx",那么相当于是写死的 {path:"xxxx"}
      return { path: '/search', query: { q: to.params.searchText } }
    },
  },
  {
    path: '/search',
    // ...
  },
]
  • 别名
const routes = [
  {
    path: '/users',
    component: UsersLayout,
    children: [
      // 为这 3 个 URL 呈现 UserList
      // - /users
      // - /users/list
      // - /people
      { path: '', component: UserList, alias: ['/people', 'list'] },
    ],
  },
]

路由组件传参

路由组件我们有两种方式为其传参:

  • $route:因为$route是当前的一个路由对象,里面我们可以通过paramsquery等字段为其传参。
  • 组件的props:在路由配置表里面我们可以设置props:true,可以把componentprops作为路由组件的默认参数,传递给路由组件。
const router = new VueRouter({
  routes: [
    { path: '/user/:id', component: User, props: true },

    // 对于包含命名视图的路由,你必须分别为每个命名视图添加 `props` 选项:
    {
      path: '/user/:id',
      components: { default: User, sidebar: Sidebar },
      props: { default: true, sidebar: false }
    },
    // 函数传参
    {
      path: '/search',
      component: SearchUser,
      props: route => ({ query: route.query.q })
    },
    // 对象传参
    {
      path: '/promotion/from-newsletter',
      component: Promotion,
      props: { newsletterPopup: false }
    }
  ]
})

关于路由的两种模式

  • hash模式:hash模式在v3.x里面是默认的,在v4.x里面需要手动开启。
    • 原理:hash模式是基于原生事件onhashchange实现的,hash变则加载不同的文件。
    • 不利于SEO,因为此模式不跟服务器进行交互。
  • history模式:history模式在v3.x里面是需要手动配置的,在v4.x里面也是需要手动配置的。
    • 原理:history模式是基于浏览器事件pushStatereplaceState操作History历史记录栈实现的。

配置形式

// v3.x
const router = new VueRouter({
  // 默认hash  
  // mode: 'history',
  routes: [...]
})

// v4.x
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(), // history: createWebHistory() history模式
  routes: [
    //...
  ],
})

关于v3.xv4.x中的history模式,需要服务端做的支持

  • 为什么要后端做支持?
    • 因为我们知道我们做的是SPA,服务端上只有我们一个根目录地址,我们经过http访问路径,我们会带回路径资源文件,如果走的是二级目录,那么重新刷新,就会造成页面404,因为服务器上并没有这样的路径。
  • 解决办法
    • 我们需要在服务端配置这样的需求,如果访问的路径下没有任何资源,那么需要把这个路径指向默认的index.html页面。
  • 基于原生nodejs解决方案。
const http = require('http')
const fs = require('fs')
const httpPort = 80

http.createServer((req, res) => {
  fs.readFile('index.html', 'utf-8', (err, content) => {
    if (err) {
      console.log('We cannot open "index.html" file.')
    }

    res.writeHead(200, {
      'Content-Type': 'text/html; charset=utf-8'
    })

    res.end(content)
  })
}).listen(httpPort, () => {
  console.log('Server listening on: http://localhost:%s', httpPort)
})
  • 基于nginx的解决方案
location / {
  try_files $uri $uri/ /index.html;
}

路由守卫

  • 全局前置路由守卫(能够处理用户进入页面之前的业务逻辑)
router.beforeEach((to, from, next) => {
  if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' })
  // 如果用户未能验证身份,则 `next` 会被调用两次
  next()
  // return false
  // return {name:'Login'}
})
- from:表示将要离开的页面。
- to:表示将要去的页面。
- false:表示取消当前导航。
- next:表示决定是否要继续进行当前导航操作。
- return {name:'Login'} :返回一个对象,表示重定向到Login页面。
  • 全局后置路由守卫(能够处理用户离开页面之前的业务逻辑)
router.afterEach((to, from, failure) => {
  if (!failure) sendToAnalytics(to.fullPath)
})
// 这里的failure表示路由导航失败,具体参考,这里不再多与述说
// https://router.vuejs.org/zh/guide/advanced/navigation-failures.html
  • 路由独享的守卫(能够处理用户进入之前指定页面的逻辑)
const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      beforeEnter: (to, from, next) => {
        // ...
      }
    }
  ]
})

独享守卫特性:和动态路由一样,如果只是在同一个路径下的queryparamshash改变,那么这个beforeEnter钩子,不会再被触发,如果想对参数做一些什么处理,你可以这样做:

function removeQueryParams(to) {
  if (Object.keys(to.query).length)
    return { path: to.path, query: {}, hash: to.hash }
}

function removeHash(to) {
  if (to.hash) return { path: to.path, query: to.query, hash: '' }
}

const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: [removeQueryParams, removeHash],
  },
  {
    path: '/about',
    component: UserDetails,
    beforeEnter: [removeQueryParams],
  },
]
  • 组件内的守卫(每一个组件应该有的钩子)
const UserDetails = {
  template: `...`,
  beforeRouteEnter(to, from) {
    // 在渲染该组件的对应路由被验证前调用
    // 不能获取组件实例 `this` !
    // 因为当守卫执行时,组件实例还没被创建!
    // 可以通过next回调来访问实例
    next(vm => {
      // 通过 `vm` 访问组件实例
    })
  },
  beforeRouteUpdate(to, from) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 `/users/:id`,在 `/users/1` 和 `/users/2` 之间跳转的时候,
    // 由于会渲染同样的 `UserDetails` 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 因为在这种情况发生的时候,组件已经挂载好了,导航守卫可以访问组件实例 `this`
  },
  beforeRouteLeave(to, from) {
    // 在导航离开渲染该组件的对应路由时调用
    // 与 `beforeRouteUpdate` 一样,它可以访问组件实例 `this`
    // 用于表单提交场景,刷新之前需要用户确认是否保存表单内容信息
    const answer = window.confirm('Do you really want to leave? you have unsaved changes!')
      if (answer) {
        next()
      } else {
        next(false) // 通过false来取消当前导航
      }
  },
}

扩展:如果是用的Vue3setup函数,可以使用onBeforeRouteUpdateonBeforeRouteLeave来达到beforeRouteUpdatebeforeRouteLeave的效果。

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'

export default {
  setup() {
    // 与 beforeRouteLeave 相同,无法访问 `this`
    onBeforeRouteLeave((to, from) => {
      const answer = window.confirm(
        'Do you really want to leave? you have unsaved changes!'
      )
      // 取消导航并停留在同一页面上
      if (!answer) return false
    })

    const userData = ref()

    // 与 beforeRouteUpdate 相同, 但是setup函数中没有`this`,所以不能访问`this`
    onBeforeRouteUpdate(async (to, from) => {
      //仅当 id 更改时才获取用户,例如仅 query 或 hash 值已更改
      if (to.params.id !== from.params.id) {
        userData.value = await fetchUser(to.params.id)
      }
    })
  },
}

路由守卫的触发过程

  1. 导航被触发。
  2. 在失活的组件里调用 beforeRouteLeave 守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫(2.2+)。
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve 守卫(2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。

路由元信息meta

meta字段,能够提供每一个路由的基本信息,在配置路由表的时候我们可以配置meta字段,在实际的场景中我们一般可以用来做业务鉴权处理。

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    // 可以利用meta字段来检测当前业务是否需要登录鉴权,如果没有登录,那就应该重定向到登录页面
    if (!auth.loggedIn()) {
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    } else {
      next()
    }
  } else {
    next() // 确保一定要调用 next(),不然会取消导航造成业务BUG
  }
})

也可以通过TypeScript来扩展meta字段,

关于路由中完成异步请求需求

Vue项目中提供了两种思路。

  • 组件中的生命周期函数中获取数据。
export default {
  data() {
    return {
      loading: false,
      post: null,
      error: null,
    }
  },
  created() {
    // watch 路由的参数,以便再次获取数据
    this.$watch(
      () => this.$route.params,
      () => {
        this.fetchData()
      },
      // 组件创建完后获取数据,
      // 此时 data 已经被 observed 了
      { immediate: true }
    )
  },
  methods: {
    fetchData() {
      this.error = this.post = null
      this.loading = true
      // replace `getPost` with your data fetching util / API wrapper
      getPost(this.$route.params.id, (err, post) => {
        this.loading = false
        if (err) {
          this.error = err.toString()
        } else {
          this.post = post
        }
      })
    },
  },
}
  • 路由守卫中获取数据。
export default {
  data() {
    return {
      post: null,
      error: null,
    }
  },
  beforeRouteEnter(to, from, next) {
    getPost(to.params.id, (err, post) => {
      next(vm => vm.setData(err, post))
    })
  },
  // 路由改变前,组件就已经渲染完了
  async beforeRouteUpdate(to, from) {
    this.post = null
    try {
      this.post = await getPost(to.params.id)
    } catch (error) {
      this.error = error.toString()
    }
  },
}
  • 两种方式都可以实现数据的获取,在页面维度组件维度钩子函数能够提供在流程中的一种劫持功能,区别是在页面导航完成前后的区别。

路由懒加载

  • 基于异步组件实现懒加载。
const Foo = () =>
  Promise.resolve({
    /* 组件定义对象 */
    template: '<div>I am async!</div>'
  })
  • 基于三方工具webpackvite实现懒加载。
    • webpack实现
    
    const Foo = () => import('./Foo.vue')
    const router = new VueRouter({
      routes: [{ path: '/foo', component: Foo }]
    })
    
    // 基于webpack的代码分割功能,我们给每一个组件取一个webpackChunkName,
    这样可以使同一个页面下的组件,打包进一个chunk
    // /* webpackChunkName: "group-foo" */ 魔法注释,给模块生成别名
    const Foo = () => import(/* webpackChunkName: "group-foo" */ './Foo.vue')
    const Bar = () => import(/* webpackChunkName: "group-foo" */ './Bar.vue')
    const Baz = () => import(/* webpackChunkName: "group-foo" */ './Baz.vue')
    
    • vite实现(关于vite,我正在写vite的进阶使用,一起来学习一下吧,->> )
    // vite.config.js
    export default defineConfig({
      build: {
        rollupOptions: {
          // https://rollupjs.org/guide/en/#outputmanualchunks
          output: {
            manualChunks: {
              'group-user': [
                './src/UserDetails',
                './src/UserDashboard',
                './src/UserProfileEdit',
              ],
            },
        },
      },
    })
    

动态路由相关补充

  • 业务背景:针对现有的业务,对其路由配置进行新增、删除、查询,以完成业务。
  • 解决方案:Vue-router提供了addRouteremoveRoutehasRoutegetRoutes来帮助我们解决问题。
router.addRoute({ path: '/about', name: 'about', component: About }) // 添加路由
router.removeRoute('about') // 移除路由
router.addRoute('admin', { path: 'settings', component: AdminSettings }) // 添加嵌套路由
router.hasRoute():检查路由是否存在。
router.getRoutes():获取一个包含所有路由记录的数组。

从vue2到vue3的迁移router.vuejs.org/zh/guide/mi…

源码解读

源码解读分别解析vue3.x4.x的部分源码,因为两个版本中的大部分API是没有动过的,但是不同的是3.x的版本采用的是new VueRouter的方式,而4.x采用的是createRouter的方式。

  • 3.x的方式
import VueRouter from 'vue-router';
Vue.use(VueRouter)
const router = new VueRouter(...);

所以3.x中,具有VueRouter的一个类,类上有一个静态方法install

export declare class VueRouter {
  constructor (options?: RouterOptions); // 当new VueRouter的时候,会被立即调用

  app: Vue; // vm实例
  mode: RouterMode; // "hash" | "history" | "abstract";
  currentRoute: Route; // 当前路由对象
  
  // 路由守卫
  beforeEach (guard: NavigationGuard): Function;
  beforeResolve (guard: NavigationGuard): Function;
  afterEach (hook: (to: Route, from: Route) => any): Function;
  // 方法
  push (location: RawLocation, onComplete?: Function, onAbort?: Function): void;
  replace (location: RawLocation, onComplete?: Function, onAbort?: Function): void;
  go (n: number): void;
  back (): void;
  forward (): void;
  getMatchedComponents (to?: RawLocation | Route): Component[];
  onReady (cb: Function, errorCb?: Function): void;
  onError (cb: Function): void;
  addRoutes (routes: RouteConfig[]): void;
  resolve (to: RawLocation, current?: Route, append?: boolean): {
    location: Location;
    route: Route;
    href: string;
    // backwards compat
    normalizedTo: Location;
    resolved: Route;
  };
  
  // 静态方法install,本身是一个插件,用于安装安装自己
  static install: PluginFunction<never>;
}

既然这里说到了install方法,那我们就一起来看看install方法的实现,以便于以后自己写Vue插件。

install

export let _Vue
export function install (Vue) {
  // install 传入整个Vue实例
  // 刚开始进来installed与_Vue都是undefined
  // 这里也就做了检验,如果已经安装过了并且传入的是Vue实例,就return
  if (install.installed && _Vue === Vue) return
  install.installed = true // 避免二次刷新调用install方法

  _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实例中去
  Vue.mixin({
    // 在生命周期beforeCreate注册 
    beforeCreate () {
      // 如果存在$options.router,遍绑定this、router并且执行init方法
      if (isDef(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 {
        ...
      }
      // 注册实例
      registerInstance(this, this)
    },
    // 在生命周期destroyed销毁
    destroyed () {
      registerInstance(this)
    }
  })
  
  // 当访问原型上的router的时候,返回整个router实例数组
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  // 当访问原型上的route的时候,返回当前路由对象
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

  // Vue注册组件<router-link>、<router-view>  
  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
}

所以VueRouter实现了install方法,在install方法中把路由混入到生命周期钩子中,并调用init方法,处理部分逻辑,如果对混入不了解的同学,可以看看vue的mixin混入

  • 4.x的方式
const router = VueRouter.createRouter({ 
    history: VueRouter.createWebHashHistory(), // 路由模式,其他的使用createWebHistory 
    routes, // `routes: routes` 的缩写 
})

createRouter

createRouter做了一些配置路由表的合并、路由模式的处理等最后返回了一个带有install等方法的router,这里的install方法跟3.x版本的方法做的的功能差不多,只不过这里的router是被挂在了vmglobalProperties上。

image.png

  • createRouterMatcher执行完后,会返回的5个函数{ addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
  • 创建全局路由守卫,beforeGuardsbeforeResolveGuardsafterGuards,通过守卫.add方法暴露给路由钩子。
  • 创建一些普通方法,比如pushreplace
  • 通过router暴露出去,给createRouter使用。 createRouter大致的流程如下:

image.png

上面就是v3.xv4.x的创建Router的一个区别,另外我们还可以看到关于modehistory的变化,那么关于v4.x里面的三种history

  • "history" => createWebHistory()
export function createWebHistory(base?: string): RouterHistory {
  base = normalizeBase(base)
  // 创建vue-router的history对象
  const historyNavigation = useHistoryStateNavigation(base)
  // 创建vue-router的historyListeners侦听器
  const historyListeners = useHistoryListeners(
    base,
    historyNavigation.state,
    historyNavigation.location,
    historyNavigation.replace
  )
  // 实现go方法
  function go(delta: number, triggerListeners = true) {
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta)
  }
  // 合并routerHistory对象
  const routerHistory: RouterHistory = assign(
    {
      // it's overridden right after
      location: '',
      base,
      go,
      createHref: createHref.bind(null, base),
    },

    historyNavigation,
    historyListeners
  )
  // 劫持location属性,访问的时候返回value
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => historyNavigation.location.value,
  })
  // 劫持state属性,访问的时候返回value
  Object.defineProperty(routerHistory, 'state', {
    enumerable: true,
    get: () => historyNavigation.state.value,
  })
  // 返回routerHistory对象
  return routerHistory // => 如果是createRouter({history:createWebHistory,routes})
}

创建的historyNavigation包含了基于h5history对象的pushStatereplaceState方法、state对象重新封装的pushreplace方法和state对象,基于window.location处理的location,具体长这样。

const historyNavigation = {
    location, // window.location
    state, // history.state
    push, // => function push (...){changeLocation(...);changeLocation(...)} => history.pushState
    replace // => function replace (...){changeLocation(...)} => history.replaceState
}

changeLocation

那么这几种自定义的属性,方法都是经过changeLocation特殊处理的,我们来看一看这个方法。

 function changeLocation(
    to: HistoryLocation,
    state: StateEntry,
    replace: boolean
  ): void {
    // 是否是hash模式?
    const hashIndex = base.indexOf('#')
    
    const url =
      // 是hash => createBaseLocation() + base + to
      // 不是hash => base.slice(hashIndex)) + to
      hashIndex > -1
        ? (location.host && document.querySelector('base')
            ? base
            : base.slice(hashIndex)) + to
        // createBaseLocation => location.protocol + '//' + location.host    
        : createBaseLocation() + base + to
    try {
      // BROWSER QUIRK
      // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
      history[replace ? 'replaceState' : 'pushState'](state, '', url)
      historyState.value = state
    } catch (err) {
      if (__DEV__) {
        warn('Error with push/replace State', err)
      } else {
        console.error(err)
      }
      // Force the navigation, this also resets the call count
      location[replace ? 'replace' : 'assign'](url)
    }
  }

根据base根路径计算最终的跳转url,然后根据replace标记决定使用history.pushState 或 history.replaceState进行跳转。如果是hash的话则会调用createBaseLocation生成url也进行history.pushState 或 history.replaceState跳转。

useHistoryListeners

经过上面的分析,知道了怎么样跳转了,但是在函数里面,还创建了路由监听器,因为h5中,history.gohistory.forwardhistory.back会触发原生popState事件,所以这里也对此做了处理。

function useHistoryListeners(
  base: string,
  historyState: ValueContainer<StateEntry>,
  currentLocation: ValueContainer<HistoryLocation>,
  replace: RouterHistory['replace']
) {
  // 监听器事件池
  let listeners: NavigationCallback[] = []
  let teardowns: Array<() => void> = []
  // 保存取消时候的状态
  let pauseState: HistoryLocation | null = null
  
  // 
  const popStateHandler: PopStateListener = ({
    state,
  }: {
    state: StateEntry | null
  }) => {
    const to = createCurrentLocation(base, location) // 新地址
    const from: HistoryLocation = currentLocation.value // 当前地址
    const fromState: StateEntry = historyState.value // 当前路由state
    let delta = 0 // 计步器,用来记录一次操作,to与from的次数

    if (state) {
      currentLocation.value = to // to路由state不为空时,更新currentLocation和historyState缓存
      historyState.value = state

      // 暂停监听,重置pauseState
      if (pauseState && pauseState === from) {
        pauseState = null
        return
      }
      delta = fromState ? state.position - fromState.position : 0
    } else {
      // 为空,直接跳
      replace(to)
    }

    // console.log({ deltaFromCurrent })
    // Here we could also revert the navigation by calling history.go(-delta)
    // this listener will have to be adapted to not trigger again and to wait for the url
    // to be updated before triggering the listeners. Some kind of validation function would also
    // need to be passed to the listeners so the navigation can be accepted
    // call all listeners
    // 给所有订阅者注册跳转事件回调
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta, // 步数
        type: NavigationType.pop, // 类型
        direction: delta // 跳转距离
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      })
    })
  }
  // 停止监听
  function pauseListeners() {
    pauseState = currentLocation.value
  }
  // 注册监听回调,发布订阅  
  function listen(callback: NavigationCallback) {
    // set up the listener and prepare teardown callbacks
    listeners.push(callback)
    // 执行回调
    const teardown = () => {
      const index = listeners.indexOf(callback)
      if (index > -1) listeners.splice(index, 1)
    }

    teardowns.push(teardown)
    return teardown
  }
  // 离开、关闭页面监听器,与原生事件beforeunload绑定
  function beforeUnloadListener() {
    const { history } = window
    if (!history.state) return
    history.replaceState(
      assign({}, history.state, { scroll: computeScrollPosition() }),
      ''
    )
  }
  // 清空监听器,卸载监听器
  function destroy() {
    for (const teardown of teardowns) teardown()
    teardowns = []
    window.removeEventListener('popstate', popStateHandler)
    window.removeEventListener('beforeunload', beforeUnloadListener)
  }

  // set up the listeners and prepare teardown callbacks
  // 绑定popstate -> popStateHandler, beforeunload -> beforeUnloadListener 
  // 页面关闭触发beforeunload
  window.addEventListener('popstate', popStateHandler)
  window.addEventListener('beforeunload', beforeUnloadListener)

  // 暴露出三个方法
  return {
    pauseListeners, // 停止监听
    listen, // 注册监听回调
    destroy, // 卸载监听器
  }
}

关于路由监听器popStateHandler会做两件事。

  1. 更新historylocationstate
  2. 将数据挂起,通知所有订阅者注册回调。

关于路由监听器beforeUnloadListener主要是记录页面离开之前所在的位置。

function go(delta: number, triggerListeners = true) {
  if (!triggerListeners) historyListeners.pauseListeners()
  history.go(delta) // 跳转
}

const routerHistory: RouterHistory = assign(
  {
    location: '',
    base,
    go,
    createHref: createHref.bind(null, base),  
  },  
  historyNavigation,
  historyListeners  
)
// 劫持location
Object.defineProperty(routerHistory, 'location', {
  enumerable: true,
  get: () => historyNavigation.location.value,
})

// 劫持state
Object.defineProperty(routerHistory, 'state', {
  enumerable: true,
  get: () => historyNavigation.state.value,
})

// 暴露routerHistory
return routerHistory
  • "hash" => createWebHashHistory()
export function createWebHashHistory(base?: string): RouterHistory {
  // Make sure this implementation is fine in terms of encoding, specially for IE11
  // for `file://`, directly use the pathname and ignore the base
  // location.pathname contains an initial `/` even at the root: `https://example.com`
  base = location.host ? base || location.pathname + location.search : ''
  // allow the user to provide a `#` in the middle: `/base/#/app`
  
  // 如果您使用createWebHashhistory,那么自动在base上加一个'#'
  // 在createWebHistory中,hash模式会调用createBaseLocation => url = location.protocol + 
  // '//' + location.host + base + to
  if (!base.includes('#')) base += '#'
  // https://www.baidu.com/#  
  if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
    warn(
      `A hash base must end with a "#":\n"${base}" should be "${base.replace(
        /#.*$/,
        '#'
      )}".`
    )
  }
  return createWebHistory(base) // => 如果是createRouter({history:createWebHashHistory,routes})
}
  • "abstract" => createMemoryHistory()

image.png

另外我们看到router-linkrouter-view,也看看他们的具体实现吧,这里会调用Vue.Component方法来注册一个组件标签。

router-link

所以根据app.componentVue.component的原理来说,都是接受两个参数,那么在Vue.component中采用的是Vue.extend来返回组件的,在app.component中利用的是context.component来返回组件的。

  • 3.x
Vue.component('RouterLink', Link);

var Link = {
  name: 'RouterLink', // 标签名
  props: {
    to: {
      type: toTypes,
      required: true
    }, // 属性
    tag: {
      type: String,
      default: 'a'
    }, // 标签类型
    custom: Boolean, // 默认为false,用于把<router-link><router-link>中间内容用a标签包裹起来
    exact: Boolean, // 精准匹配
    exactPath: Boolean,
    append: Boolean, // /a -> /b => /a/b
    replace: Boolean, // 替换路径,不会留下导航记录
    activeClass: String, // 激活的导航类名
    exactActiveClass: String, // 精准匹配时候激活的类类名
    ariaCurrentValue: {
      type: String,
      default: 'page'
    },
    event: {
      type: eventTypes,
      default: 'click'
    } // 事件
  },
  // h => $createElement
  render (h) {
    const router = this.$router; // 实例对象
    const current = this.$route; // 当前路由对象
    const { location, route, href } = router.resolve(
      this.to,
      current,
      this.append
    );
   
    // 处理跳转函数
    const handler = e => {
      if (guardEvent(e)) {
        if (this.replace) {
          router.replace(location, noop);
        } else {
          router.push(location, noop);
        }
      }
    };
    ...
    // 绑定事件
    const on = { click: guardEvent };
    if (Array.isArray(this.event)) {
      this.event.forEach(e => {
        on[e] = handler;
      });
    } else {
      on[this.event] = handler;
    }

    const data = { class: classes };
    ...
    // 创建虚拟节点
    return h(this.tag, data, this.$slots.default)
  }
};

router-view

  • v3.x
var View = {
  name: 'RouterView',
  functional: true,
  props: {
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    ...
    const h = parent.$createElement;
    const name = props.name; // 组件名
    const route = parent.$route; // 当前路由对象
    const cache = parent._routerViewCache || (parent._routerViewCache = {}); // 缓存路由组件
    ...
    const component = matched && matched.components[name];
    ...
    // 通过$createElement创建虚拟节点
    return h(component, data, children)
  }
};

keep-alive

export default {
  name: 'keep-alive', // 组件名,存在于Vue源码中,而不是在VueRouter源码中
  abstract: true,

  // 属性
  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [String, Number]
  },

  methods: {
    cacheVNode() {
      // 调用cacheVNode
      const { cache, keys, vnodeToCache, keyToCache } = this
      
      if (vnodeToCache) {
        const { tag, componentInstance, componentOptions } = vnodeToCache
        // cache对缓存组件的映射
        cache[keyToCache] = {
          name: _getComponentName(componentOptions),
          tag,
          componentInstance
        }
        keys.push(keyToCache)
        // prune oldest entry
        // 如果缓存中的组件个数,超过了限制
        if (this.max && keys.length > parseInt(this.max)) {
          // 则从缓存中删除第一个,LRU算法。  
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
        this.vnodeToCache = null
      }
    }
  },
  // created钩子会创建一个cache对象,用来作为缓存容器,保存vnode节点。
  created() {
    this.cache = Object.create(null)
    this.keys = []
  },
  // destroyed钩子则在组件被销毁的时候清除cache缓存中的所有组件实例。
  destroyed() {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted() {
    // 组件挂载的时候会触发cacheVNode,并且监听include、exclude
    this.cacheVNode()
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  updated() {
    // 触发updated,调用cacheVNode
    this.cacheVNode()
  },

  render() {
    // 获取插槽里面的内容,以第一个为标准
    const slot = this.$slots.default
    const vnode = getFirstComponentChild(slot)
    // 获取componentOptions
    const componentOptions = vnode && vnode.componentOptions
    
    if (componentOptions) {
      // check pattern
      // 获取组件的name,如果有name就返回name,没有就返回tag
      const name = _getComponentName(componentOptions)
      const { include, exclude } = this
      
      if (
        // not included
        // 如果与include匹配上了,或者与exclude不匹配,表示不缓存
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        // 直接返回vnode
        return vnode
      }

      const { cache, keys } = this
      // 取到组件名,用来处理缓存命中问题
      const key =
        vnode.key == null
          ? // same constructor may get registered as different local components
            // so cid alone is not enough (#3269)
            componentOptions.Ctor.cid +
            (componentOptions.tag ? `::${componentOptions.tag}` : '')
          : vnode.key
          
      if (cache[key]) {
        // 如果命中缓存,则从缓存中拿到组件实例
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest
        remove(keys, key) // 从cache中删除当前key
        keys.push(key) // 再加入到第一位
      } else {
        // 如果没有命中,将其设置进cache
        // delay setting the cache until update
        this.vnodeToCache = vnode
        this.keyToCache = key
      }

      // 设置组件为keepAlive
      vnode.data.keepAlive = true
    }
    // 返回vnode => 被缓存的组件渲染不会再次触发created,mounted,会触发updated
    return vnode || (slot && slot[0])
  }
}

所以keep-alive的底层实现,就是依赖于生命周期钩子函数,在created中创建了cache容器,在mountedupdated中根据includeexclude匹配规则对容器进行数据调动,数据调用时基于LRU算法的。

image.png

总结

这一章我们讲解了VueRouter的基本用法,install方法,RouterLinkRouterViewKeepAlive的实现原理等,下一章我们将会去探索更多的功能。