前言
本文是基于 Vue Router 4(for Vue3) 分析的 ,repository。拆分的 MEMO 如下,路由的钩子函数这部分源码不会深入。建议对官方文档有一定了解,了解源码为什么要这样做
涉及到 html5 的 pushState、replaceState API 以及 popstate、hashchange 这些事件概念
MEMO
createRouter
// router 入口history
// 属性决定历史记录模式是 hash 还是 HTML5 的 api- 根据 URL 匹配组件,计算嵌套组件深度
router view
// 渲染 URL 对应的组件- 历史条目记录监听
- 根据路由 URL 变化更新组件
createRouter 创建 router 入口
文件位置:src/router.ts
-
app.use(router)
实际上调用的是router.install
方法 -
返回了 router: Router 接口形状的对象
-
像
addRoute
、removeRoute
这些 API 也是在 createRouter 内部声明,封装到router: Router
内部进行返回的 -
install 方法内针对 router-link、router-view 进行了全局注册 app.component,还有全局对象
$router
arduinoapp.component('RouterLink', RouterLink) app.component('RouterView', RouterView) app.config.globalProperties.$router = router
-
通过 provide/inject 向后代组件注入了 routerKey、routeLocationKey、routerViewLocationKey
scssapp.provide(routerKey, router) app.provide(routeLocationKey, shallowReactive(reactiveRoute)) app.provide(routerViewLocationKey, currentRoute)
-
app unmount
的时候会调用removeHistoryListener
方法卸载路由。removeHistoryListener 由 routerHistory.listen 返回,后面介绍 MEMO 2 history 会重新介绍iniapp.unmount = function () { installedApps.delete(app) // the router is not attached to an app anymore if (installedApps.size < 1) { // invalidate the current navigation pendingLocation = START_LOCATION_NORMALIZED removeHistoryListener && removeHistoryListener() removeHistoryListener = null currentRoute.value = START_LOCATION_NORMALIZED started = false ready = false } unmountApp() }
history 属性
history 可以设置 html5 模式、hash 模式、memory 模式,官方文档
序前言
这部分有几个前置知识以及思考
- popstate 事件定义 mdn。重点是以下 备注: 调用
history.pushState()
或者history.replaceState()
不会触发popstate
事件。popstate
事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用history.back()
方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。 - popstate 和 hashchange 事件触发条件,是否存在包含关系。因为等下源码会揭示 hash 模式最终也是调用 html5 模式的
createWebHistory
函数,也就是说 popstate 事件一定程度上是可以代替 hashchange 事件的(技术向前发展,不讨论兼容性相关)。参考链接 cdn,也可以在浏览器实践操作下
hash 模式
文件位置:src/history/hash.ts
-
createWebHashHistory(base?: string) { ... return createWebHistory(base) }
- base 默认值为 pathName + search
- 以 《 # 》结尾
- 最终调用的是 HTML5 模式的入口,返回 createWebHistory(base)
- API 文档
csharpbase = location.host ? base || location.pathname + location.search : '' if (!base.includes('#')) base += '#' if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) { warn( `A hash base must end with a "#":\n"${base}" should be "${base.replace( /#.*$/, '#' )}".` ) } return createWebHistory(base)
html5 模式
文件位置:src/history/html5.ts
-
createWebHistory(base?: string): RouterHistory
,返回 routerHistory 对象 -
通过
Object.defineProperty
声明了 location、state 等只读属性到 routerHistory;Object.assign
合并了 routerHistory.push、replace、go、listen 等方法。这些属性和方法对应的文档dartObject.defineProperty(routerHistory, 'location', { enumerable: true, get: () => historyNavigation.location.value, }) Object.defineProperty(routerHistory, 'state', { enumerable: true, get: () => historyNavigation.state.value, }) const routerHistory: RouterHistory = assign( { // it's overridden right after location: '', base, go, createHref: createHref.bind(null, base), }, historyNavigation, // 包含了 push、replace historyListeners // 包含了 listen、destroy )
-
routerHistory 的
push、replace、go
方法本质是对 history 的pushState、replaceState、go
封装。其中 replaceState 以及 pushState 是集成在 changeLocation 里面的scsstry { // BROWSER QUIRK // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds history[replace ? 'replaceState' : 'pushState'](state, '', url) historyState.value = state } catch (err) { if (__DEV__) { warn('Error with push/replace State', err) } else { console.error(err) } // Force the navigation, this also resets the call count location[replace ? 'replace' : 'assign'](url) }
根据 URL 匹配组件
文件位置:src/matcher/index.ts
序前言
vue router 官方文档的 children 属性决定一个路由组件是否为嵌套组件。组件之间的嵌套关系可以用父子关系来表示,它们是一起渲染形成完整的一个页面的
提前了解下 matcherMap
数据结构,因为这部分数据结构嵌套的比较复杂,这一块源码也主要是以 matcherMap 数据作为依赖
csharp
matcherMap.set(matcher.record.name, matcher) // [[name, matcher: ], ...]
matcher: RouteRecordMatcher = {
record: RouteRecordNormalized,
parent,
// these needs to be populated by the parent
children: [],
alias: [],
}
record: RouteRecordNormalized = {
path: record.path,
redirect: record.redirect,
name: record.name,
...
components:
'components' in record
? record.components || null
: record.component && { default: record.component },
}
-
createRouterMatche(routes)
,vue 外部传入 routes 属性作为参数- 主要创建
matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
对象 // [[name, matcher: ], ...] - 返回
addRoute, resolve, removeRoute, getRoutes, getRecordMatcher
等方法,用于辅助$router
动态更新路由 官方文档
- 主要创建
-
addRoute
与 routes.children 递归调用,为每个一个 route 都保存了 RouteRecordMatcher 对象,[[name, matcher: RouteRecordMatcher], ...]
ini// add initial routes routes.forEach(route => addRoute(route))
- 内部与
route.children
搭配递归调用。这一部分添加的parent
是嵌套路由组件的依据
cssfor (let i = 0; i < children.length; i++) { addRoute( children[i], matcher, originalRecord && originalRecord.children[i] ) }
- 内部与
-
重点 :
matcher.resolve(location , currentLocation): MatcherLocation
-
返回
toLocation
的 MatcherLocation 数据,里面最重要的是matched
属性 -
matched
是一个先进先出的队列 ,通过 unshift 保存着 toLocation matcher 的 record: RouteRecordNormalized 数据,以及它所有祖先组件 location matcher 的 record- 如果 location 是一个嵌套路由,则 matched 匹配所有祖先组件的 matcher,下标越大,嵌套层级越深
- 如果 location 不是嵌套路由,matched 只有一个元素,即当前路由
-
-
routerHistory.listen、pushWithRedirect
都有调用到matcher.resolve
生成 toLocation,赋值给 currentLocation- 后面会重新介绍
routerHistory.listen、pushWithRedirect
这 2 个方法
- 后面会重新介绍
-
router view 使用 matched 匹配路由组件
-
前面已经介绍了
matched
保存的是一个嵌套路由组件的 matcher 数组。默认的是非嵌套路由组件,且 components 肯定非空,所以viewDepthKey = 1
-
如果是嵌套路由,则所有祖先组件以及 currentRoute 会一起渲染形成完整页面。每个祖先组件内部都有
<RouterView />
组件用于生成下一层级路由组件。以下源码可知,每一个嵌套路由都depth.value + 1
,从而计算出最后的 currentRoute 路由深度- 嵌套路由的 components 可以为空,显示子路由组件内容。这时候会进入 while 循环,循环内部的
initialDepth++
表明 empty component 也算进嵌套路由深度里面
typescriptconst injectedDepth = inject(viewDepthKey, 0) // The depth changes based on empty components option, which allows passthrough routes e.g. routes with children // that are used to reuse the `path` property const depth = computed<number>(() => { let initialDepth = unref(injectedDepth) const { matched } = routeToDisplay.value let matchedRoute: RouteLocationMatched | undefined while ( (matchedRoute = matched[initialDepth]) && !matchedRoute.components ) { initialDepth++ } return initialDepth }) const matchedRouteRef = computed<RouteLocationMatched | undefined>( () => routeToDisplay.value.matched[depth.value] ) // 注入当前组件嵌套深度 provide( viewDepthKey, computed(() => depth.value + 1) )
- 嵌套路由的 components 可以为空,显示子路由组件内容。这时候会进入 while 循环,循环内部的
-
router view 渲染 URL 对应的组件
文件位置:src/RuterView.ts
export 的 interface
有
- RouterViewProps // API 文档
- RouterViewDevtoolsContext // 开发者工具
- RouterViewImpl // 只在 RouterView 内部有使用
- RouterView // 渲染当前路由
RouterView RouterView = RouterViewImpl as unknown as {...}
-
返回 vnode
phpreturn ( // pass the vnode to the slot as a prop. // h and <component :is="..."> both accept vnodes normalizeSlot(slots.default, { Component: component, route }) || component )
-
通过
matchedRoute.components
匹配当前路由- 没有找到就使用
slots.default
来渲染
phpconst ViewComponent = matchedRoute && matchedRoute.components![currentName] if (!ViewComponent) { return normalizeSlot(slots.default, { Component: ViewComponent, route }) }
- 没有找到就使用
历史条目记录监听
文件位置:src/history/html5.ts
-
routerHistory.listen
- 本质是一个发布/订阅模式。通过 listen 收集回调函数保存到 listeners: NavigationCallback[] 里,后续发布模式下执行
- 返回 teardown 用于卸载 listener,即上文提及的
removeHistoryListener
scss// set up the listener and prepare teardown callbacks listeners.push(callback) const teardown = () => { const index = listeners.indexOf(callback) if (index > -1) listeners.splice(index, 1) } teardowns.push(teardown) return teardown
-
通过 popstate 监听 URL 变化,执行相应 handler
window.addEventListener('popstate', popStateHandler)
- 在 popStateHandler 事件监听中,循环执行已收集的 listeners
phplisteners.forEach(listener => { listener(currentLocation.value, from, { delta, type: NavigationType.pop, direction: delta ? delta > 0 ? NavigationDirection.forward : NavigationDirection.back : NavigationDirection.unknown, }) })
更新组件
文件位置:src/router.ts
2 种途径更新组件
-
通过事件监听更新路由组件
routerHistory.listen
事件监听中调用。上面提及到 listen 会收集 callback,在popstate
事件中遍历执行 listeners- listen 内部执行
pushWithRedirect
重定向以及navigate(toLocation, from)
导航等操作
-
通过
$router.push
、$router.replace
方法主动更新路由- 内部也是通过
pushWithRedirect
调用。
- 内部也是通过
以上方法最后都会调用 finalizeNavigation 更新 currentRoute
-
内部通过 routerHistory 的 push、replace 更新历史条目,也就是
history.pushState
、history.replaceState
更新历史条目scssif (isPush) { // on the initial navigation, we want to reuse the scroll position from // history state if it exists if (replace || isFirstNavigation) routerHistory.replace( toLocation.fullPath, assign( { scroll: isFirstNavigation && state && state.scroll, }, data ) ) else routerHistory.push(toLocation.fullPath, data) } // accept current navigation currentRoute.value = toLocation