路由的概念相信大部分同学并不陌生,我们在用 Vue 开发过实际项目的时候都会用到 Vue-Router 这个官方插件来帮我们解决路由的问题。它的作用就是根据不同的路径映射到不同的视图。本文不再讲述路由的基础使用和API,不清楚的同学可以自行查阅官方文档vue-router3 对应 vue2 和 vue-router4 对应 vue3。今天我们从源码出发以vue-router 3.5.3源码为例,一起来分析下Vue-Router的具体实现。
由于篇幅原因,
vue-router源码分析分上、中、下三篇文章讲解。
前面我们已经讲了路由的安装和实例化,下面我们来看看初始化流程。
前面我们说到,在安装的时候会混入全局mixin。我们知道全局混入,会影响后续创建的所有Vue实例。beforeCreate首次触发是在Vue根实例实例化的时候即new Vue({router})时。
Vue.mixin({
beforeCreate () {
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 {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
}
...
})
由于router仅存在于Vue根实例的$options上,所以,整个初始化只会被调用一次。也就是这个if (isDef(this.$options.router))只会执行一次。
在这里我们我们重点分析下init方法。
// src/index.js
...
init (app: any /* Vue component instance */) {
process.env.NODE_ENV !== 'production' &&
assert(
install.installed,
`not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
`before creating root instance.`
)
this.apps.push(app) // 保存实例
// 绑定destroyed hook,避免内存泄露
app.$once('hook:destroyed', () => {
const index = this.apps.indexOf(app)
if (index > -1) this.apps.splice(index, 1)
// 需要确保始终有个主应用
if (this.app === app) this.app = this.apps[0] || null
if (!this.app) this.history.teardown()
})
// main app已经存在,则不需要重复初始化history 的事件监听
if (this.app) {
return
}
this.app = app
const history = this.history
if (history instanceof HTML5History || history instanceof HashHistory) {
const handleInitialScroll = routeOrError => {
const from = history.current
const expectScroll = this.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll && 'fullPath' in routeOrError) {
handleScroll(this, routeOrError, from, false)
}
}
const setupListeners = routeOrError => {
history.setupListeners()
handleInitialScroll(routeOrError)
}
history.transitionTo(
history.getCurrentLocation(),
setupListeners,
setupListeners
)
}
// 调用父类的listen方法,添加回调;
// 回调会在父类的updateRoute方法被调用时触发,重新为app._route赋值
// 由于app._route被定义为响应式,所以app._route发生变化,依赖app._route的组件(route-view组件)都会被重新渲染
history.listen(route => {
this.apps.forEach(app => {
app._route = route
})
})
}
可以看到,init方法主要做了下面几件事
检查了VueRouter是否已经安装
保存了挂载router实例的vue实例。VueRouter支持多实例嵌套,所以存在this.apps来保存持有router实例的vue实例
注册了一个一次性钩子destroyed,在destroyed时,卸载this.app,避免内存泄露。
检查了this.app,避免重复事件监听。
根据history类型,调用transitionTo跳转到初始页面,并调用setupListeners函数初始化路由变化的监听。
注册updateRoute回调,在route更新时,更新app._route完成页面重新渲染。
我们重点看下transitionTo相关逻辑
// src/history/base.js
transitionTo (
location: RawLocation, // 原始location,一个url或者是一个Location
onComplete?: Function, // 跳转成功回调
onAbort?: Function // 跳转失败回调
) {
let route
try {
// 传入需要跳转的location和当前路由对象,返回to的Route
route = this.router.match(location, this.current)
} catch (e) {
this.errorCbs.forEach(cb => {
cb(e)
})
// Exception should still be thrown
throw e
}
// 保存之前的Route对象
const prev = this.current
// 确认跳转
this.confirmTransition(
route,
() => {
// 更新最新路由并更新所有vue实例_route属性
this.updateRoute(route)
// 执行监听回调,后面url的变化会自动触发transitionTo方法
onComplete && onComplete(route)
// 变更浏览器url
this.ensureURL()
// 执行钩子函数
this.router.afterHooks.forEach(hook => {
hook && hook(route, prev)
})
// 触发ready回调 只触发一次
if (!this.ready) {
this.ready = true
this.readyCbs.forEach(cb => {
cb(route)
})
}
},
err => {
if (onAbort) {
onAbort(err)
}
if (err && !this.ready) {
if (!isNavigationFailure(err, NavigationFailureType.redirected) || prev !== START) {
this.ready = true
// 触发error回调
this.readyErrorCbs.forEach(cb => {
cb(err)
})
}
}
}
)
}
上面说到,在初始化时,会调用transitionTo跳转到初始页面。
为什么要跳转初始页面?因为在初始化时,url可能指向其它页面(比如我们在/about刷新页面),此时需要调用getCurrentLocation方法,从当前url上解析出路由,然后跳转。
我们来分析下transitionTo方法
match 方法它的作用是根据传入的 raw 和当前的路径currentRoute计算出一个新的Route对象并返回。这其实就是路由匹配过程。
调用了this.router.match(location, this.current)方法,match 方法接收 3 个参数,其中raw是RawLocation 类型,它可以是一个 url 字符串,也可以是一个 Location 对象。currentRoute是 Route 类型,它表示当前的路径。redirectedFrom 和重定向相关,这里先忽略。
我们先来看看Location、RawLocation和Route的数结构
export interface Location {
name?: string
path?: string
hash?: string
query?: Dictionary<string | (string | null)[] | null | undefined>
params?: Dictionary<string>
append?: boolean
replace?: boolean
}
RawLocation是联合类型,是字符串或者Location。
我们使用this.$router.push()和this.$router.replace()里面的对象就是RawLocation类型。
export type RawLocation = string | Location
这个对象就是我们this.$route对象。
export interface Route {
path: string
name?: string | null
hash: string
query: Dictionary<string | (string | null)[]>
params: Dictionary<string>
fullPath: string
matched: RouteRecord[]
redirectedFrom?: string
meta?: RouteMeta
}
下面我们来看看match方法。
在实例化的时候我们分析createMatcher的时候有提到这个match方法,match就是用来完成url和路由的匹配工作。
function match ( raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location ): Route {
// 获取格式化后的location
const location = normalizeLocation(raw, currentRoute, false, router)
const { name } = location
// 通过name匹配
if (name) {
const record = nameMap[name]
if (process.env.NODE_ENV !== 'production') {
warn(record, `Route with name '${name}' does not exist`)
}
// 没找到返回新Route对象
if (!record) return _createRoute(null, location)
// 获取动态路由参数名
const paramNames = record.regex.keys
.filter(key => !key.optional)
.map(key => key.name)
// params参数
if (typeof location.params !== 'object') {
location.params = {}
}
// 提取当前Route中路由params赋值给location
if (currentRoute && typeof currentRoute.params === 'object') {
for (const key in currentRoute.params) {
if (!(key in location.params) && paramNames.indexOf(key) > -1) {
location.params[key] = currentRoute.params[key]
}
}
}
// 填充params
location.path = fillParams(record.path, location.params, `named route "${name}"`)
// 创建Route
return _createRoute(record, location, redirectedFrom)
// 通过path匹配
} else if (location.path) {
location.params = {}
for (let i = 0; i < pathList.length; i++) {
const path = pathList[i]
const record = pathMap[path]
if (matchRoute(record.regex, location.path, location.params)) {
// 创建Route
return _createRoute(record, location, redirectedFrom)
}
}
}
// name、path都不匹配创建新Route
return _createRoute(null, location)
}
我们来分析下match方法。
1。 首先它对传入的raw地址,使用normalizeLocation方法进行了格式化。
然后取出格式化地址中的name。
name存在,判断是否能通过name在nameMap中找到对应的路由记录RouteRecord。
可以找到,则通过fillParams方法填充params,并使用此路由记录通过_createRoute方法创建一个新的Route对象返回。
无法找到,则通过_createRoute方法创建一个新Route对象返回。
name不存在,则判断path是否存在,存在,则利用pathList、pathMap调用matchRoute方法判断是否匹配,进而找到匹配的路由记录,然后使用此路由记录通过_createRoute方法创建新Route对象返回。
如果name、path都不存在,则通过_createRoute方法直接创建一个新Route对象返回
我们再来分析下上面提到的几个重要方法
// src/util/location.js
export function normalizeLocation ( raw: RawLocation, // 原始location,一个string,或者是一个已经格式化后的location
current: ?Route, // 当前路由对象
append: ?boolean, // 是否是追加模式
router: ?VueRouter // VueRouter实例 ): Location {
let next: Location = typeof raw === 'string' ? { path: raw } : raw
// named target
if (next._normalized) {
return next
} else if (next.name) {
next = extend({}, raw)
const params = next.params
if (params && typeof params === 'object') {
next.params = extend({}, params)
}
return next
}
// relative params
if (!next.path && next.params && current) {
next = extend({}, next)
next._normalized = true
const params: any = extend(extend({}, current.params), next.params)
if (current.name) {
next.name = current.name
next.params = params
} else if (current.matched.length) {
const rawPath = current.matched[current.matched.length - 1].path
next.path = fillParams(rawPath, params, `path ${current.path}`)
} else if (process.env.NODE_ENV !== 'production') {
warn(false, `relative params navigation requires a current route.`)
}
return next
}
const parsedPath = parsePath(next.path || '')
const basePath = (current && current.path) || '/'
const path = parsedPath.path
? resolvePath(parsedPath.path, basePath, append || next.append)
: basePath
const query = resolveQuery(
parsedPath.query,
next.query,
router && router.options.parseQuery
)
let hash = next.hash || parsedPath.hash
if (hash && hash.charAt(0) !== '#') {
hash = `#${hash}`
}
return {
_normalized: true,
path,
query,
hash
}
}
首先将string类型的转换为对象形式,方便后面统一处理
如果发现地址已经做过格式化处理,则直接返回
再判断是否是命名路由,若是,则拷贝原始地址raw,拷贝params,直接返回
处理了仅携带参数的相对路由(相对参数)跳转,就是this.$router.push({params:{id:1}})形式
处理通过path跳转的方式
经过上面一番处理,无论传入何种地址,都返回一个带有_normalized:true标识的Location类型的对象
// 检查path是否能通过regex的匹配,并对params对象正确赋值
function matchRoute ( regex: RouteRegExp,
path: string,
params: Object ): boolean {
const m = path.match(regex)
if (!m) {
return false
} else if (!params) {
return true
}
for (let i = 1, len = m.length; i < len; ++i) {
const key = regex.keys[i - 1]
if (key) {
// Fix #1994: using * with props: true generates a param named 0
params[key.name || 'pathMatch'] = typeof m[i] === 'string' ? decode(m[i]) : m[i]
}
}
return true
}
通过方法签名,可以知道它返回一个boolean值,这个值代表传入的path是否能通过regex的匹配;
虽然返回一个boolean值,但是其内部还做了件很重要的事,从path上提取动态路由参数值,我们看下完整逻辑
首先调用path.match(regex)
不能匹配直接返回false
可以匹配且无params,返回true
剩下的就只有一种情况,可以匹配且params存在,此时需要对params进行正确赋值。这里的参数是路由动态参数。即/user/:id + /user/123 -> {id:123}
fillParams可以看做是matchRoute的逆操作,是一个借助动态路径,使用参数生成 url 的过程;即/user/:id + {id:123} -> /user/123
export function fillParams ( path: string,
params: ?Object,
routeMsg: string ): string {
params = params || {}
try {
const filler =
regexpCompileCache[path] ||
(regexpCompileCache[path] = Regexp.compile(path))
// Fix #2505 resolving asterisk routes { name: 'not-found', params: { pathMatch: '/not-found' }}
// and fix #3106 so that you can work with location descriptor object having params.pathMatch equal to empty string
if (typeof params.pathMatch === 'string') params[0] = params.pathMatch
return filler(params, { pretty: true })
} catch (e) {
if (process.env.NODE_ENV !== 'production') {
// Fix #3072 no warn if `pathMatch` is string
warn(typeof params.pathMatch === 'string', `missing param for ${routeMsg}: ${e.message}`)
}
return ''
} finally {
// delete the 0 if it was added
delete params[0]
}
}
可以发现通过name匹配是有params赋值操作的,但是通过path匹配是没有的。所以也可以得出,通过path就行路由跳转是不能传递params的原因。
通过path跳转的才有动态参数的处理过程,所以也可以得出只有通过path跳转才能传递路由动态参数。
_createRoute方法实现的作用是找到与地址匹配的路由对象。该方法分普通、重定向、别名三种创建方式。
//src/create-matcher.js
function _createRoute ( record: ?RouteRecord,
location: Location,
redirectedFrom?: Location ): Route {
if (record && record.redirect) {
return redirect(record, redirectedFrom || location)
}
if (record && record.matchAs) {
return alias(record, location, record.matchAs)
}
return createRoute(record, location, redirectedFrom, router)
}
不管哪种创建方式其核心都是调用了createRoute方法
// src/util/route.js
export function createRoute ( record: ?RouteRecord,
location: Location,
redirectedFrom?: ?Location,
router?: VueRouter ): Route {
const stringifyQuery = router && router.options.stringifyQuery
// 处理query参数
let query: any = location.query || {}
try {
query = clone(query)
} catch (e) {}
const route: Route = {
name: location.name || (record && record.name),
meta: (record && record.meta) || {},
path: location.path || '/',
hash: location.hash || '',
query,
params: location.params || {},
fullPath: getFullPath(location, stringifyQuery), // 完整path
matched: record ? formatMatch(record) : [] // 获取所有匹配的路由记录
}
// 如果是从其它路由对重定向过来的,则需要记录重定向之前的地址
if (redirectedFrom) {
route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery)
}
// 调用`Object.freeze`冻结新`Route` 对象,因为`Route`对象是`immutable`的
return Object.freeze(route)
}
createRoute方法创建的就是一个Route对象。
这里我们可以看到 query参数是取得location.query,这也就解释了为什么query参数在刷新的时候不会丢失。因为一直在浏览器路径里。params参数是在location.params上,是临时赋值的一个属性,所以刷新会丢失。
// src/base.js
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
// 路由里面的from
const current = this.current
// 路由里面的to
this.pending = route
const abort = err => {
// changed after adding errors with
// https://github.com/vuejs/vue-router/pull/3047 before that change,
// redirect and aborted navigation would produce an err == null
if (!isNavigationFailure(err) && isError(err)) {
if (this.errorCbs.length) {
this.errorCbs.forEach(cb => {
cb(err)
})
} else {
if (process.env.NODE_ENV !== 'production') {
warn(false, 'uncaught error during route navigation:')
}
console.error(err)
}
}
onAbort && onAbort(err)
}
const lastRouteIndex = route.matched.length - 1
const lastCurrentIndex = current.matched.length - 1
// 相同Route 调用abort方法
if (
isSameRoute(route, current) &&
// in the case the route map has been dynamically appended to
lastRouteIndex === lastCurrentIndex &&
route.matched[lastRouteIndex] === current.matched[lastCurrentIndex]
) {
// ensureURL由子类实现,主要根据传参确定是添加还是替换一个记录,这里是替换当前历史记录
this.ensureURL()
if (route.hash) {
handleScroll(this.router, current, route, false)
}
return abort(createNavigationDuplicatedError(current, route))
}
// 对比前后route的RouteRecord,找出需要更新、失活、激活的的路由记录
const { updated, deactivated, activated } = resolveQueue(
this.current.matched,
route.matched
)
// 生成需要执行的守卫、钩子队列
const queue: Array<?NavigationGuard> = [].concat(
// in-component leave guards
extractLeaveGuards(deactivated), // 提取路由组件中所有beforeRouteLeave守卫
// global before hooks
this.router.beforeHooks, // 全局的beforeEach守卫
// in-component update hooks
extractUpdateHooks(updated), // 提取路由组件中所有beforeRouteUpdate守卫
// in-config enter guards
activated.map(m => m.beforeEnter), // 路由独享的beforeEnter守卫
// async components
resolveAsyncComponents(activated) // 解析异步组件
)
const iterator = (hook: NavigationGuard, next) => {
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
try {
hook(route, current, (to: any) => {
if (to === false) {
// next(false) -> abort navigation, ensure current URL
this.ensureURL(true)
abort(createNavigationAbortedError(current, route))
} else if (isError(to)) {
this.ensureURL(true)
abort(to)
} else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {
// next('/') or next({ path: '/' }) -> redirect
abort(createNavigationRedirectedError(current, route))
if (typeof to === 'object' && to.replace) {
this.replace(to)
} else {
this.push(to)
}
} else {
// confirm transition and pass on the value
next(to)
}
})
} catch (e) {
abort(e)
}
}
runQueue(queue, iterator, () => {
// wait until async components are resolved before
// extracting in-component enter guards
const enterGuards = extractEnterGuards(activated)
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
this.pending = null
// 执行onComplete回调,onComplete中会调用updateRoute方法,内部会触发afterEach钩子
onComplete(route)
if (this.router.app) {
this.router.app.$nextTick(() => {
handleRouteEntered(route)
})
}
})
})
}
拿到将要跳转的route后,调用confirmTransition完成route的解析跳转,并在跳转成功、取消时调用对应回调方法;这是导航解析过程。
我们来看看这个方法主要干了那几件事情
首先定义好from和to路由对象。
定义了取消路由方法abort。
首先处理了重复跳转的问题
然后通过对比找出需要更新、失活、激活的路由记录
从上述三种路由记录中抽取出对应钩子、守卫函数
将钩子及守卫函数放入队列中并执行
主要利用isSameRoute检测了当前路由对象和目标路由对象是否相同,若相同且二者匹配到路由记录数量相同,则视为重复跳转,此时调用abort方法并传入NavigationDuplicated错误并终止流程
isSameRoute主要判断了path、name、hash、query、params等关键信息是否相同,若相同则视为相同路由对象。
在确定是重复跳转后,仍然会调用子类的ensureURL方法来更新url
// src/util/route.js
// 是否相同route
export function isSameRoute(a: Route, b: ?Route): boolean {
if (b === START) {
return a === b
} else if (!b) {
return false
} else if (a.path && b.path) {
// path都存在,比较path、hash、query是否相同
return (
a.path.replace(trailingSlashRE, '') ===
b.path.replace(trailingSlashRE, '') &&
a.hash === b.hash &&
isObjectEqual(a.query, b.query)
)
} else if (a.name && b.name) {
// name都存在,比较name、hash、query、params是否相同
return (
a.name === b.name &&
a.hash === b.hash &&
isObjectEqual(a.query, b.query) &&
isObjectEqual(a.params, b.params)
)
} else {
return false
}
}
判断完重复跳转后,就需要对比from、to路由对象,找出哪些路由记录需要更新,哪些失活、哪些需要激活,用来后续抽取钩子、守卫函数
// src/history/base.js
// 对比前后route的RouteRecord,找出需要更新、失活、激活的的路由记录
const { updated, deactivated, activated } = resolveQueue(
this.current.matched,
route.matched
)
传入了当前和目标路由对象的记录列表,从返回值中解构出了updated, deactivated, activated。
首先我们梳理下vue-router有哪些钩子、守卫函数
router.beforeEach全局前置守卫router.beforeResolve全局解析守卫(v2.5.0 新增)router.afterEach全局后置钩子RouteConfig.beforeEnter路由独享的守卫vm.beforeRouteEntervue 组件内路由进入守卫vm.beforeRouteUpdatevue 组件内路由更新守卫(v2.2 新增)vm.beforeRouteLeavevue 组件内路由离开守卫上面我们已经拿到需要更新、激活、失活的RouteRecord路由记录,我们看下分别要从中抽取出哪些守卫
activated中抽取beforeRouteEnterdeactivated中抽取beforeRouteLeaveupdated中抽取beforeRouteUpdate队列的执行是通过runQueue、iterator相互配合来实现的
整个导航的解析(确认),其实就是从不同状态的路由记录中抽取出对应的守卫及钩子
然后组成队列,使用runQueue、iterator巧妙的完成守卫的执行
并在其中处理了异步组件的解析、postEnterCb中实例获取的问题
整个守卫、钩子的执行流程如下