前言
本文是基于 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,还有全局对象
$routerarduinoapp.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