相信用过 Vue 框架的朋友,一定听过 Vue Router。要想写一个复杂的单页应用,没有路由可不行。
Vue Router 默认开启的是 hash 模式,如果我们不想让浏览器中的 URL 包含影响美观的 #
,我们可以开启 history 模式。不过这种 history 的模式稍微复杂一点,需要后端服务器配合。
学东西,最好是知其然,知其所以然。本文将从源码的角度分析 hash 与 history 这两种模式的实现,本文基于 Vue Router 3.5.1 版本。
单页应用
当我们在浏览器地址栏输入一个地址时,浏览器就会去服务端去请求内容。但每次点击一个链接,就去服务端请求,这样会有页面加载的等待。
后来慢慢就出现了单页应用,在第一次访问时,就把 html 文件,以及其他静态资源都请求到了客户端。之后的操作,只是利用 js 实现组件的展示和隐藏。除非需要刷新数据,才会利用 ajax 去请求。
但是纯粹的单页应用不方便管理,尤其是开发复杂应用的时候,需要有“多页面”的概念,并且很多用户习惯浏览器的前进后退的导航功能。
能不能有一种方法,可以在不向服务器发送请求的条件下,改变浏览器的 URL,以此来实现“多页面”概念?
答案是有,Vue Router 就是官方开发的一个插件,专门来做这件事。
URL 相关 API
最早改变 URL,但不向服务器发送请求的方式就是 hash。比如这种:
https://music.163.com/#/discover/toplist
同时浏览器也提供了一个事件来监听 hash 的改变,当 URL 的片段标识符更改时,将触发 hashchange 事件 (跟在#符号后面的URL部分,包括#符号)。
window.addEventListener('hashchange', function() {
console.log('The hash has changed!')
}, false);
后来 HTML5 发布,history 对象又增加了两个方法,用来改变浏览器的 URL。只是改变浏览器的访问记录栈,但是不会向服务器发起请求。
- history.pushState(state, title[, url]):该方法会向浏览器会话的历史堆栈中添加一个状态。
- history.replaceState(stateObj, title[, url]):该方法与上一个方法类似,但区别是它会在历史堆栈中替换掉当前的记录。
下面给出一个方法示例,具体参数细节查看这里。
const state = {
'page_id': 1, 'user_id': 5 }
const title = ''
const url = 'hello-world.html'
history.pushState(state, title, url)
当我们调用 history 对象这两个方法时,会触发 popstate 事件,但不会触发 hashchange 事件。另外,在调用 pushState 方法后,我们点击浏览器的前进和后退按钮也会触发 popstate 事件。
不过 history 模式有个缺点,一旦我们点击了浏览器的刷新按钮,这时候会真正向服务器发起请求。当前处于首页还好,如果是其它页面,页面会报 404。所以,这种模式需要服务器端配置路由规则,防止出现 404 的情况。
源码中 hash 与 history 模式
Vue Router 源码中有一个 history 目录,有 4 个文件。
- base.js:history 基类
- hash.js:hash 模式
- html5.js:history 模式
- abstract.js:js 模拟历史堆栈,用于服务器端(这里不作分析)
为了帮助大家更好的理解 Vue Router,本文也会讲一些 hash 与 history 模式之外的内容。
我们先来看一看 VueRouter 对象,为了方便查看,这里只列了构造函数部分。
export default class VueRouter {
constructor (options: RouterOptions = {
}) {
this.app = null
this.apps = []
this.options = options
this.beforeHooks = []
this.resolveHooks = []
this.afterHooks = []
this.matcher = createMatcher(options.routes || [], this)
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}`)
}
}
}
}
VueRouter 接受一个 options 参数,我们使用时,会把 routes 配置传递进去。
VueRouter 首先根据 routes 配置文件,创建一个 matcher 对象。然后根据 mode 模式实例化不同的 history 对象,这里也是本文关注的重点。
VueRouter 整个构造函数简洁清晰,阅读起来十分友好。
我们先来看一看 hash history 和 html5 history 对象前下,我们先看看两者的基类。
export class History {
constructor (router: Router, base: ?string) {
this.router = router
this.base = normalizeBase(base)
// start with a route object that stands for "nowhere"
this.current = START
this.pending = null
this.ready = false
this.readyCbs = []
this.readyErrorCbs = []
this.errorCbs = []
this.listeners = []
}
listen (cb: Function) {
this.cb = cb
}
transitionTo (
location: RawLocation,
onComplete?: Function,
onAbort?: Function
) {
let route
// catch redirect option https://github.com/vuejs/vue-router/issues/3201
try {
route = this.router.match(location, this.current)
} catch (e) {
this.errorCbs.forEach(cb => {
cb(e)
})
// Exception should still be thrown
throw e
}
const prev = this.current
this.confirmTransition(
route,
() => {
this.updateRoute(route)
onComplete && onComplete(route)
this.ensureURL()
this.router.afterHooks.forEach(hook => {
hook && hook(route, prev)
})
// fire ready cbs once
if (!this.ready) {
this.ready = true
this.readyCbs.forEach(cb => {
cb(route)
})
}
},
err => {
if (onAbort) {
onAbort(err)
}
if (err && !this.ready) {
// Initial redirection should not mark the history as ready yet
// because it's triggered by the redirection instead
// https://github.com/vuejs/vue-router/issues/3225
// https://github.com/vuejs/vue-router/issues/3331
if (!isNavigationFailure(err, NavigationFailureType.redirected) || prev !== START) {
this.ready = true
this.readyErrorCbs.forEach(cb => {
cb(err)
})
}
}
}
)
}
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
const current =