vue-router 源码:路由模式

4,245 阅读1分钟

前言

前端的路由模式包括了 Hash 模式和 History 模式。

vue-router 在初始化的时候,会根据 mode 来判断使用不同的路由模式,从而 new 出了不同的对象实例。例如 history 模式就用 HTML5History,hash 模式就用 HashHistory

init (app: any /* Vue component instance */) {
  this.app = app

  const { mode, options, fallback } = this
  switch (mode) {
    case 'history':
      this.history = new HTML5History(this, options.base)
      break
    case 'hash':
      this.history = new HashHistory(this, options.base, fallback)
      break
    case 'abstract':
      this.history = new AbstractHistory(this)
      break
    default:
      assert(false, `invalid mode: ${mode}`)
  }

  this.history.listen(route => {
    this.app._route = route
  })
}

本次重点来了解一下 HTML5HistoryHashHistory 的实现。

HashHistory

vue-router 通过 new 一个 HashHistory 来实现 Hash 模式路由。

this.history = new HashHistory(this, options.base, fallback)

三个参数分别代表:

  • this:Router 实例
  • base:应用的基路径
  • fallback:History 模式,但不支持 History 而被转成 Hash 模式

HashHistory 继承 History 类,有一些属性与方法都来自于 History 类。先来看下 HashHistory 的构造函数 constructor。

constructor

构造函数主要做了四件事情。

  1. 通过 super 调用父类构造函数,这个先放一边。
  2. 处理 History 模式,但不支持 History 而被转成 Hash 模式的情况。
  3. 确保 # 后面有斜杠,没有则加上。
  4. 实现跳转到 hash 页面,并监听 hash 变化事件。
constructor (router: VueRouter, base: ?string, fallback: boolean) {
  super(router, base)

  // check history fallback deeplinking
  if (fallback && this.checkFallback()) {
    return
  }

  ensureSlash()
  this.transitionTo(getHash(), () => {
    window.addEventListener('hashchange', () => {
      this.onHashChange()
    })
  })
}

下面细讲一下这几件事情的细节。

checkFallback

先来看构造函数做的第二件事情,fallback 为 true 的情况,一般是低版本的浏览器(IE9)不支持 History 模式,所以会被降级为 Hash 模式。

同时需要通过 checkFallback 方法来检测 url。

checkFallback () {
  // 去掉 base 前缀
  const location = getLocation(this.base)

  // 如果不是以 /# 开头
  if (!/^\/#/.test(location)) {
    window.location.replace(
      cleanPath(this.base + '/#' + location)
    )
    return true
  }
}

先通过 getLocation 方法来去掉 base 前缀,接着正则判断 url 是否以 /# 为开头。如果不是,则将 url 替换成以 /# 为开头。最后跳出 constructor,因为在 IE9 下以 Hash 方式的 url 切换路由,它会使得整个页面进行刷新,后面的监听 hashchange 不会起作用,所以直接 return 跳出。

再来看看 checkFallback 里面调用的 getLocationcleanPath 方法的实现。

getLocation 方法主要是去掉 base 前缀。在 vue-router 官方文档里搜索 base,可以知道它是应用的基路径

export function getLocation (base: string): string {
  let path = window.location.pathname
  if (base && path.indexOf(base) === 0) {
    path = path.slice(base.length)
  }
  return (path || '/') + window.location.search + window.location.hash
}

cleanPath 方法则是将双斜杠替换成单斜杠,保证 url 路径正确。

export function cleanPath (path: string): string {
  return path.replace(/\/\//g, '/')
}

ensureSlash

接下来来看看构造函数做的第三件事情。

ensureSlash 方法做的事情就是确保 url 根路径带上斜杠,没有的话则加上。

function ensureSlash (): boolean {
  const path = getHash()
  if (path.charAt(0) === '/') {
    return true
  }
  replaceHash('/' + path)
  return false
}

ensureSlash 通过 getHash 来获取 url 的 # 符号后面的路径,再通过 replaceHash 来替换路由。

function getHash (): string {
  // We can't use window.location.hash here because it's not
  // consistent across browsers - Firefox will pre-decode it!
  const href = window.location.href
  const index = href.indexOf('#')
  return index === -1 ? '' : href.slice(index + 1)
}

由于 Firefox 浏览器的原因(源码注释里已经写出来了),所以不能通过 window.location.hash 来获取,而是通过 window.location.href 来获取。

function replaceHash (path) {
  const i = window.location.href.indexOf('#')
  window.location.replace(
    window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
  )
}

replaceHash 方法做的事情则是更换 # 符号后面的 hash 路由。

onHashChange

最后看看构造函数做的第四件事情。

this.transitionTo(getHash(), () => {
  window.addEventListener('hashchange', () => {
    this.onHashChange()
  })
})

transitionTo 是父类 History 的一个方法,比较的复杂,主要是实现了 守卫导航 的功能。这里也暂时先放一放,以后再深入了解。

接下来的是监听 hashchange 事件,当 hash 路由发生的变化,会调用 onHashChange 方法。

onHashChange () {
  if (!ensureSlash()) {
    return
  }
  this.transitionTo(getHash(), route => {
    replaceHash(route.fullPath)
  })
}

当 hash 路由发生的变化,即页面发生了跳转时,首先取保路由是以斜杠开头的,然后触发守卫导航,最后更换新的 hash 路由。

HashHistory 还分别实现了 pushreplacego 等编程式导航,有兴趣可以直接看源码,这里就不一一讲解了,主要也是运用了上面的方法来实现。

HTML5History

vue-router 通过 new 一个 HTML5History 来实现 History 模式路由。

this.history = new HTML5History(this, options.base)

HTML5History 也是继承与 History 类。

constructor

HTML5History 的构造函数做了这么几件事情:

  1. 调用父类 transitionTo 方法,触发守卫导航,以后细讲。
  2. 监听 popstate 事件。
  3. 如果有滚动行为,则监听滚动条滚动。
constructor (router: VueRouter, base: ?string) {
  super(router, base)

  this.transitionTo(getLocation(this.base))

  const expectScroll = router.options.scrollBehavior
  window.addEventListener('popstate', e => {
    _key = e.state && e.state.key
    const current = this.current
    this.transitionTo(getLocation(this.base), next => {
      if (expectScroll) {
        this.handleScroll(next, current, true)
      }
    })
  })

  if (expectScroll) {
    window.addEventListener('scroll', () => {
      saveScrollPosition(_key)
    })
  }
}

下面细讲一下这几件事情的细节。

scroll

先从监听滚动条滚动事件说起吧。

window.addEventListener('scroll', () => {
  saveScrollPosition(_key)
})

滚动条滚动后,vue-router 就会保存滚动条的位置。这里有两个要了解的,一个是 saveScrollPosition 方法,一个是 _key

const genKey = () => String(Date.now())
let _key: string = genKey()

_key 是一个当前时间戳,每次浏览器的前进或后退,_key 都将作为参数传入,从而跳转的页面也能获取到。那么 _key 是做什么用呢。

来看看 saveScrollPosition 的实现就知道了:

export function saveScrollPosition (key: string) {
  if (!key) return
  window.sessionStorage.setItem(key, JSON.stringify({
    x: window.pageXOffset,
    y: window.pageYOffset
  }))
}

vue-router 将滚动条位置保存在 sessionStorage,其中的键就是 _key 了。

所以每一次的浏览器滚动,滚动条的位置将会被保存在 sessionStorage 中,以便后面的取出使用。

popstate

浏览器的前进与后退会触发 popstate 事件。这时同样会调用 transitionTo 触发守卫导航,如果有滚动行为,则调用 handleScroll 方法。

handleScroll 方法代码比较多,我们先来看看是怎么使用滚动行为的。

scrollBehavior (to, from, savedPosition) {
  if (savedPosition) {
    return savedPosition
  } else {
    return { x: 0, y: 0 }
  }
}

如果要模拟“滚动到锚点”的行为:

scrollBehavior (to, from, savedPosition) {
  if (to.hash) {
    return {
      selector: to.hash
    }
  }
}

所以至少有三个要判断,一个是 savedPosition(即保存的滚动条位置),一个是 selector,还有一个就是 xy 坐标。

再来看 handleScroll(删掉一些判断):

handleScroll (to: Route, from: Route, isPop: boolean) {
  const router = this.router
  const behavior = router.options.scrollBehavior

  // wait until re-render finishes before scrolling
  router.app.$nextTick(() => {
    let position = getScrollPosition(_key)
    const shouldScroll = behavior(to, from, isPop ? position : null)
    if (!shouldScroll) {
      return
    }
    const isObject = typeof shouldScroll === 'object'
    if (isObject && typeof shouldScroll.selector === 'string') {
      const el = document.querySelector(shouldScroll.selector)
      if (el) {
        position = getElementPosition(el)
      } else if (isValidPosition(shouldScroll)) {
        position = normalizePosition(shouldScroll)
      }
    } else if (isObject && isValidPosition(shouldScroll)) {
      position = normalizePosition(shouldScroll)
    }

    if (position) {
      window.scrollTo(position.x, position.y)
    }
  })
}

从 if 判断开始,如果有 selector,则获取对应的元素的坐标。

否则,则使用 scrollBehavior 返回的值作为坐标,其中有可能是 savedPosition 的坐标,也有可能是自定义的 xy 坐标。

通过一系列校验后,最终调用 window.scrollTo 方法来设置滚动条位置。

其中有三个方法用来对坐标进行处理的,分别是:

  • getElementPosition:获取元素坐标
  • isValidPosition:验证坐标是否有效
  • normalizePosition:格式化坐标

代码量不大,具体的代码细节感兴趣的可以看一下。

同样,HTML5History 也分别实现了 pushreplacego 等编程式导航。

最后

至此,HashHistory 和 HTML5History 的实现就大致了解了。在阅读的过程中,我们不断地遇到了父类 History 与其 transitionTo 方法,下一篇就来对其进行深入了解吧。