theme: nico
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
哈喽大咖好,我是跑手,本次给大家带来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
最重要的方法之一,里面集合了路由初始化整个流程,核心路由方法的定义等职责。
在这篇文章里,你能获得以下增益:
vue3
框架下,createRouter
创建路由整个过程,以及它周边函数的功能职责;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管理Monorepo方式来组建vue-router,这样项目管理模式带来的好处无需多言,主要有以下优势:
store
配合 hard link
机制来优化项目内的node_modules
依赖,使得存储空间、打包性能得到显著提升。根据目前官方提供的 benchmark 数据可以看出在一些综合场景下, pnpm比 npm/yarn 快了大概两倍;扩展阅读:
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文章介绍。
简单易用源于插件的设计模式,下面是最基础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
}
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
函数一共286行,初始化matcher入口在代码340行,调用的方法是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
还创建了一些列内置方法,方便我们使用。
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里的能力。
返回路由地址的标准化版本。还包括一个包含任何现有 base
的 href
属性。这部分源码比较清晰不在这赘述了,主要包含path信息的组装返回。
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
})
源码:
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)
这几个函数底层都依靠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
对应的生命周期取出调用。
官方定义:添加一个错误处理程序,在导航期间每次发生未捕获的错误时都会调用该处理程序。这包括同步和异步抛出的错误、在任何导航守卫中返回或传递给
next
的错误,以及在试图解析渲染路由所需的异步组件时发生的错误。
实现原理:和导航守卫一样,通过useCallbacks
实现。
Vue
全局安装插件方法。
到这里,createRouter
内部原理差不多讲完了。这个函数加上它的裙带逻辑大概占据了整个 vue-router
30%以上的核心逻辑,读懂了它,理解其他部分也就没那么难了。
预告:文中埋了个坑,就是关于
matcher
是如何生成,以及它在整个vue-router
中充当什么作用?关于这个问题,我们下期来看看路由matcher的前世今生。