# 路由切换

# 三种路由模式

Vue-Router 为我们提供了三种路由模式:hash、history 和 abstract。其中 abstract 是用于非浏览器环境中的,很少被使用,我们不讨论此模式。

hash 路由的特点在于路径中存在 #,这种形式在前端又被成为锚点。值得注意的是,此模式下向服务器请求时,URL 中 # 后的内容不会被带到服务端,所以 hash 路由不支持服务端渲染。此外,此模式下的 URL 很丑(因为有 #,官方文档直言很丑 😂 ,不是我说的),所以一般情况下我们会优先使用 history 模式。

history 路由中不存在 #,且请求时完整的路径可以被带到后端,支持服务端渲染。下面我们来看看 Vue-Router 是如何区分这三种模式的:


    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
    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}`)
        }
    }
  1. 首先判断用户是否指定路由模式,若未指定,则默认使用 hash 模式
  2. 若为 history 模式,如果浏览器不支持 history,则降级为 hash 模式;若此时不处于浏览器环境中,则降级为 abstract 模式
  3. 最后分情况,根据不同的路由模式,创建不同的 history 对象

# $route 和 $router

关于 history 对象,我们可以简单理解为,管理路由、挂载了路由信息的一个对象。不同模式下创建的路由对象不同,但差异不大,主要差异在于路由跳转时如何修改路径参数。

由于源码中此处代码较为冗长,本节讲解 history 对象采用的代码来自于 mini-vue-router3 中。

history 对象定义于 history 文件夹下,其中 base 文件夹中定义的 history 类是所有模式的 history 对象的父类,其中定义了一系列公共方法:

class history {
    constructor(router) {
        this.router = router

        this.current = createRoute(null, '/')
    }

    transitionTo(location, callback) {

        let record = this.router.match(location)

        let route = createRoute(record, location)

        let queue = this.router.beforeHooks
        // 跳转的路径和匹配结果一致
        if (location === this.current.path && route.matched.length === this.current.matched.length) {
            // 拦截重复跳转
            return
        }

        // 执行前置路由守卫
        runQueue(this, queue, this.current, route, () => {

            // 每次跳转,都要更新 current
            this.current = route
            if (callback) callback()

            // 更新 _route 的值
            this.cb && this.cb(route)

            let afterQueue = this.router.afterHooks

            // 执行后置路由守卫
            runQueue(this, afterQueue, this.current, route, () => { })

        })


    }

    listen(cb) {
        this.cb = cb
    }
}

其中比较重要的函数是 transitionTo,所有的跳转逻辑均需要经过此函数。我们后面会详细讲解。

值得一提的是 history 上挂载的两个属性:router 和 current,十分重要。其实他们就是我们常用的 $router$route。因为我们使用 defineproperty 做了代理:

    Object.defineProperty(Vue.prototype, '$router', {
        get() {
            // 代理一层,不通过 this._routerRoot._router 来访问路由,而是通过 this.$router 的便捷方式
            return this._routerRoot && this._routerRoot._router
        }
    })

    Object.defineProperty(Vue.prototype, '$route', {
        get() {
            // 代理一层,不通过 this._routerRoot._route 来访问路由,而是通过 this.$route 的便捷方式
            return this._routerRoot && this._routerRoot._route
        }
    })

this._routerRoot._route 其实就是为 current 添加了响应式之后的结果:

// 将 current 对象定义为响应式,并挂载到根实例上,可以通过 this._routerRoot._route 拿到
Vue.util.defineReactive(this, '_route', this._router.history.current)

$router 和 $route 的区别:

  • $router 是全局路由实例,主要挂载了路由相关的一些方法,比如路由跳转方法和路由守卫钩子等
  • $route 叫做路由信息对象,包含当前路由的一些基本信息,比如name、meta、path、hash、query、params 等等。

# 路由的跳转

路由的跳转,主要是调用了 push 方法:

  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.push(location, resolve, reject)
      })
    } else {
      this.history.push(location, onComplete, onAbort)
    }
  }

此函数接收1-3个参数,onComplete 为跳转完成的回调,onAbort 为跳转失败的回调。若没有传入 onComplete 和 onAbort, 且浏览器不支持 promise, 则直接调用 this.history.push(location, onComplete, onAbort),否则返回一个 Promise 对象,这个 Promise 在导航成功时被解析,失败时被拒绝。

histroy 的 push 方法在不同路由模式下的实现不同,下面我们以 hash 模式为例:

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
    )
  }

可以看到,主要是调用了 transitionTo 方法,并在回调中执行了 pushHash 方法。而 pushHash 的底层其实就是调用了 pushState 方法:

export function pushState (url?: string, replace?: boolean) {
  const history = window.history
  try {
    if (replace) {
      history.replaceState({ key: _key }, '', url)
    } else {
      _key = genKey()
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

所以,归根结底,还是使用了浏览器的 replaceState 和 pushState 来做的。

  • replaceState:在不添加新的历史记录的情况下替换当前页面的 URL
  • pushState:会向浏览器的历史记录栈中添加一个新的记录,因此,当用户点击浏览器的后退按钮时,会回到之前的 URL。

# Vue Router 的响应式

Vue Router 中的响应式主要在于路由变化,需要自动渲染新路由对应的组件。

首先,如何监听到路由的变化?

对于这个问题 hash 和 history 模式的实现不同,在 Vue Router 3 以前,hash 模式监听路由变化采用的是监听 hashChange 的方式,而在 Vue3 以后,监听的是和 history 相同的 popState 事件。

值得注意的是:

我们调用 pushState 和 replaceState 是不会触发 popState 事件的!只有用户点击浏览器前进 / 后退按钮操作历史记录时,才会触发!所以在使用 popState 时,我们需要在 pushState 和 replaceState 后手动调用一次 transitionTo 方法。若是直接监听 hash 值的变化则不必如此。

// hash
    /**
     * 创建监听器,监听hash值的变化
     */
    setupListeners() {
        window.addEventListener('hashchange', () => {
            this.transitionTo(getHash())
        })
    }

// history
    /**
    * 创建监听器,监听hash值的变化
    */
    setupListeners() {
        window.addEventListener('popstate', function () {
            this.transitionTo(getPathName())
        })
    }

其次,路由变化时如何实现组件的切换?

这里就要用到响应式了,在前面我们也提到过,history 上的 current 属性被我们使用 Vue 提供的工具方法 defineReactive 定义为了响应式属性

// 将 current 对象定义为响应式,并挂载到根实例上,可以通过 this._routerRoot._route 拿到
Vue.util.defineReactive(this, '_route', this._router.history.current)

这个属性保存了当前路由的相关信息,而每次页面切换时都需要走 history.transitionTo 方法,在此方法内,我们会对 this.current 进行更新:

    transitionTo(location, callback) {

        let record = this.router.match(location)

        let route = createRoute(record, location)

        let queue = this.router.beforeHooks
        // 跳转的路径和匹配结果一致
        if (location === this.current.path && route.matched.length === this.current.matched.length) {
            // 拦截重复跳转
            return
        }

        // 执行前置路由守卫
        runQueue(this, queue, this.current, route, () => {

            // 每次跳转,都要更新 current
            this.current = route
            if (callback) callback()

            // 更新 _route 的值
            this.cb && this.cb(route)

            let afterQueue = this.router.afterHooks

            // 执行后置路由守卫
            runQueue(this, afterQueue, this.current, route, () => { })

        })
    }

因为 current 为响应式数据,所以 current 更新,会导致视图重新渲染。