本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐
源码专栏
感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:
开场
哈喽大咖好,我是跑手,本次给大家带来vue-router@4.x
源码解读的一些干货。
众所周知,vue-router
是vue
官方指定的路由管理库,拥有21.2k github star(18.9k for Vue 2
+ 2.3k for Vue 3
)和 2,039,876 的周下载量,实属难得的优秀开源库。
对很多开发者来讲,了解vue-router
还是很有必要的,像React Router
、Vue Router
这系列单页应用底层都是借助 H5 History API能力来实现的。
那么,Vue Router
又是如何借用H5 History,完美与Vue结合在一起,并处理当中千丝万缕的联系的呢?在《Vue Router 4 源码探索系列》专栏中,我们一起揭秘它的神秘面纱。
那么今天,我们先来聊下大家在使用vue-router
时候第一个用到的方法——createRouter
。createRouter
作为vue-router
最重要的方法之一,里面集合了路由初始化整个流程,核心路由方法的定义等职责。
在这篇文章里,你能获得以下增益:
- 了解vue-router的包管理模式 —— pnpm下对Monorepo的管理;
- 了解在
vue3
框架下,createRouter
创建路由整个过程,以及它周边函数的功能职责; - 了解router对象中
getRoutes
、push
等12个核心方法的实现原理;
关于vue-router@4.x
对于vue-router
的版本3.x
和4.x
还是有区别的,并且源码的git仓库也不一样。vue-router@4.x
主要是为了兼容vue3
而生,包括兼容vue3的composition API,并提供更友好、灵活的hooks方法等。本章节主要是探讨4.x
版本的源码。
源码仓库:vue-router@4.x
pnpm的包管理模式
纵贯而视,作者用了pnpm管理Monorepo方式来组建vue-router,这样项目管理模式带来的好处无需多言,主要有以下优势:
- pnpm优势:引入全局的
store
配合hard link
机制来优化项目内的node_modules
依赖,使得存储空间、打包性能得到显著提升。根据目前官方提供的 benchmark 数据可以看出在一些综合场景下, pnpm比 npm/yarn 快了大概两倍; - Monorepo 支持:pnpm因其本身的设计机制特点,特别适合多包管理的情景,导致很多多包管理的问题都得到了相当有效的解决;
- workspace 支持:pnpm 提供了 workspace 来支持依赖版本的引用问题,见官网文档: pnpm workspaces。
扩展阅读:
Monorepo
是管理项目代码的方式之一,指在一个大的项目仓库(repo)中 管理多个模块/包(package),每个包可以独立发布,这种类型的项目大都在项目根目录下有一个packages文件夹,分多个项目管理。 大概结构如下:
项目结构
. ├── .github ├── .gitignore ├── .npmrc // 项目的配置文件 ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── netlify.toml ├── package.json ├── packages // 项目分包 │ ├── docs // vue router API文档 │ ├── playground // 本地调试项目 │ └── router // vue router源码 ├── pnpm-lock.yaml // 依赖版本控制 ├── pnpm-workspace.yaml // 工作空间根目录 └── scripts // 工程脚本
由于本文主要探讨是vue-router原理,对于包管理在这先不多介绍,日后有机会单独出一篇pnpm文章介绍。
createRouter
使用场景🌰
简单易用源于插件的设计模式,下面是最基础router引入例子:
import Vue from 'vue' import { createRouter, createWebHistory } from 'vue-router' // 创建和挂载 const routes = [ { path: '/', component: { template: '<div>Home</div>' } }, { path: '/about', component: { template: '<div>About</div>' } }, ] const router = createRouter({ history: createWebHistory(), routes, }) const app = Vue.createApp({}) app.use(router) app.mount('#app') // 组件内使用 import { useRouter } from 'vue-router'; const router = useRouter(); console.log(router.currentRoute) router.back() // ...
函数定义
众所周知,createRouter
作为 vue-router
的初始化方法,重要地位非同一般,当中也完成了路由对象创建,方法挂载等一系列操作,要了解路由,从这里入手最合适不过了。
这里先锚定下:本章节源码讲解更多是思路和关键逻辑的研读,并不会咬文嚼字到每一行代码,大家可以下载源码到本地一起对照阅读。
我们可以在 packages/router/rollup.config.js
找到vue-router的入口文件src/index.ts
,这个文件中把我们能想到的功能函数、hooks都export出去了,当然也包含了createRouter
。
按图索骥,createRouter
方法的定义在 packages/router/src/router.ts
中 ,逻辑代码有901行,但做的事情比较简单,所以要看懂也不难,等下我们再细述逻辑。
先看createRouter方法的Typescript定义:
createRouter(options: RouterOptions): Router { /**/ }
RouterOptions
就是我们创建路由传进去的配置项,可以参考官网介绍 。
返回项Router
则是创建出来的全局路由对象,包含了路由实例和常用的内置方法。类型定义如下:
export interface Router { // 当前路由 readonly currentRoute: Ref<RouteLocationNormalizedLoaded> // 路由配置项 readonly options: RouterOptions // 是否监听 listening: boolean // 添加路由 addRoute(parentName: RouteRecordName, route: RouteRecordRaw): () => void addRoute(route: RouteRecordRaw): () => void // 删除路由 removeRoute(name: RouteRecordName): void // 是否存在路由name=xxx hasRoute(name: RouteRecordName): boolean // 获取所有路由matcher getRoutes(): RouteRecord[] // 返回路由地址的标准化版本 resolve( to: RouteLocationRaw, currentLocation?: RouteLocationNormalizedLoaded ): RouteLocation & { href: string } // 路由push跳转 push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined> // 路由replace跳转 replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined> // 路由回退 back(): ReturnType<Router['go']> // 路由前进 forward(): ReturnType<Router['go']> // 路由跳页 go(delta: number): void // 全局导航守卫 beforeEach(guard: NavigationGuardWithThis<undefined>): () => void beforeResolve(guard: NavigationGuardWithThis<undefined>): () => void afterEach(guard: NavigationHookAfter): () => void // 路由错误处理 onError(handler: _ErrorHandler): () => void // 路由器是否完成初始化导航 isReady(): Promise<void> // vue2.x版本路由安装方法 install(app: App): void }
实现流程图
createRouterMatcher
createRouter
方法的第一步就是根据传进来的路由配置列表,为每项创建matcher。这里的matcher可以理解成一个路由页面的匹配器,包含了对路由所有信息和常规操作方法。但它与我们通过getRoutes获取的路由对象不一样,路由对象只是它的一个子集,存储在matcher的record
字段中。
最终输出
createRouterMatcher
执行完后,会返回的5个函数{ addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
,为后续的路由创建提供帮助。这些函数的作用,无非就是围绕着上面说到的matcher
增删改查操作,例如,getRoutes
用于返回所有matcher,removeRoute
则是删除某个指定的matcher。。。
为了方便大家阅读,我们先看下创建的matcher最终长啥样?我们可以使用getRoutes()
方法获取到的对象集,得到最终生成的matcher列表:
import { createRouterMatcher, createWebHistory, } from 'vue-router' export const routerHistory = createWebHistory() const options = { // your options... } console.log('matchers:', createRouterMatcher(options.routes, options).getRoutes())
输出:
其中,record
字段就是我们经常使用到的vue-router
路由对象(即router.getRoute()
得到的对象),这样理解方便多了吧 [\手动狗头]。。。
createRouterMatcher处理流程
讲了一大堆,还是回归到源码。createRouterMatcher
函数一共286行,初始化matcher入口在代码340行,调用的方法是addRoute
。
addRoute处理流程
涉及matcher初始化和addRoute处理还是挺复杂的,为了不影响大家理解createRouter
流程,笔者会开另一篇文章单独讲,这里先让大家鸟瞰下处理流程:
当addRoute
流程走完后,最后返回original matcher集合,得到文中上面截图的matchers。
导航守卫相关处理
在执行完createRouterMatcher
后就是初始化几个导航守卫了,守卫有三种:
beforeEach
:在任何导航之前执行。beforeResolve
:在导航解析之前执行。afterEach
:在任何导航之后执行。
初始化源码如下:
const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>() const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>() const afterGuards = useCallbacks<NavigationHookAfter>() // ... const router: Router = { // ... beforeEach: beforeGuards.add, beforeResolve: beforeResolveGuards.add, afterEach: afterGuards.add, }
这里说下useCallbacks
方法,利用回调函数实现守卫逻辑保存、执行以及重置。源码部分:
/** * Create a list of callbacks that can be reset. Used to create before and after navigation guards list */ export function useCallbacks<T>() { let handlers: T[] = [] function add(handler: T): () => void { handlers.push(handler) return () => { const i = handlers.indexOf(handler) if (i > -1) handlers.splice(i, 1) } } function reset() { handlers = [] } return { add, list: () => handlers, reset, } }
内置方法
接下来,createRouter
还创建了一些列内置方法,方便我们使用。
matcher相关
function addRoute( parentOrRoute: RouteRecordName | RouteRecordRaw, route?: RouteRecordRaw ) { let parent: Parameters<typeof matcher['addRoute']>[1] | undefined let record: RouteRecordRaw if (isRouteName(parentOrRoute)) { parent = matcher.getRecordMatcher(parentOrRoute) record = route! } else { record = parentOrRoute } return matcher.addRoute(record, parent) } function removeRoute(name: RouteRecordName) { const recordMatcher = matcher.getRecordMatcher(name) if (recordMatcher) { matcher.removeRoute(recordMatcher) } else if (__DEV__) { warn(`Cannot remove non-existent route "${String(name)}"`) } } function getRoutes() { return matcher.getRoutes().map(routeMatcher => routeMatcher.record) } function hasRoute(name: RouteRecordName): boolean { return !!matcher.getRecordMatcher(name) }
这几个是对路由项curd相关的,其实都是调用 createRouterMatcher
生成的matcher里的能力。
path相关
resolve
返回路由地址的标准化版本。还包括一个包含任何现有 base
的 href
属性。这部分源码比较清晰不在这赘述了,主要包含path信息的组装返回。
push
push
方法应该是路由跳转用的最多的功能了,它的原理基于h5的,实现前端url重写而不与服务器交互,达到单页应用改变组件显示的目的。使用场景:
// 浏览器带参数跳转有三种写法 router.push('/user?name=johnny') router.push({path: '/user', query: {name: 'johnny'}}) router.push({name: 'user', query: {name: 'johnny'}})
push
调用了pushWithRedirect
(源码),我们开始源码拆解分析:
// function pushWithRedirect const targetLocation: RouteLocation = (pendingLocation = resolve(to)) const from = currentRoute.value const data: HistoryState | undefined = (to as RouteLocationOptions).state const force: boolean | undefined = (to as RouteLocationOptions).force // to could be a string where `replace` is a function const replace = (to as RouteLocationOptions).replace === true // 寻找重定向的路由 const shouldRedirect = handleRedirectRecord(targetLocation) if (shouldRedirect) return pushWithRedirect( assign(locationAsObject(shouldRedirect), { state: data, force, replace, }), // keep original redirectedFrom if it exists redirectedFrom || targetLocation )
先处理redirect(重定向路由),符合条件继续递归调用pushWithRedirect
方法。
// if it was a redirect we already called `pushWithRedirect` above const toLocation = targetLocation as RouteLocationNormalized toLocation.redirectedFrom = redirectedFrom let failure: NavigationFailure | void | undefined if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) { failure = createRouterError<NavigationFailure>( ErrorTypes.NAVIGATION_DUPLICATED, { to: toLocation, from } ) // trigger scroll to allow scrolling to the same anchor handleScroll( from, from, // this is a push, the only way for it to be triggered from a // history.listen is with a redirect, which makes it become a push true, // This cannot be the first navigation because the initial location // cannot be manually navigated to false ) }
当已经找到重定向的目标路由后,如果要目标地址与当前路由一致并且不设置强制跳转,则直接抛出异常,后处理页面滚动行为,页面滚动源码 handleScroll 方法大家有兴趣可以看看。
return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
pushWithRedirect
最后会返回一个Promise
,在没有错误时会执行navigate
方法。
关于navigate
的逻辑,大致如下:
function navigate( to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded ): Promise<any> { let guards: Lazy<any>[] /** * extractChangingRecords根据to(跳转到的路由)和from(即将离开的路由)到matcher里匹配,把结果存到3个数组中 * leavingRecords:即将离开的路由 * updatingRecords:要更新的路由,一般只同路由更新 * enteringRecords:要进入的路由,一般用于不同路由互跳 */ const [leavingRecords, updatingRecords, enteringRecords] = extractChangingRecords(to, from) /** * extractComponentsGuards用于提取路由的钩子(为beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave之一,通过第二参数决定) * 因为路由跳转前要把原路由的beforeRouteLeave钩子要执行一遍,因此要提取leavingRecords里所有路由的钩子 * 有由于vue组件销毁顺序是从子到父,因此要reverse反转路由数组保证子路由钩子的高优先级 */ guards = extractComponentsGuards( leavingRecords.reverse(), 'beforeRouteLeave', to, from ) /** * 将组件内用onBeforeRouteLeave方法注册的导航守卫添加到guards里面 */ for (const record of leavingRecords) { record.leaveGuards.forEach(guard => { guards.push(guardToPromiseFn(guard, to, from)) }) } // 如果过程有任何路由触发canceledNavigationCheck,则跳过后续所有的导航守卫执行 const canceledNavigationCheck = checkCanceledNavigationAndReject.bind( null, to, from ) guards.push(canceledNavigationCheck) /** * 执行所有beforeRouteLeave钩子函数,并在后续按vue组件生命周期执行新路由组件挂载完成前的所有导航守卫 */ return ( runGuardQueue(guards) .then(() => { // 执行全局 beforeEach 钩子 guards = [] for (const guard of beforeGuards.list()) { guards.push(guardToPromiseFn(guard, to, from)) } guards.push(canceledNavigationCheck) return runGuardQueue(guards) }) .then(() => { // 执行组件内 beforeRouteUpdate 钩子 guards = extractComponentsGuards( updatingRecords, 'beforeRouteUpdate', to, from ) for (const record of updatingRecords) { record.updateGuards.forEach(guard => { guards.push(guardToPromiseFn(guard, to, from)) }) } guards.push(canceledNavigationCheck) // run the queue of per route beforeEnter guards return runGuardQueue(guards) }) .then(() => { // 执行全局 beforeEnter 钩子 guards = [] for (const record of to.matched) { // do not trigger beforeEnter on reused views if (record.beforeEnter && !from.matched.includes(record)) { if (isArray(record.beforeEnter)) { for (const beforeEnter of record.beforeEnter) guards.push(guardToPromiseFn(beforeEnter, to, from)) } else { guards.push(guardToPromiseFn(record.beforeEnter, to, from)) } } } guards.push(canceledNavigationCheck) // run the queue of per route beforeEnter guards return runGuardQueue(guards) }) .then(() => { // NOTE: at this point to.matched is normalized and does not contain any () => Promise<Component> // 清除已经存在的enterCallbacks, 因为这些已经在 extractComponentsGuards 里面添加 to.matched.forEach(record => (record.enterCallbacks = {})) // check in-component beforeRouteEnter guards = extractComponentsGuards( enteringRecords, 'beforeRouteEnter', to, from ) guards.push(canceledNavigationCheck) // run the queue of per route beforeEnter guards return runGuardQueue(guards) }) .then(() => { // 执行全局 beforeResolve 钩子 guards = [] for (const guard of beforeResolveGuards.list()) { guards.push(guardToPromiseFn(guard, to, from)) } guards.push(canceledNavigationCheck) return runGuardQueue(guards) }) // 捕获其他错误 .catch(err => isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED) ? err : Promise.reject(err) ) ) }
在navigate
执行完后,还要对抛出的异常做最后处理,来完结整个push跳转过程,这里处理包含:
return (failure ? Promise.resolve(failure) : navigate(toLocation, from)) .catch((error: NavigationFailure | NavigationRedirectError) => isNavigationFailure(error) ? // navigation redirects still mark the router as ready,这部分会进入下面的.then()逻辑 isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) ? error : markAsReady(error) // also returns the error : // 未知错误时直接抛出异常 triggerError(error, toLocation, from) ) .then((failure: NavigationFailure | NavigationRedirectError | void) => { if (failure) { // 重定向错误,进入10次重试 if ( isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) ) { // ... } } else { /** * 如果在navigate过程中没有抛出错误信息,则确认本次跳转 * 这时会调用finalizeNavigation函数,它会处理浏览器url、和页面滚动行为, * 完成后调用markAsReady方法,将路由标记为准备状态,执行isReady钩子里面的逻辑 */ failure = finalizeNavigation( toLocation as RouteLocationNormalizedLoaded, from, true, replace, data ) } // 最后触发全局afterEach钩子,至此push操作全部完成 triggerAfterEach( toLocation as RouteLocationNormalizedLoaded, from, failure ) return failure })
replace
源码:
function replace(to: RouteLocationRaw) { return push(assign(locationAsObject(to), { replace: true })) }
replace操作其实就是调用push,只是加了个{ replace: true }
参数,这个参数的作用体现在上面讲到的finalizeNavigation
方法里面对url的处理逻辑,相关源码如下:
// 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)
go、back、forward
这几个函数底层都依靠H5 history API原生能力,但不是直接与这些api对接,而是与初始化是传入的history option(由 createWebHashHistory
或 createWebHistory
或 createMemoryHistory
生成的router history对象)打交道。关于vue-router history
如何与原生history
打通,会新开一篇文章讲述。
导航守卫相关
beforeEach: beforeGuards.add, beforeResolve: beforeResolveGuards.add, afterEach: afterGuards.add,
这部分也在上面讲过了,通过useCallbacks
的add方法往matcher里头添加回调事件,在vue-router
对应的生命周期取出调用。
onError
官方定义:添加一个错误处理程序,在导航期间每次发生未捕获的错误时都会调用该处理程序。这包括同步和异步抛出的错误、在任何导航守卫中返回或传递给
next
的错误,以及在试图解析渲染路由所需的异步组件时发生的错误。
实现原理:和导航守卫一样,通过useCallbacks
实现。
install
Vue
全局安装插件方法。
落幕
到这里,createRouter
内部原理差不多讲完了。这个函数加上它的裙带逻辑大概占据了整个 vue-router
30%以上的核心逻辑,读懂了它,理解其他部分也就没那么难了。
预告:文中埋了个坑,就是关于
matcher
是如何生成,以及它在整个vue-router
中充当什么作用?关于这个问题,我们下期来看看路由matcher的前世今生。
最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹