vue-router源码解析——初始化
最近迭代博客过程中遇到一个诡异bug,webpack升级到4后,在生产模式下vue-router匹配到异步组件后无法触发onReady钩子方法。严重怀疑是vue-router的问题,正好借这个机会看看vue-router的源码好了。
安装
我们知道vue-router是作为插件使用的,第一步就是在顶层组件使用Vue.use(VueRouter)
注入。这部分代码都在src/install.js
下。源码如下:
import View from './components/view'
import Link from './components/link'
export let _Vue
export function install (Vue) {
if (install.installed && _Vue === Vue) return
install.installed = true
_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.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)
},
destroyed () {
registerInstance(this)
}
})
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
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
}
首先声明了一个_Vue
变量用来保存Vue引用。这里的作用是为了使用Vue的一些原生方法,但又不想导入真正的Vue在打包时将Vue打包到依赖,所以会在install
开始时再将其赋值。
接着全局混入了一个beforeCreate
生命周期函数,做的事就是如果实例化时传入了router
选项,则初始化该路由并挂载到实例上,否则就向上查询直到找到路由。再调用registerInstance
,需要注意,该方法本质是调用了组件实例的registerRouterInstance
方法,这个方法是View
组件才会有的方法,也就是常用的RouterView
组件了。
接着在原型上定义了相关路由属性,以便所有组件都能访问到,并全局注册了两个内部组件RouterView
和RouterLink
,再确认路由生命周期的选项合并策略和created
生命周期一致,插件的安装就完成了。
vue-router实例化
安装好了插件,就可以在项目中使用了。通常使用方式都是在项目入口处实例化并传入一些构建选项,再将其作为参数传递给顶层组件。先看vue-router实例化时,传递给Vue组件之前都干了些什么。这部分源码在src/index
中。
export default class VueRouter {
static install: () => void;
static version: string;
app: any;
apps: Array<any>;
ready: boolean;
readyCbs: Array<Function>;
options: RouterOptions;
mode: string;
history: HashHistory | HTML5History | AbstractHistory;
matcher: Matcher;
fallback: boolean;
beforeHooks: Array<?NavigationGuard>;
resolveHooks: Array<?NavigationGuard>;
afterHooks: Array<?AfterNavigationHook>;
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}`)
}
}
}
match (
raw: RawLocation,
current?: Route,
redirectedFrom?: Location
): Route {
return this.matcher.match(raw, current, redirectedFrom)
}
get currentRoute (): ?Route {
return this.history && this.history.current
}
init (app: any /* Vue component instance */) {
// ...
}
beforeEach (fn: Function): Function {
return registerHook(this.beforeHooks, fn)
}
beforeResolve (fn: Function): Function {
return registerHook(this.resolveHooks, fn)
}
afterEach (fn: Function): Function {
return registerHook(this.afterHooks, fn)
}
onReady (cb: Function, errorCb?: Function) {
this.history.onReady(cb, errorCb)
}
onError (errorCb: Function) {
this.history.onError(errorCb)
}
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.push(location, onComplete, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.replace(location, onComplete, onAbort)
}
go (n: number) {
this.history.go(n)
}
back () {
this.go(-1)
}
forward () {
this.go(1)
}
getMatchedComponents (to?: RawLocation | Route): Array<any> {
const route: any = to
? to.matched
? to
: this.resolve(to).route
: this.currentRoute
if (!route) {
return []
}
return [].concat.apply([], route.matched.map(m => {
return Object.keys(m.components).map(key => {
return m.components[key]
})
}))
}
resolve (
to: RawLocation,
current?: Route,
append?: boolean
): {
location: Location,
route: Route,
href: string,
// for backwards compat
normalizedTo: Location,
resolved: Route
} {
const location = normalizeLocation(
to,
current || this.history.current,
append,
this
)
const route = this.match(location, current)
const fullPath = route.redirectedFrom || route.fullPath
const base = this.history.base
const href = createHref(base, fullPath, this.mode)
return {
location,
route,
href,
// for backwards compat
normalizedTo: location,
resolved: route
}
}
addRoutes (routes: Array<RouteConfig>) {
this.matcher.addRoutes(routes)
if (this.history.current !== START) {
this.history.transitionTo(this.history.getCurrentLocation())
}
}
}
function registerHook (list: Array<any>, fn: Function): Function {
list.push(fn)
return () => {
const i = list.indexOf(fn)
if (i > -1) list.splice(i, 1)
}
}
function createHref (base: string, fullPath: string, mode) {
var path = mode === 'hash' ? '#' + fullPath : fullPath
return base ? cleanPath(base + '/' + path) : path
}
VueRouter.install = install
VueRouter.version = '__VERSION__'
if (inBrowser && window.Vue) {
window.Vue.use(VueRouter)
可以看到,实例化对象中的属性主要是一些生命周期钩子,但其中有两个关键点不容忽视,那就是history
和matcher
。前者对应着该路由使用哪种策略,我们知道常用的前端路由策略有hash
和history
,vue-router
还实现了一种策略为abstract
,供服务端使用。后者则是利用传入的routes
参数创建路由匹配函数,支撑路由核心功能。
路由匹配对象
该函数的创建通过createMatcher
方法进入。源码在src/create-matcher
下。
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
function addRoutes (routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}
function match (
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {
const location = normalizeLocation(raw, currentRoute, false, router)
const { name } = location
if (name) {
const record = nameMap[name]
if (process.env.NODE_ENV !== 'production') {
warn(record, `Route with name '${name}' does not exist`)
}
if (!record) return _createRoute(null, location)
const paramNames = record.regex.keys
.filter(key => !key.optional)
.map(key => key.name)
if (typeof location.params !== 'object') {
location.params = {}
}
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]
}
}
}
if (record) {
location.path = fillParams(record.path, location.params, `named route "${name}"`)
return _createRoute(record, location, redirectedFrom)
}
} 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)) {
return _createRoute(record, location, redirectedFrom)
}
}
}
// no match
return _createRoute(null, location)
}
function redirect (
record: RouteRecord,
location: Location
): Route {
const originalRedirect = record.redirect
let redirect = typeof originalRedirect === 'function'
? originalRedirect(createRoute(record, location, null, router))
: originalRedirect
if (typeof redirect === 'string') {
redirect = { path: redirect }
}
if (!redirect || typeof redirect !== 'object') {
if (process.env.NODE_ENV !== 'production') {
warn(
false, `invalid redirect option: ${JSON.stringify(redirect)}`
)
}
return _createRoute(null, location)
}
const re: Object = redirect
const { name, path } = re
let { query, hash, params } = location
query = re.hasOwnProperty('query') ? re.query : query
hash = re.hasOwnProperty('hash') ? re.hash : hash
params = re.hasOwnProperty('params') ? re.params : params
if (name) {
// resolved named direct
const targetRecord = nameMap[name]
if (process.env.NODE_ENV !== 'production') {
assert(targetRecord, `redirect failed: named route "${name}" not found.`)
}
return match({
_normalized: true,
name,
query,
hash,
params
}, undefined, location)
} else if (path) {
// 1. resolve relative redirect
const rawPath = resolveRecordPath(path, record)
// 2. resolve params
const resolvedPath = fillParams(rawPath, params, `redirect route with path "${rawPath}"`)
// 3. rematch with existing query and hash
return match({
_normalized: true,
path: resolvedPath,
query,
hash
}, undefined, location)
} else {
if (process.env.NODE_ENV !== 'production') {
warn(false, `invalid redirect option: ${JSON.stringify(redirect)}`)
}
return _createRoute(null, location)
}
}
function alias (
record: RouteRecord,
location: Location,
matchAs: string
): Route {
const aliasedPath = fillParams(matchAs, location.params, `aliased route with path "${matchAs}"`)
const aliasedMatch = match({
_normalized: true,
path: aliasedPath
})
if (aliasedMatch) {
const matched = aliasedMatch.matched
const aliasedRecord = matched[matched.length - 1]
location.params = aliasedMatch.params
return _createRoute(aliasedRecord, location)
}
return _createRoute(null, location)
}
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)
}
return {
match,
addRoutes
}
}
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]
const val = typeof m[i] === 'string' ? decodeURIComponent(m[i]) : m[i]
if (key) {
// Fix #1994: using * with props: true generates a param named 0
params[key.name || 'pathMatch'] = val
}
}
return true
}
function resolveRecordPath (path: string, record: RouteRecord): string {
return resolvePath(path, record.parent ? record.parent.path : '/', true)
}
首先根据传入的routers
配置调用createRouterMap
函数生成对应的map
,该map
用来记录路由路径和路由名称到路由记录对象的映射关系。
再声明match
函数用来匹配相关路径,声明addRoutes
用来动态添加相关路由到map
。match
函数做的事,简单来说就是根据路径或名称匹配到对应的路由记录对象,并根据记录对象生成对应的路由匹配对象,其中有多种匹配策略,但最终都要落实到createRoute
方法生成路由匹配对象供后续使用。具体逻辑在后文的使用流程中再分析。
History对象
完成路由匹配函数的创建后,接下来就是根据mode
参数实例化对应的History对象了。所有的History类定义都是src/history
下并继承自src/history/base
。
export class History {
router: Router;
base: string;
current: Route;
pending: ?Route;
cb: (r: Route) => void;
ready: boolean;
readyCbs: Array<Function>;
readyErrorCbs: Array<Function>;
errorCbs: Array<Function>;
// implemented by sub-classes
+go: (n: number) => void;
+push: (loc: RawLocation) => void;
+replace: (loc: RawLocation) => void;
+ensureURL: (push?: boolean) => void;
+getCurrentLocation: () => string;
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 = []
}
// ...
具体使用在后文中分析,现在只需要知道该对象中主要用来管理编程式导航相关的一些信息即可。
vue-router实例初始化
现在我们有了vue-router的实例,也大概了解了其中的结构。其中注册了两个内部组件router-view
和router-link
,并且功能使用主要分成两个部分,matcher
根据对应路径匹配生成相关路由对象,history
用来完成实际的编程导航。
接下来就是真正在Vue实例中初始化使用,将上述流程串联起来的时候了。
实例初始化的入口在插件安装时混入的beforeCreate
方法中,这一步主要做了两件事,首先调用实例的init方法完成初始化,再将_route
对象通过Vue内部定义响应式属性的defineReactive
方法将其转化为响应式。
init
init(app) {
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)
// main app already initialized.
if (this.app) {
return
}
this.app = app
const history = this.history
if (history instanceof HTML5History) {
history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {
const setupHashListener = () => {
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),
setupHashListener,
setupHashListener
)
}
history.listen(route => {
this.apps.forEach((app) => {
app._route = route
})
})
}
初始化过程中主要是将组件实例赋值到vue-router实例的app属性和apps数组中,再根据当前location
使用transitionTo
完成单页应用的首次跳转。该方法既然是编程导航的一个方法,那自然要从history
中调用了。在src/history/base
中,可以查询到其实现。
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const route = this.router.match(location, this.current)
this.confirmTransition(route, () => {
this.updateRoute(route)
onComplete && onComplete(route)
this.ensureURL()
// 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) {
this.ready = true
this.readyErrorCbs.forEach(cb => { cb(err) })
}
})
}
可以看到,这一步做的主要工作就是调用路由匹配函数获取到路由匹配对象,再根据路由匹配对象触发confirmTransition
方法确认过渡。
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
const current = this.current
const abort = err => {
if (isError(err)) {
if (this.errorCbs.length) {
this.errorCbs.forEach(cb => { cb(err) })
} else {
warn(false, 'uncaught error during route navigation:')
console.error(err)
}
}
onAbort && onAbort(err)
}
if (
isSameRoute(route, current) &&
// in the case the route map has been dynamically appended to
route.matched.length === current.matched.length
) {
this.ensureURL()
return abort()
}
const {
updated,
deactivated,
activated
} = resolveQueue(this.current.matched, route.matched)
const queue: Array<?NavigationGuard> = [].concat(
// in-component leave guards
extractLeaveGuards(deactivated),
// global before hooks
this.router.beforeHooks,
// in-component update hooks
extractUpdateHooks(updated),
// in-config enter guards
activated.map(m => m.beforeEnter),
// async components
resolveAsyncComponents(activated)
)
this.pending = route
const iterator = (hook: NavigationGuard, next) => {
if (this.pending !== route) {
return abort()
}
try {
hook(route, current, (to: any) => {
if (to === false || isError(to)) {
// next(false) -> abort navigation, ensure current URL
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()
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, () => {
const postEnterCbs = []
const isValid = () => this.current === route
// wait until async components are resolved before
// extracting in-component enter guards
const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {
if (this.pending !== route) {
return abort()
}
this.pending = null
onComplete(route)
if (this.router.app) {
this.router.app.$nextTick(() => {
postEnterCbs.forEach(cb => { cb() })
})
}
})
})
}
首先和当前location对比判断是否是相同路由对象,如果是则返回,不是则通过resolveQueue
比对路由记录对象,由于路由记录对象中记录了对应组件信息,则通过该步可以知道哪里是需要失活的组件deactived
,哪些是需要激活的组件activated
。
接着定义了一个queue
队列,其中按生命周期顺序存储了失活组件和激活组件相对应的守卫函数和钩子函数。
再将当前路由状态挂起,标志着即将进入过渡。
再定义了一个迭代器函数,用来迭代执行队列中的守卫函数和钩子函数。
接着通过runQueue
函数传入队列和迭代器开始执行,并传入一个回调,该回调的作用是提取出激活组件入口守卫函数队列并在当前队列执行完成后再执行,这里是考虑到异步组件相关钩子则用回调的方式处理。runQueue
源码如下,这个功能函数实现的还是很有意思。
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
const step = index => {
if (index >= queue.length) {
cb()
} else {
if (queue[index]) {
fn(queue[index], () => {
step(index + 1)
})
} else {
step(index + 1)
}
}
}
step(0)
}
最终迭代完成后,就可以通过updateRoute
更新当前路由对象了。
updateRoute (route: Route) {
const prev = this.current
this.current = route
this.cb && this.cb(route)
this.router.afterHooks.forEach(hook => {
hook && hook(route, prev)
})
}
这一步就是更新current
属性及处理相关回调钩子了。
最后调用history对象的listen
方法简单设置updateRoute
后的回调去更新app._route
为当前路由对象。至此vue-router实例初始化就完成了。
history.listen (cb: Function) {
this.cb = cb
}
可以看到这个回调第一次并不会触发,因为监听在第一次updateRoute
后,之后每次更新路由都会更新_route
的值并触发组件更新。这一步响应式更新的机制也就是在beforeCreate
中除去初始化后所做的第二个工作,定义响应式属性的这一行代码了。
defineReactive
Vue.util.defineReactive(this, '_route', this._router.history.current)
defineReactive
的作用在Vue响应式原理中已经做了介绍,就不赘述了。结果就是每次_route
更新都会触发Vue实例重渲染,完成组件更新。
小结
至此vue-router的初始化流程就介绍完毕了,整体来看还是很清晰的,首先在初始化时根据传入routes
构建路由记录对象,并实例化history
对象管理编程导航流程。工作时根据location
信息调用路由匹配函数match
生成路由匹配对象,再根据路由匹配对象查询路由记录对象获取对应组件信息,再提取出组件相关钩子,在过渡时迭代执行,最终更新路由对象触发响应式更新重渲染视图,则完成了整个流程。
-- EOF --