VueRouter4 github地址:github.com/vuejs/vue-r…
本文基于release版本v4.0.8
一、入口文件分析
源码的入口文件,通常可以通过package.json
文件的scripts
中得知:
{
"scripts": {
"build": "rollup -c rollup.config.js",
...
"dev": "webpack serve --mode=development",
...
}
}
复制代码
在源码的scripts
配置中,通过build命令可以得知以下信息:
- 生产版本通过
rollup
构建工具进行打包 - 可以通过
rollup.config.js
文件获取入口文件信息
在rollup.config.js
文件中查找input
配置,得到入口文件地址为src/index.ts
:
{
input: `src/index.ts`,
}
复制代码
二、入口文件导出的模块
打开入口文件src/index.ts
,源代码如下:
export { createWebHistory } from './history/html5'
export { createMemoryHistory } from './history/memory'
export { createWebHashHistory } from './history/hash'
export { createRouterMatcher, RouterMatcher } from './matcher'
export {
LocationQuery,
parseQuery,
stringifyQuery,
LocationQueryRaw,
LocationQueryValue,
LocationQueryValueRaw,
} from './query'
export { RouterHistory, HistoryState } from './history/common'
export { RouteRecord, RouteRecordNormalized } from './matcher/types'
export {
PathParserOptions,
_PathParserOptions,
} from './matcher/pathParserRanker'
export {
routeLocationKey,
routerViewLocationKey,
routerKey,
matchedRouteKey,
viewDepthKey,
} from './injectionSymbols'
export {
// route location
_RouteLocationBase,
LocationAsPath,
LocationAsRelativeRaw,
RouteQueryAndHash,
RouteLocationRaw,
RouteLocation,
RouteLocationNormalized,
RouteLocationNormalizedLoaded,
RouteParams,
RouteParamsRaw,
RouteParamValue,
RouteParamValueRaw,
RouteLocationMatched,
RouteLocationOptions,
RouteRecordRedirectOption,
// route records
_RouteRecordBase,
RouteMeta,
START_LOCATION_NORMALIZED as START_LOCATION,
RouteComponent,
// RawRouteComponent,
RouteRecordName,
RouteRecordRaw,
NavigationGuard,
NavigationGuardNext,
NavigationGuardWithThis,
NavigationHookAfter,
} from './types'
export {
createRouter,
Router,
RouterOptions,
RouterScrollBehavior,
} from './router'
export {
NavigationFailureType,
NavigationFailure,
isNavigationFailure,
} from './errors'
export { onBeforeRouteLeave, onBeforeRouteUpdate } from './navigationGuards'
export {
RouterLink,
useLink,
RouterLinkProps,
UseLinkOptions,
} from './RouterLink'
export { RouterView, RouterViewProps } from './RouterView'
export * from './useApi'
export * from './globalExtensions'
复制代码
分析export的内容,可以将导出的API分为以下几类:
- history 模块
- matcher 模块
- router 模块
- RouterLink 模块
- RouterView 模块
- errors 模块
- navigationGuards 模块
- 其他
- injectionSymbols
- types
- useApi
- globalExtensions
下面将开始从VueRouter的文档中的基础到进阶,逐一分析以上模块的基本功能和实现原理。
三、基础
对应使用文档地址:next.router.vuejs.org/zh/guide/
1、createRouter创建路由实例
在文档提供的示例代码中,路由配置的定义与VueRouter3基本没有变化,但是路由实例,是通过执行createRouter(options)
方法创建的。
const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
]
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(),
routes,
})
复制代码
在入口文件中找到createRouter
方法,它是通过router模块导出的,router模块源码路径为src/router.ts
,在该文件中找到createRouter
方法源码,简化后代码如下。
简而言之,该方法就是传入RouterOptions
类型的对象,然后返回一个Router
实例。
export function createRouter(options: RouterOptions): Router {
// ... 省略了一堆代码 ...
const router: Router = {
currentRoute,
addRoute,
removeRoute,
hasRoute,
getRoutes,
resolve,
options,
push,
replace,
go,
back: () => go(-1),
forward: () => go(1),
beforeEach: beforeGuards.add,
beforeResolve: beforeResolveGuards.add,
afterEach: afterGuards.add,
onError: errorHandlers.add,
isReady,
install(app: App) {
// ...
},
}
return router
}
复制代码
2、参数:RouterOptions
createRouter()
方法只有一个options
对象参数,类型是RouterOptions
。
// src/router.ts
export interface RouterOptions extends PathParserOptions {
history: RouterHistory
routes: RouteRecordRaw[]
scrollBehavior?: RouterScrollBehavior
parseQuery?: typeof originalParseQuery
stringifyQuery?: typeof originalStringifyQuery
linkActiveClass?: string
linkExactActiveClass?: string
}
复制代码
从接口定义可以看出,必须包含的对象属性为:
- history:用于路由实现历史记录,类型为
RouterHistory
。 - routes:应该添加到路由的初始路由列表,类型为
RouteRecordRaw
。
以下属性为非必需属性:
- scrollBehavior:在页面之间导航时控制滚动的函数。可以返回一个
Promise
来延迟滚动。 - parseQuery:用于解析查询的自定义实现。必须解码查询键和值。参见对应的
stringifyQuery
。 - stringifyQuery:对查询对象进行字符串化的自定义实现。不应该在前面加上 ?。应该正确编码查询键和值。
parseQuery
对应于处理查询解析。 - linkActiveClass:用于激活的 RouterLink 的默认类。如果什么都没提供,则会使用
router-link-active
。 - linkExactActiveClass:用于精准激活的 RouterLink 的默认类。如果什么都没提供,则会使用
router-link-exact-active
。
下面分析下history
和routes
这两个属性。
2-1、history
RouterHistory的接口定义如下:
interface RouterHistory {
// 只读属性,基本路径,会添加到每个url的前面
readonly base: string
// 只读属性,当前路由
readonly location: HistoryLocation
// 只读属性,当前状态
readonly state: HistoryState
// 路由跳转方法
push(to: HistoryLocation, data?: HistoryState): void
// 路由跳转方法
replace(to: HistoryLocation, data?: HistoryState): void
// 路由跳转方法
go(delta: number, triggerListeners?: boolean): void
// 添加一个路由事件监听器
listen(callback: NavigationCallback): () => void
// 生成在锚点标签中使用的href的方法
createHref(location: HistoryLocation): string
// 清除listeners
destroy(): void
}
复制代码
VueRouter提供三种方法创建RouterHistory
对象:
-
createWebHashHistory()
: 创建一个 hash 历史记录。对于没有主机的 web 应用程序 (例如 file://),或当配置服务器不能处理任意 URL 时这非常有用。注意:如果 SEO 对你很重要,你应该使用 createWebHistory。 -
createWebHistory()
: 创建一个 HTML5 历史,即单页面应用程序中最常见的历史记录。应用程序必须通过 http 协议被提供服务。 -
createMemoryHistory()
:创建一个基于内存的历史记录。这个历史记录的主要目的是处理 SSR。它在一个特殊的位置开始,这个位置无处不在。如果用户不在浏览器上下文中,它们可以通过调用 router.push() 或 router.replace() 将该位置替换为启动位置。
换一句话说,在创建VueRouter实例时,options.history
参数为以上三种的其中一种或者自定义方法(需要返回RouterHistory
类型对象)。
1、createWebHashHistory
(1)base
在上面提供的例子中,调用createWebHashHistory
方法时没有传入任何参数,访问地址是 http://localhost:8080 ,则此时base为'/'
,此时没有#
,所以base会再追加一个#
符号。接着调用createWebHistory
函数继续创建其他属性或者方法。
base = location.host ? base || location.pathname + location.search : ''
if (base.indexOf('#') < 0)
base += '#'
return createWebHistory(base)
复制代码
(2)其他属性和方法
除了base属性,其他属性和方法都是通过createWebHistory(base)
方法进行创建的,所以其他属性和方法,在createWebHistory(base)
中进行分析。
2、createWebHistory
(1)base属性
首先对base进行格式化,上面在调用createWebHashHistory
创建hash模式history对象时,传进来的base的值为'/#'
,调用normalizeBase
函数后,得到的base依然是'/#'
。
如果是在创建VueRouter实例时,调用createWebHistory()
创建history对象,则此时base时undefined,调用normalizeBase
格式化后的base为空字符串''
。
base = normalizeBase(base)
复制代码
normalizeBase
方法代码如下:
// src/utils/env.ts
export const isBrowser = typeof window !== 'undefined'
// src/history/common.ts
function normalizeBase(base?: string): string {
if (!base) {
if (isBrowser) {
const baseEl = document.querySelector('base')
base = (baseEl && baseEl.getAttribute('href')) || '/'
base = base.replace(/^\w+:\/\/[^\/]+/, '')
} else {
base = '/'
}
}
if (base[0] !== '/' && base[0] !== '#') base = '/' + base
return removeTrailingSlash(base)
}
// src/location.ts
const TRAILING_SLASH_RE = /\/$/
export const removeTrailingSlash = (path: string) => path.replace(TRAILING_SLASH_RE, '')
复制代码
(2)其他属性和方法的创建
在createWebHistory
方法中,通过调用useHistoryStateNavigation(base)
方法,返回一个包含location
,state
,push
,replace
属性和方法的对象。
// src/history/html5.ts
const historyNavigation = useHistoryStateNavigation(base)
复制代码
然后通过调用useHistoryListeners(...)
函数,返回pauseListeners
,listen
,destroy
方法的对象。
const historyListeners = useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
)
复制代码
接着声明go()
方法:
function go(delta: number, triggerListeners = true) {
// ...
}
复制代码
然后将以上两个方法的到的对象以及默认对象组合成routerHistory
对象,此时routerHistory
对象创建完成。
// src/history/html5.ts
const routerHistory: RouterHistory = assign(
{
// it's overridden right after
location: '',
base,
go,
createHref: createHref.bind(null, base),
},
historyNavigation,
historyListeners
)
复制代码
最后为location
,state
这两个属性添加getter
,读取这两个属性值时,返回该对象的value属性值。
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
})
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
})
复制代码
hash和history路由模式,除了base
的处理逻辑不同,其他属性或者方法使用的是共同的逻辑。现在了解了创建RouterHistory
对象的整体流程,然后再具体分析除了base
之外的属性或者方法的实现逻辑。
-
location
location
属性是在useHistoryStateNavigation()
方法中声明的,该方法中与location
相关的代码简化后如下所示。function useHistoryStateNavigation(base: string) { const { location } = window let currentLocation: ValueContainer<HistoryLocation> = { value: createCurrentLocation(base, location), } return { location: currentLocation, } } function createCurrentLocation( base: string, location: Location ): HistoryLocation { const { pathname, search, hash } = location // 支持的hash如 #, /#, #/, #!, #!/, /#!/, 或者 /folder#end const hashPos = base.indexOf('#') // 如果是hash类型 if (hashPos > -1) { let slicePos = hash.includes(base.slice(hashPos)) ? base.slice(hashPos).length : 1 let pathFromHash = hash.slice(slicePos) // prepend the starting slash to hash so the url starts with /# if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash return stripBase(pathFromHash, '') } const path = stripBase(pathname, base) return path + search + hash } function stripBase(pathname: string, base: string): string { // 没有 base 或者 base 在开始位置找不到 if (!base || pathname.toLowerCase().indexOf(base.toLowerCase())) return pathname return pathname.slice(base.length) || '/' } 复制代码
首先声明内部属性
currentLocation
,是只有一个value
属性的对象,value
的值通过createCurrentLocation(base, location)
方法获取,该方法的作用是将当前url格式化为标准的path + search + hash
的字符串。 -
state
state
属性也是在useHistoryStateNavigation
方法中声明的,与state
相关的代码如下。function useHistoryStateNavigation(base: string) { const { history } = window let historyState: ValueContainer<StateEntry> = { value: history.state } return { state: historyState, } } 复制代码
state
属性是window.history.state
作为value
属性值的对象。在创建声明了state属性之后,需要进行以下判断。
如果该值是假值时,表示浏览器history未进行任何操作,此时需要调用
changeLocation
方法。changeLocation
方法十分重要,它是push
方法和replace
等路由跳转方法的实现基础。该方法包含三个参数:目标location、目标state对象、是否为替换当前location。if (!historyState.value) { changeLocation( currentLocation.value, { back: null, current: currentLocation.value, forward: null, position: history.length - 1, replaced: true, scroll: null, }, true ) } let createBaseLocation = () => location.protocol + '//' + location.host function changeLocation( to: HistoryLocation, state: StateEntry, replace: boolean ): void { const hashIndex = base.indexOf('#') const url = hashIndex > -1 ? (location.host && document.querySelector('base') // base + currentLocation.value ? base // #xxx + currentLocation.value : base.slice(hashIndex)) + to // `url`为:`协议://主机地址 + base + currentLocation.value`; : createBaseLocation() + base + to try { // 尝试使用history api更改window.history history[replace ? 'replaceState' : 'pushState'](state, '', url) historyState.value = state } catch (err) { // 如果使用history api失败,则降级使用window.location方法代替 location[replace ? 'replace' : 'assign'](url) } } 复制代码
-
replace 方法
replace
方法也是在useHistoryStateNavigation
中声明的,相关源代码如下,replace
方法接收to
和data
参数,首先通过参数构建state
对象,然后调用changeLocation
方法进行路由跳转。function useHistoryStateNavigation(base: string) { const { history } = window function replace(to: HistoryLocation, data?: HistoryState) { // 整合state对象 const state: StateEntry = assign( {}, history.state, buildState( historyState.value.back, to, historyState.value.forward, true ), data, { position: historyState.value.position } ) // 调用路由跳转方法 changeLocation(to, state, true) currentLocation.value = to } return { replace, } } const computeScrollPosition = () => ({ left: window.pageXOffset, top: window.pageYOffset, } as _ScrollPositionNormalized) function buildState( back: HistoryLocation | null, current: HistoryLocation, forward: HistoryLocation | null, replaced: boolean = false, computeScroll: boolean = false ): StateEntry { return { back, current, forward, replaced, position: window.history.length, scroll: computeScroll ? computeScrollPosition() : null, } } 复制代码
-
push 方法
push
方法和replace
方法类型,相关源码如下。function useHistoryStateNavigation(base: string) { const { history } = window function push(to: HistoryLocation, data?: HistoryState) { const currentState = assign( {}, historyState.value, history.state as Partial<StateEntry> | null, { forward: to, scroll: computeScrollPosition(), } ) changeLocation(currentState.current, currentState, true) const state: StateEntry = assign( {}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data ) changeLocation(to, state, false) currentLocation.value = to } return { push, } } 复制代码
观察
push
方法,发现方法内部调用了两次changeLocation
方法,这是为什么呢?主要的原因是需要保存当前
location
对应的页面滚动信息(scroll
)以及将要跳转的页面信息(forward
),此时调用changeLocation
方法的replace
参数为true
,也就是更新当前location
。然后再创建要跳转的页面的
state
,这里和replace
的不同的是,postion
的值需要+1,此时调用changeLocation
方法的replace
参数为false
。 -
go 方法
go
方法是直接在createWebHistory
方法中声明的,调用window.history.go(delta)
进行路由跳转。另外支持triggerListeners
参数,默认触发listeners
中的回调函数执行,如果传入了false
,则调用historyListeners.pauseListeners()
,该方法修改了pauseState
变量,该变量在后面的popState
事件中会涉及到。function go(delta: number, triggerListeners = true) { if (!triggerListeners) historyListeners.pauseListeners() history.go(delta) } 复制代码
function pauseListeners() { pauseState = currentLocation.value } 复制代码
-
listen 方法
listen
方法由useHistoryListeners
函数返回,在useHistoryListeners
函数内部查看listen方法相关源码。function useHistoryListeners() { let listeners: NavigationCallback[] = [] let teardowns: Array<() => void> = [] function listen(callback: NavigationCallback) { listeners.push(callback) const teardown = () => { const index = listeners.indexOf(callback) if (index > -1) listeners.splice(index, 1) } teardowns.push(teardown) return teardown } return { listen, } } 复制代码
listen
方法的作用是将传入的回调函数添加到listeners
数组中,并返回移除函数,同时将移除函数添加到teardowns
数组中,方便进行批量移除。 -
createHref 方法
该方法在
createWebHistory
方法中bind
公共的createHref
方法,创建一个参数为base
的createHref
方法,调用routerHistory
中的createHref
方法时,返回一个由当前base
组成href。// src/history/html5.ts function createWebHistory(base?: string): RouterHistory { // 省略其他代码 const routerHistory: RouterHistory = assign( { // 省略其他代码 createHref: createHref.bind(null, base), }, // 省略其他代码 ) // 省略其他代码 } 复制代码
// src/history/common.ts // remove any character before the hash const BEFORE_HASH_RE = /^[^#]+#/ export function createHref(base: string, location: HistoryLocation): string { return base.replace(BEFORE_HASH_RE, '#') + location } 复制代码
-
destroy 方法
destroy
方法是在useHistoryListeners
函数中生成,简化后的函数源码为:function useHistoryListeners() { let teardowns: Array<() => void> = [] const popStateHandler: PopStateListener = ({ state, }: { state: StateEntry | null }) => { // ... } function beforeUnloadListener() { // ... } function destroy() { for (const teardown of teardowns) teardown() teardowns = [] window.removeEventListener('popstate', popStateHandler) window.removeEventListener('beforeunload', beforeUnloadListener) } window.addEventListener('popstate', popStateHandler) window.addEventListener('beforeunload', beforeUnloadListener) return { destroy, } } 复制代码
destroy
方法被调用时,首先遍历teardowns
数组,移除所有listener
,然后清空teardowns
数组,接着解绑在window
对象上的popstate
和beforeunload
事件。popstate
事件在活动历史记录条目更改时被触发,此时调用popStateHandler
函数,该函数源码如下。const popStateHandler: PopStateListener = ({ state, }: { state: StateEntry | null }) => { const to = createCurrentLocation(base, location) const from: HistoryLocation = currentLocation.value const fromState: StateEntry = historyState.value let delta = 0 if (state) { currentLocation.value = to historyState.value = state // ignore the popstate and reset the pauseState if (pauseState && pauseState === from) { pauseState = null return } delta = fromState ? state.position - fromState.position : 0 } else { replace(to) } listeners.forEach(listener => { listener(currentLocation.value, from, { delta, type: NavigationType.pop, direction: delta ? delta > 0 ? NavigationDirection.forward : NavigationDirection.back : NavigationDirection.unknown, }) }) } 复制代码
当用户操作浏览器导航按钮或者应用中调用了push/replace/go等方法时,都会触发该事件,调用此函数。除了更新某些对象值之外,这个函数的关键在于遍历
listeners
数组调用每一个注册的回调函数。在上面分析
go
方法的时候,提到了pauseListeners
方法,当该方法被调用后,且等于上一次的location时,pauseState && pauseState === from
条件成立,然后会执行pauseState = null;return
语句,所以就不会继续执行后面遍历listeners
的逻辑了。beforeunload
事件当浏览器窗口关闭或者刷新时被触发,此时会调用beforeUnloadListener
函数,该函数源码如下,主要作用是将当前滚动信息保存到当前历史记录实体中。function beforeUnloadListener() { const { history } = window if (!history.state) return history.replaceState( assign({}, history.state, { scroll: computeScrollPosition() }), '' ) } 复制代码
createWebHistory
和createWebHashHistory
的实现原理分析完毕,先做一个小总结。从以上的分析可以得出,history和hash模式的差别在于base
的处理,换句话可以说是浏览器url表现方式的差异,路由的跳转、事件的监听,都是基于history api
,但是当使用history进行跳转出现错误时,VueRouter做了容错处理,降级使用location
进行跳转。
3、createMemoryHistory
有了以上两个函数的分析基础,再来看createMemoryHistory
函数就容易理解过了,内存类型的RouterHistory
,由于不是运行在浏览器端,所以不存在window.history
对象,接下来直接看routerHistory
对象中的属性和方法分别是如何实现的。
-
base
直接取的base参数的值,默认是’/’。 -
location
初始值为常量START,每次获取location
时,都会通过queue[position]
获取最新location
的值。// src/history/common.ts const START: HistoryLocation = '' // src/history/memory.ts const routerHistory: RouterHistory = { // rewritten by Object.defineProperty location: START, } Object.defineProperty(routerHistory, 'location', { enumerable: true, // queue数组模拟的是浏览器历史记录 get: () => queue[position], }) 复制代码
-
state
默认为一个空对象state: {}
,但是源代码中有TODO的注释,后续版本应该会有改动。// TODO: should be kept in queue state: {}, 复制代码
-
push方法
简而言之,该方法实现的是往queue数组中追加一个location。let queue: HistoryLocation[] = [START] function setLocation(location: HistoryLocation) { position++ if (position === queue.length) { // we are at the end, we can simply append a new entry queue.push(location) } else { // we are in the middle, we remove everything from here in the queue queue.splice(position) queue.push(location) } } const routerHistory: RouterHistory = { push(to, data?: HistoryState) { setLocation(to) }, } 复制代码
-
replace方法
与push
方法类型,差别在于replace
被调用时,会先把queue
中最后一个location
删掉,然后再调用setLocation
方法,追加一个location
。const routerHistory: RouterHistory = { replace(to) { // remove current entry and decrement position queue.splice(position--, 1) setLocation(to) }, } 复制代码
-
go方法
通过delta参数大小,然后更新position的值。这里并没有调用
setLocation
方法,也就是不需要更新queue
,只是更新了position
,后续获取location
时是通过queue[position]
获取的,所以可以拿到正确的location
。function triggerListeners( to: HistoryLocation, from: HistoryLocation, { direction, delta }: Pick<NavigationInformation, 'direction' | 'delta'> ): void { const info: NavigationInformation = { direction, delta, type: NavigationType.pop, } for (let callback of listeners) { callback(to, from, info) } } const routerHistory: RouterHistory = { go(delta, shouldTrigger = true) { const from = this.location const direction: NavigationDirection = // we are considering delta === 0 going forward, but in abstract mode // using 0 for the delta doesn't make sense like it does in html5 where // it reloads the page delta < 0 ? NavigationDirection.back : NavigationDirection.forward position = Math.max(0, Math.min(position + delta, queue.length - 1)) if (shouldTrigger) { triggerListeners(this.location, from, { direction, delta, }) } }, } 复制代码
-
listen方法
跟浏览器端要实现的功能基本一致,方法被调用时,将传进来的回调函数添加到listeners
数组中,然后返回删除该回调的函数。let listeners: NavigationCallback[] = [] const routerHistory: RouterHistory = { listen(callback) { listeners.push(callback) return () => { const index = listeners.indexOf(callback) if (index > -1) listeners.splice(index, 1) } }, } 复制代码
-
createHref方法
实现方式和浏览器端一致。const routerHistory: RouterHistory = { createHref: createHref.bind(null, base), } 复制代码
-
destroy方法
该方法被调用时,重置路由相关变量。const routerHistory: RouterHistory = { destroy() { listeners = [] queue = [START] position = 0 }, } 复制代码
这里与浏览器端的对比,少了teardowns数组,此方法中是直接将listeners数组清空,为什么浏览器端不能直接清空,而需要通过teardowns数组进行管理呢??
history属性的分析到此完结了,通过history三种创建方式的分析,我们了解到了浏览器端路由相关的属性、方法和事件,以及非浏览器端是如何模拟实现浏览器端的路由的,其中内存类型感觉还存在需要完善的地方,后续版本应该会有些许改动。
2-2、routes 属性
回到createRouter
方法中,可以看到该方法中只有一个地方用到了options.routes
,它作为createRouterMatcher
参数,执行后返回一个RouterMatcher
类型的对象。
export function createRouter(options: RouterOptions): Router {
const matcher = createRouterMatcher(options.routes, options)
// ....
}
复制代码
matcher
模块的入口是src/matcher/index.ts
,该模块提供了路由配置相关的属性和方法,matcher
接口定义如下。
// src/matcher/index.ts
interface RouterMatcher {
addRoute: (record: RouteRecordRaw, parent?: RouteRecordMatcher) => () => void
removeRoute: {
(matcher: RouteRecordMatcher): void
(name: RouteRecordName): void
}
getRoutes: () => RouteRecordMatcher[]
getRecordMatcher: (name: RouteRecordName) => RouteRecordMatcher | undefined
resolve: (
location: MatcherLocationRaw,
currentLocation: MatcherLocation
) => MatcherLocation
}
复制代码
1、createRouterMatcher函数的基本逻辑
简化后的代码如下。
function createRouterMatcher(
routes: RouteRecordRaw[],
globalOptions: PathParserOptions
): RouterMatcher {
const matchers: RouteRecordMatcher[] = []
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
globalOptions = mergeOptions(
{ strict: false, end: true, sensitive: false } as PathParserOptions,
globalOptions
)
function getRecordMatcher(name: RouteRecordName) {
// ...
}
function addRoute(
record: RouteRecordRaw,
parent?: RouteRecordMatcher,
originalRecord?: RouteRecordMatcher
) {
// ...
}
function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) {
// ...
}
function getRoutes() {
// ...
}
function insertMatcher(matcher: RouteRecordMatcher) {
// ...
}
function resolve(
location: Readonly<MatcherLocationRaw>,
currentLocation: Readonly<MatcherLocation>
): MatcherLocation {
// ...
}
// add initial routes
routes.forEach(route => addRoute(route))
return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}
复制代码
该函数接收两个参数,第一个参数是路由配置数组,第二个参数是VueRouter初始化时传进来的options
。然后声明两个变量matchers
和matcherMap
,然后是声明一系列方法,在return之前,遍历routes
,通过addRoute
方法,将路由配置转化为matcher。
现在来逐个分析这几个方法。
-
addRoute方法
function addRoute( record: RouteRecordRaw, parent?: RouteRecordMatcher, originalRecord?: RouteRecordMatcher ) { let isRootAdd = !originalRecord let mainNormalizedRecord = normalizeRouteRecord(record) mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record const options: PathParserOptions = mergeOptions(globalOptions, record) const normalizedRecords: typeof mainNormalizedRecord[] = [ mainNormalizedRecord, ] if ('alias' in record) { const aliases = typeof record.alias === 'string' ? [record.alias] : record.alias! for (const alias of aliases) { normalizedRecords.push( assign({}, mainNormalizedRecord, { components: originalRecord ? originalRecord.record.components : mainNormalizedRecord.components, path: alias, aliasOf: originalRecord ? originalRecord.record : mainNormalizedRecord, }) as typeof mainNormalizedRecord ) } } let matcher: RouteRecordMatcher let originalMatcher: RouteRecordMatcher | undefined for (const normalizedRecord of normalizedRecords) { let { path } = normalizedRecord if (parent && path[0] !== '/') { let parentPath = parent.record.path let connectingSlash = parentPath[parentPath.length - 1] === '/' ? '' : '/' normalizedRecord.path = parent.record.path + (path && connectingSlash + path) } matcher = createRouteRecordMatcher(normalizedRecord, parent, options) if (originalRecord) { originalRecord.alias.push(matcher) } else { originalMatcher = originalMatcher || matcher if (originalMatcher !== matcher) originalMatcher.alias.push(matcher) if (isRootAdd && record.name && !isAliasRecord(matcher)) removeRoute(record.name) } if ('children' in mainNormalizedRecord) { let children = mainNormalizedRecord.children for (let i = 0; i < children.length; i++) { addRoute( children[i], matcher, originalRecord && originalRecord.children[i] ) } } originalRecord = originalRecord || matcher insertMatcher(matcher) } return originalMatcher ? () => { removeRoute(originalMatcher!) } : noop } 复制代码
这个方法要做的事情就是,根据路由配置对象,创建一个
matcher
对象,然后添加到matchers
数组中,最后根据originalMatcher
条件return一个移除路由方法或者noop(let noop = () => {}
)。应用中传进来的路由配置是不完整的,所以需要首先通过normalizeRouteRecord
方法对路由配置格式化,生成完整的路由配置对象,其中props属性通过normalizeRecordProps
函数格式化,格式化对象是根据路由配置对象中的component
或者components
生成,如果存在component
属性,则props对象包含一个default属性,并赋值为配置中的props,否则取components
对象中的key,并从路由配置属性props中取对应的值。function normalizeRouteRecord( record: RouteRecordRaw ): RouteRecordNormalized { return { path: record.path, redirect: record.redirect, name: record.name, meta: record.meta || {}, aliasOf: undefined, beforeEnter: record.beforeEnter, props: normalizeRecordProps(record), children: record.children || [], instances: {}, leaveGuards: new Set(), updateGuards: new Set(), enterCallbacks: {}, components: 'components' in record ? record.components || {} : { default: record.component! }, } } function normalizeRecordProps( record: RouteRecordRaw ): Record<string, _RouteRecordProps> { const propsObject = {} as Record<string, _RouteRecordProps> const props = (record as any).props || false if ('component' in record) { propsObject.default = props } else { for (let name in record.components) propsObject[name] = typeof props === 'boolean' ? props : props[name] } return propsObject } 复制代码
调用
normalizeRouteRecord
方法格式化路由配置对象后,将处理后的mainNormalizedRecord
对象添加到normalizedRecords
数组中。
接着判断'alias' in record
,如果有别名,则添加记录到normalizedRecords
数组,基本逻辑是复制mainNormalizedRecord
,然后重新设置components
,path
,aliasOf
属性,换句话说,别名的实现原理是通过拷贝记录,并调整部分属性得到新的记录。
以上代码都是为接下来创建matcher做准备,继续往下分析代码,首先准备两个变量:matcher
、originalMatcher
,然后开始遍历normalizedRecords
。这里有个小技巧,
matcher
是在遍历的过程中赋值的,既然如此为什么不放到遍历内部呢?这是因为如果放在内部,每次遍历都会生成一个新的对象,如果路由数量很多,则会在短时间内频繁地创建临时对象,导致内存占用上升,可能导致频繁的垃圾回收,最终导致页面卡顿,所以放在遍历的外面,可以减少临时变量的数量,优化内存占用,减少垃圾回收的次数。在遍历的内部,根据路由配置对象,创建matcher,并插入到matchers中,分为以下几步:
-
1、如果是子路由配置,且path不是以/开头,则将父路由的path和子路由的path加起来生成完整path
-
2、调用
createRouteRecordMatcher
方法,创建matcher对象,如果存在parent,则在parent.children
中添加当前matcher对象。function createRouteRecordMatcher( record: Readonly<RouteRecord>, parent: RouteRecordMatcher | undefined, options?: PathParserOptions ): RouteRecordMatcher { const parser = tokensToParser(tokenizePath(record.path), options) const matcher: RouteRecordMatcher = assign(parser, { record, parent, children: [], alias: [], }) if (parent) { if (!matcher.record.aliasOf === !parent.record.aliasOf) parent.children.push(matcher) } return matcher } 复制代码
matcher对象类型为
RouteRecordMatcher
,它继承自PathParser
接口,所以一个matcher对象应该包含以下属性和方法,前面五个属性或方法是通过tokensToParser(tokenizePath(record.path), options)
创建的,下面将在该方法中分析这几个属性或方法的实现逻辑。re: RegExp
score: Array<number[]>
keys: PathParserParamKey[]
parse(path: string): PathParams | null
stringify(params: PathParams): string
record: RouteRecord
保存格式化后的路由配置记录parent: RouteRecordMatcher | undefined
保存父路由matcher对象children: RouteRecordMatcher[]
子路由,初始化为空数组alias: RouteRecordMatcher[]
别名,初始化为空数组
在分析
tokensToParser
之前,需要先看一下tokenizePath(record.path)
,该方法将path转化为一个token数组。export const enum TokenType { Static, Param, Group, } const enum TokenizerState { Static, Param, ParamRegExp, // custom re for a param ParamRegExpEnd, // check if there is any ? + * EscapeNext, } interface TokenStatic { type: TokenType.Static value: string } interface TokenParam { type: TokenType.Param regexp?: string value: string optional: boolean repeatable: boolean } interface TokenGroup { type: TokenType.Group value: Exclude<Token, TokenGroup>[] } export type Token = TokenStatic | TokenParam | TokenGroup const ROOT_TOKEN: Token = { type: TokenType.Static, value: '', } const VALID_PARAM_RE = /[a-zA-Z0-9_]/ // After some profiling, the cache seems to be unnecessary because tokenizePath // (the slowest part of adding a route) is very fast // const tokenCache = new Map<string, Token[][]>() export function tokenizePath(path: string): Array<Token[]> { if (!path) return [[]] if (path === '/') return [[ROOT_TOKEN]] if (!path.startsWith('/')) { throw new Error( __DEV__ ? `Route paths should start with a "/": "${path}" should be "/${path}".` : `Invalid path "${path}"` ) } // if (tokenCache.has(path)) return tokenCache.get(path)! function crash(message: string) { throw new Error(`ERR (${state})/"${buffer}": ${message}`) } let state: TokenizerState = TokenizerState.Static let previousState: TokenizerState = state const tokens: Array<Token[]> = [] // the segment will always be valid because we get into the initial state // with the leading / let segment!: Token[] function finalizeSegment() { if (segment) tokens.push(segment) segment = [] } // index on the path let i = 0 // char at index let char: string // buffer of the value read let buffer: string = '' // custom regexp for a param let customRe: string = '' function consumeBuffer() { if (!buffer) return if (state === TokenizerState.Static) { segment.push({ type: TokenType.Static, value: buffer, }) } else if ( state === TokenizerState.Param || state === TokenizerState.ParamRegExp || state === TokenizerState.ParamRegExpEnd ) { if (segment.length > 1 && (char === '*' || char === '+')) crash( `A repeatable param (${buffer}) must be alone in its segment. eg: '/:ids+.` ) segment.push({ type: TokenType.Param, value: buffer, regexp: customRe, repeatable: char === '*' || char === '+', optional: char === '*' || char === '?', }) } else { crash('Invalid state to consume buffer') } buffer = '' } function addCharToBuffer() { buffer += char } while (i < path.length) { char = path[i++] if (char === '\\' && state !== TokenizerState.ParamRegExp) { previousState = state state = TokenizerState.EscapeNext continue } switch (state) { case TokenizerState.Static: if (char === '/') { if (buffer) { consumeBuffer() } finalizeSegment() } else if (char === ':') { consumeBuffer() state = TokenizerState.Param } else { addCharToBuffer() } break case TokenizerState.EscapeNext: addCharToBuffer() state = previousState break case TokenizerState.Param: if (char === '(') { state = TokenizerState.ParamRegExp } else if (VALID_PARAM_RE.test(char)) { addCharToBuffer() } else { consumeBuffer() state = TokenizerState.Static // go back one character if we were not modifying if (char !== '*' && char !== '?' && char !== '+') i-- } break case TokenizerState.ParamRegExp: // TODO: is it worth handling nested regexp? like :p(?:prefix_([^/]+)_suffix) // it already works by escaping the closing ) // https://paths.esm.dev/?p=AAMeJbiAwQEcDKbAoAAkP60PG2R6QAvgNaA6AFACM2ABuQBB# // is this really something people need since you can also write // /prefix_:p()_suffix if (char === ')') { // handle the escaped ) if (customRe[customRe.length - 1] == '\\') customRe = customRe.slice(0, -1) + char else state = TokenizerState.ParamRegExpEnd } else { customRe += char } break case TokenizerState.ParamRegExpEnd: // same as finalizing a param consumeBuffer() state = TokenizerState.Static // go back one character if we were not modifying if (char !== '*' && char !== '?' && char !== '+') i-- customRe = '' break default: crash('Unknown state') break } } if (state === TokenizerState.ParamRegExp) crash(`Unfinished custom RegExp for param "${buffer}"`) consumeBuffer() finalizeSegment() // tokenCache.set(path, tokens) return tokens } 复制代码
这个函数的目的是将path字符串转换为数组,方便后续的处理。
比如/user
会转换为[[{type: 0, value: 'user'}]]
,/user/:id
则会转换成:[ [{type: 0, value: "user"}], [{type: 1, value: "id", regexp: "", repeatable: false, optional: false}], ] 复制代码
再回到
tokensToParser
函数进行分析PathParser
是如何生成的。- re
就是一个正则表达式,通过从参数传进来的path的tokens以及一些列的条件判断,将token转换为匹配path的正则表达式。const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = { sensitive: false, strict: false, start: true, end: true, } function tokensToParser( segments: Array<Token[]>, extraOptions?: _PathParserOptions ): PathParser { const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions) let pattern = options.start ? '^' : '' for (const segment of segments) { // 遍历tokens逐步完善正则表达式 // TODO:这里挖个坑,以后再分析如何生成正则表达式的 } if (!options.strict) pattern += '/?' if (options.end) pattern += '$' else if (options.strict) pattern += '(?:/|$)' const re = new RegExp(pattern, options.sensitive ? '' : 'i') // ... } 复制代码
- score
给当前path计算一个分数,在后续path之间进行比较时,可以用到score的值,相当于权重的比较。let score: Array<number[]> = [] for (const segment of segments) { const segmentScores: number[] = segment.length ? [] : [PathScore.Root] // ... score.push(segmentScores) } if (options.strict && options.end) { const i = score.length - 1 score[i][score[i].length - 1] += PathScore.BonusStrict } 复制代码
- keys
保存路由中的动态参数。const keys: PathParserParamKey[] = [] for (const segment of segments) { // ... if (token.type === TokenType.Param) { const { value, repeatable, optional, regexp } = token keys.push({ name: value, repeatable, optional, }) } // ... } 复制代码
- parse
传入path参数,然后根据re,然后遍历得到的结果,获取动态参数对象。function parse(path: string): PathParams | null { const match = path.match(re) const params: PathParams = {} if (!match) return null for (let i = 1; i < match.length; i++) { const value: string = match[i] || '' const key = keys[i - 1] params[key.name] = value && key.repeatable ? value.split('/') : value } return params } 复制代码
- stringify
该方法传入params对象,然后返回参数对象结合path组成的替换了参数值的path。function stringify(params: PathParams): string { let path = '' // for optional parameters to allow to be empty let avoidDuplicatedSlash: boolean = false for (const segment of segments) { if (!avoidDuplicatedSlash || !path.endsWith('/')) path += '/' avoidDuplicatedSlash = false for (const token of segment) { if (token.type === TokenType.Static) { path += token.value } else if (token.type === TokenType.Param) { const { value, repeatable, optional } = token const param: string | string[] = value in params ? params[value] : '' if (Array.isArray(param) && !repeatable) throw new Error( `Provided param "${value}" is an array but it is not repeatable (* or + modifiers)` ) const text: string = Array.isArray(param) ? param.join('/') : param if (!text) { if (optional) { // if we have more than one optional param like /:a?-static we // don't need to care about the optional param if (segment.length < 2) { // remove the last slash as we could be at the end if (path.endsWith('/')) path = path.slice(0, -1) // do not append a slash on the next iteration else avoidDuplicatedSlash = true } } else throw new Error(`Missing required param "${value}"`) } path += text } } } return path } 复制代码
经过以上复杂的步骤,就得到了一个完整的
matcher
对象,实属不易。 -
3、然后处理
originalMatcher
属性,如果是首次赋值,则将originalMatcher
赋值为matcher
,后面的遍历中,不再重新赋值,而是将matcher
添加到originalRecord.alias
数组中。 -
4、接着根据
'children' in mainNormalizedRecord
条件判断是否有子路由,如果有子路由,则遍历mainNormalizedRecord.children
数组,并调用addRoute方法,参数为:children[i]
,matcher
,originalRecord && originalRecord.children[i]
。 -
5、最后调用
insertMatcher(matcher)
方法,将matcher
添加到matchers
中,并更新matcherMap
。function insertMatcher(matcher: RouteRecordMatcher) { let i = 0 while ( i < matchers.length && comparePathParserScore(matcher, matchers[i]) >= 0 ) i++ matchers.splice(i, 0, matcher) if (matcher.record.name && !isAliasRecord(matcher)) matcherMap.set(matcher.record.name, matcher) } 复制代码
addRoute
方法执行完毕。 -
-
resolve方法
resolve
方法返回MatcherLocation
对象,该对象包含的属性为:name
、path
、params
、matched
、meta
,作用就是根据传入的location
进行路由匹配,找到location
对应的matcher
对应的路由信息。function resolve( location: Readonly<MatcherLocationRaw>, currentLocation: Readonly<MatcherLocation> ): MatcherLocation { let matcher: RouteRecordMatcher | undefined let params: PathParams = {} let path: MatcherLocation['path'] let name: MatcherLocation['name'] if ('name' in location && location.name) { matcher = matcherMap.get(location.name) if (!matcher) throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, { location, }) name = matcher.record.name params = assign( paramsFromLocation( currentLocation.params, matcher.keys.filter(k => !k.optional).map(k => k.name) ), location.params ) path = matcher.stringify(params) } else if ('path' in location) { path = location.path matcher = matchers.find(m => m.re.test(path)) if (matcher) { params = matcher.parse(path)! name = matcher.record.name } } else { matcher = currentLocation.name ? matcherMap.get(currentLocation.name) : matchers.find(m => m.re.test(currentLocation.path)) if (!matcher) throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, { location, currentLocation, }) name = matcher.record.name params = assign({}, currentLocation.params, location.params) path = matcher.stringify(params) } const matched: MatcherLocation['matched'] = [] let parentMatcher: RouteRecordMatcher | undefined = matcher while (parentMatcher) { matched.unshift(parentMatcher.record) parentMatcher = parentMatcher.parent } return { name, path, params, matched, meta: mergeMetaFields(matched), } } 复制代码
-
removeRoute方法
该方法接受一个参数matcherRef
,参数类型可以传入路由name属性或者matcher对象,然后通过matcherRef
找到对应的matcher或者matcher索引,在matcherMap
、matchers
中删除对应的matcher,然后再递归删除matcher.children
和matcher.alias
中对该matcher对象的引用。function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) { if (isRouteName(matcherRef)) { const matcher = matcherMap.get(matcherRef) if (matcher) { matcherMap.delete(matcherRef) matchers.splice(matchers.indexOf(matcher), 1) matcher.children.forEach(removeRoute) matcher.alias.forEach(removeRoute) } } else { let index = matchers.indexOf(matcherRef) if (index > -1) { matchers.splice(index, 1) if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name) matcherRef.children.forEach(removeRoute) matcherRef.alias.forEach(removeRoute) } } } 复制代码
-
getRoutes方法
该方法直接返回matchers
数组。function getRoutes() { return matchers } 复制代码
-
getRecordMatcher方法
该方法十分简单,就是通过路由名称从matcherMap
中获取对应的matcher
对象。function getRecordMatcher(name: RouteRecordName) { return matcherMap.get(name) } 复制代码
3、安装VueRouter实例
通过上面的分析,我们了解到:
- 通过
createRouter(options)
方法创建VueRouter对象 - 该方法的参数为一个配置对象,该对象必须提供两个属性:history和routes
- history根据需要可以通过VueRouter提供的三种方法创建不同类型的history对象,该对象提供了路由属性和路由跳转方法。
- routes为路由配置,VueRouter根据routes配置创建了matcher对象,通过matcher对象,可以为VueRouter实例提供了诸如添加路由、匹配路由、移除路由等路由配置相关的属性和方法。
接下来我们继续分析创建了VueRouter实例对象后,如何将VueRouter实例和Vue实例关联起来的。
在文档示例代码中,通过 app.use
将VueRouter实例对象添加到Vue实例对象中。
// 5. 创建并挂载根实例
const app = Vue.createApp({})
//确保 _use_ 路由实例使整个应用支持路由。
app.use(router)
复制代码
app.use(router)
执行时,实际上调用了VueRouter实例的install
方法。
export const START_LOCATION_NORMALIZED: RouteLocationNormalizedLoaded = {
path: '/',
name: undefined,
params: {},
query: {},
hash: '',
fullPath: '/',
matched: [],
meta: {},
redirectedFrom: undefined,
}
export function createRouter(options: RouterOptions): Router {
// shallowRef: 创建一个 ref,它跟踪自己的 .value 更改,但不会使其值成为响应式的。
const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
START_LOCATION_NORMALIZED
)
let routerHistory = options.history
const router: Router = {
install(app: App) {
const router = this
// 在vue实例中,注册全局路由组件 RouterLink 和 RouterView
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
// 在vue实例给 config.globalProperties.$router 赋值为当前 VueRouter 实例
app.config.globalProperties.$router = router
/**
* 当读取 app.config.globalProperties.$route 时,
* 返回 unref(currentRoute), 即当前路由信息,初始值为path为`/`的对象
*/
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true,
get: () => unref(currentRoute),
})
if (
isBrowser &&
// 避免在多个应用中使用router时多次 push,只有在首次install时stated才是false
!started &&
currentRoute.value === START_LOCATION_NORMALIZED
) {
started = true
// 跳转到浏览器url中对应的路由
push(routerHistory.location)
}
// 复制currentRoute对象并转为响应式对象reactiveRoute,该对象可以在组件中通过inject routeLocationKey 获取
const reactiveRoute = {} as {
[k in keyof RouteLocationNormalizedLoaded]: ComputedRef<
RouteLocationNormalizedLoaded[k]
>
}
for (let key in START_LOCATION_NORMALIZED) {
// @ts-ignore: the key matches
reactiveRoute[key] = computed(() => currentRoute.value[key])
}
// 向vue实例中注入 router 相关 provider,组件使用 inject 来接收这几个值
// 这几个值的属性都是 Symbol 类型,可以通过 src/injectionSymbols.ts 源码了解
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)
// 拦截vue实例unmount方法,当vue实例卸载时,重置某些属性和事件解绑,然后再执行vue实例卸载方法
let unmountApp = app.unmount
installedApps.add(app)
app.unmount = function () {
installedApps.delete(app)
if (installedApps.size < 1) {
removeHistoryListener()
currentRoute.value = START_LOCATION_NORMALIZED
started = false
ready = false
}
unmountApp()
}
},
}
}
复制代码
总的来说,install
方法做了以下几件事:
- 注册两个路由组件为vue全局组件。
- 在
app.config.globalProperties
上添加$router
和$route
属性,$router
是VueRouter实例对象本身,$route
为当前location对应的路由对象。 - 如果是首次install,则通过
push
方法跳转到url对应的路由。 - 注入三个VueRouter相关的provider。
- 拦截vue实例的
unmount
方法,在unmount
方法调用之前,先执行VueRouter相关的卸载工作。
四、总结
在阅读源码的过程中,Typescript使阅读源码相对少吃点力,通过类型定义对理解每个变量的作用提供了很好的帮助。从VueRouter实例对象的创建开始分析,对路由的基本实现原理有了一定的理解,也认识到要实现一个功能完善的路由功能,不是一件很简单的事情,需要考虑的边界问题很多。由于能力有限,也还存在很多细节不能够理解。阅读源码,始终是让人受益匪浅的一件事,不管目前能够理解多少。
本文分析的是VueRouter的基础部分,后续还会继续分析进阶部分,一起学习,一起进步吧!
其他
- 文档生成工具:vitepress
- 测试:单元测试使用 jest,e2e测试使用 nightwatch
- changelog生成:conventional-changelog-cli
- 提交代码检查:lint-staged
- Typescript: YYDS
- 还有很多需要填坑的地方,比如发现了一些TODO
history api 浏览器兼容性
现在浏览器基本都支持history api,VueRouter中的push、replace、go方法,都基于history api。
本人能力有限,可能会有一些理解上的错误,欢迎走过路过的各位大佬不吝赐教,感谢!?