一行源码没有,但讲Vue-router4核心实现!
前言
文章小小的有点标题党,毕竟是讲原理的文章,想要讲明白这个事,总会有那么一行两行源码的,但本文会竭尽全力的不贴源码。
全文字数2W,会结合N多张清晰的流程图,图说vue-router@4的核心实现。
为了方便消化,我会尽讲简单,直捞干货。文中内容相对vue-router完全实现做了很多阉割和修改,但足以能阐述清楚vue-router的核心实现原理。让你对vue-router的执行流程有清晰的认知。
友情提示:左手源码,右手本文,两相结合,不亦乐乎。
鼠标点击图片可以放大哦~
文章内容量大,流程图多,建议大家看的时候尽量保证思路跟上流程时序。如果跟不上也不要紧,你只需要保证把每一个小章节的流程搞清晰,文章的最后我会放上一张流程全图,通过全图把思维最终连贯也是一个不错的方法。
本文只探讨HTML5 history 的路由模式,我会尽量少的阐述抽象的源码内容。
不管怎样说,我们现在也是在讲原理,学习原理之前,需要先对基本的使用方式有深刻的印象。
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
// 1. 定义路由组件
const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }
// 2. 定义路由配置,每个路径映射一个路由视图组件
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
]
// 3. 创建路由实例,可以指定路由模式,传入路由配置对象
const router = createRouter({
history: createWebHistory(),
routes
})
// 4. 创建 app 实例
const app = createApp({})
// 5. 在挂载页面 之前先安装路由
app.use(router)
// 6. 挂载页面
app.mount('#app')
源码调试方法:
- 在 node_modules/vue-router/dist/vue-router.mjs 中打 debugger。
- 本地运行项目,开始调试。
1. 想想vue-router的核心需求
vue-router的核心需求从宏观来看,无非就是下图阐述的流程,可以将下面流程视为 vue-router 的中心法则:
图中红色区域 “执行一系列导航守卫” → “更新浏览器地址栏的地址” -> “router-view处的组件视图更新” 这三步会被文内多处用到,他们就是vue-router一直围绕的主线流程,为了方便理解,我们称之为 “导航更新一条龙”。
结合中心法则,开始宏观构思vue-router的实现方式,就可以理解成要实现以下4条需求:
- 实现更新路由Api,浏览器改变路由的其他方式应做到和更新路由Api同效。
- 注册的导航守卫,要能按照既定顺序链式执行,守卫可以终止或改变导航更新结果;
- 浏览器地址栏中的路由得到更新,视图更新,但不重刷页面;
- 不同router-view要按所属层级更新对应视图。
解决上面4个需求的实现,就可以说已实现简版的vue-router。接下来,我们会按照程序执行流的时序,结合流程图,看vue-router是怎么解决上述4个需求的实现的。
2. 初始化准备
2.1 createRouter() 前准备 options.history
const router = createRouter(option)
是使用vue-router的第一步,但真正的开始不是createRouter,而是options的准备阶段中的 createWebHistory()
。
const router = createRouter({
history: createWebHistory(),
routes
})
看下 createWebHistory() 到底做了什么?

createWebHistory()
内部还会执行 useHistoryStateNavigation()
和 useHistoryListeners()
两个方法。这两个方法的内部都是定义一些后续会用到的方法,然后返回这些方法。createWebHistory()最后会将这useHistoryStateNavigation()
和 useHistoryListeners()
返回的方法融合成一个集合对象,作为 option.history
传入createRouter()
。
2.1.1 useHistoryStateNavigation:
上面需求分析时提到过,要实现更新路由Api,其中最重要的无非就是 router.push/replace()
。
在vue-router中,关于 history.push/replace()
等对浏览器原生接口的封装工作都在useHistoryState()
中完成的,此时调用 option.history.push()
就相当于调用了原生的 history.push()
。
2.1.2 useHistoryListeners:
需求分析时提到过:“浏览器改变路由的其他方式应做到和vue-router提供的更新路由Api同效”。
浏览器改变路由的其他方式有哪些?
- 浏览器后退前进按钮、
history.go()
- ...
想要监听这类事件很容易:window.addEventListener('popstate', popStateHandler)
重点在于 popStateHandler
的实现,看看它是如何处理:
在上面流程图中,黄色背景的部分是useHistoryListeners
的作用域空间,在useHistoryListeners
中维护了一个listeners
数组,这个数组主要用于储存popstate
触发时的回调。可以通过listen
方法将需要执行的回调推入listeners
数组。其中listen
方法会被useHistoryListeners
返回以便在外部作用域调用。
而popStateHandler
只是将listeners
按照出队顺序遍历执行。最后通过window.addEventListener('popstate', popStateHandler)
的形式监听浏览器后退等行为的触发,当浏览器后退发生时,listeners中收集的回调就会依照出队的顺序遍历执行。
想要在popstate事件触发时执行类似router.push()
的后续“导航一条龙”流程,无非就是将“导航一条龙“作为回调,通过listen的方式收集进listeners数组即可。
这一个过程的具体实现我们先留下疑问,后面在router.install()
方法中就会提及。
假如现在浏览器后退按钮被点击时,listeners
中收集的回调就会依照出队的顺序遍历执行。
现在createWebHistory
的核心流程我们已经分析完了,总结一下:
createWebHistory()
的返回值主要由useHistoryStateNavigation()
和useHistoryListeners()
两个方法的返回值融合而成;最终赋值给createRouter(options)
的参数options.history
属性。后面options.history
会成为贯彻 vue-router 始终的routerHistory
对象,你可以理解成,以后出现的routerHistory
就是指options.history
。useHistoryStateNavigation()
返回经过封装过的原生history.push/replace()
等方法,只要执行 routerHistory.push() 或 routerHistory.replace()本质相当于调用了history.push/replace()浏览器原生方法。
useHistoryListeners()
:维护popstate事件触发的回调函数数组,并对外暴露了listen
方法,未来只需要调用routerHistory.listen(fn)
,就会将fn收集为回调,在 popstate 事件触发时就会执行 fn。
2.2 createRouter(options) 创建路由实例
在createWebHistory()
拿到options.history
后,options
就可以带入createRouter
中执行router实例的创建流程。
在router实例创建前,还需要经过下面两个核心步骤:
- 将
routes
拍平,同时创建命名路由映射表 - 初始化 currentRoute 响应式路由信息

2.2.1 将routes拍平,同时创建命名路由映射表
其中 createrRouterMatcher
的主要职责就是负责将routes
拍平,同时创建命名路由映射表。来看下createRouterMatcher
流程图是怎样的:


createRouterMatcher
的主要作为就是遍历options
中的routes
,将routes
中的每一个路由配置成员route
对象通过addRoute
方法转换成matcher
对象后,会将matcher
推入到matchers
数组中和matcherMap
中,至此实现将routes
拍平同时创建命名路由映射表的过程。下面详细来探讨一下这个过程。
在addRoute
中,route
将改称为record
,首先的第一步就是进入 createRouteRecordMatcher
方法。createRouteRecordMatcher
方法的作用就是将 record
转换为 matcher
对象:

所谓 matcher
对象,就是对 record
对象的包装对象,增加了一些其他的信息属性。但将 record
转换成 matcher
对象有什么用呢?
别忘了 record
的其实就是带入 createRouter
的 options.routes
的成员 route
。record
是树形结构的,createRouterMatcher
的目标就是将 options.routes
的拍平,如果直接拍平成一维数组,层层嵌套的父子关系信息就随着树形结构的摊平不复存在,这不方便后续逻辑中找到某个 record
的父子关系信息。所以为了能保留 record
父子关系信息,就需要将 record
在向上包装一层对象,形成 matcher
。在 matcher
中最重要的4个属性就是 record
、parent
、children
、re
:
- record:
record
自身,当前路由信息对象,相当于routes[i]
; - parent:
record
的parent record
,储存着当前路由之上的所有父路由信息; - children:
record
的children
,储存着当前路由下的所有子路由信息; - re:由
record.path
解析出的正则表达式,凡是能被这个路径匹配到的url,说明当前url的路由信息就是此record
。
只要能在后续拍平过程中,维护好每一个 matcher
的这三个属性,父子关系信息就能在拍平后得以保存了。
将 record
改造成 matcher
对象后,就会判断当前 record
是否有子路由配置对象,如果有,就将当前 record
作为 parent
代入递归执行 addRoute
。如果没有,那就通过 insertMatcher
方法将当前 matcher
存储到局部变量 matchers
或 matcherMap
中,这两个变量虽然是局部变量,但却能在 createrRouterMatcher
外部通过闭包的方式访问到。
可 matcher
最终会被存储 matchers
和 matcherMap
哪个变量中,这还需要详细看看 insertMatcher
的实现:

首先 matcher
被无条件推入 matchers
数组,如果当前 record
还具备 name
属性,说明该路由还支持通过命名路由的方式进行跳转,将 name
作为键名,record
自身作为 value
,加入 matcherMap
中,方便后面可以直接根据 name
快速找到对应的路由配置对象。
至此,我们可以总结一下:
options.routes
已经被拍平成由matcher
组成的matchers
数组,并且可以从matcher
的children
和parent
属性中寻得它的父子路由配置信息;- 命名路由可以根据
route.name
直接从matcherMap
中直接找到对应的matcher
。
但这里遗留了一个问题:
- 为什么 vue-router 要将
routes
拍平?
关于这个问题,我们先不解答,因为在下面的流程中,这个问题很自然就会得到解释。
接下来我们回到 createRouter
的总体执行流程中,看看下一步 "初始化 currentRoute
响应式路由信息" 都做了些什么?
2.2.2 初始化 currentRoute 响应式路由信息

这一步非常简单纯粹,vue-router 在全局先准备好了一个初始化信息对象 START_LOCATION_NORMALIZED
,然后通过 shallowRef
Api 将 START_LOCATION_NORMALIZED
变为响应式对象 currentRoute
。这个 currentRoute
在 vue-router 中有着非常重要的职责!它是实现路由变化引起 router-view
处视图改变的核心枢纽。 我们后面会探讨 currentRoute
是怎么承担起这个核心枢纽的职责的。
在得到 currentRoute
后,接下来就是返回我们所熟知的 router
实例了。通过 useRouter()
拿到的 router 实例就是它。在 router
属性中,最关键的就是它的 install
属性。项目中 app.use(router)
就是执行了 install
方法。
接下来我们就详细看看 router.install()
方法到底都做了哪些事情?
2.3 router.install 方法的实现
在分析 router.install
的流程之前,我们不妨先思考下 install
所需要完成的核心需求点有哪些?
首先,当我们初次进入项目页面时,vue-router 会被安装,此时浏览器地址栏是有 url 的,url 携带着路径信息(‘/’ 也算是路径信息),
这个初始化路径 /about/a 很有可能会对应着某些路由配置信息,需要进行组件相关渲染工作。
其次,在 Vue 项目的开发过程中,组件内调用 router.push()
进行跳转,监听 route
变化,拿到 route.fullpath
等种种工作都需要依赖 router
、route
两个变量。
因此,在 app.use(router)
中,必须实现app根实例下任何深度的子组件都能通过 $router
和 $route
分别访问到 router
和 route
两个对象,如果是在 setup
作用域下,也可以通过 useRouter()
和 useRoute()
来访问到 router
和 route
。要想办法将 router
和 route
通过某些方式传递给app根实例下的子组件们。
总结来看,下面两点就是在 router.install() 执行阶段,必须解决的两个需求:
- 根据初始化路径进行首次渲染;
- 向任何深度的子组件暴露 router 和 route 两个对象;
接下来,我们就心怀这两个需求点,来看看 router.install
都做了什么核心逻辑:

2.3.1 根据初始化路径进行首次渲染
在通过 app.use(router)
调用 app.install()
方法之后,首先就开始进入解决初始化路径渲染问题的流程中。初始化路径渲染,本质上就是直接调用了 pushWithRedirct()
方法进行了 ”导航更新一条龙“ 的执行过程。忘记什么是”导航更新一条龙“的小伙伴,不妨翻看文章开篇处,有对这个概念的解释,这里不在赘述。

这个 pushWithRedirct()
方法承担的就是”导航更新一条龙“ 的执行过程,是整个 vue-router 核心流程中的极其重要的角色,我们不在 router.install
中详细讨论,后面会单独对 pushWithRedirect()
进行剖析。现在你只需要知道,它的执行会导致全局守卫和组件内的路由守卫都会按照既定顺序进行执行,同时浏览器地址栏中的地址也会发生变化,并且 router-view
处渲染了此时路由配置中的组件。
2.3.2 向任何深度的子组件暴露 router 和 route 两个对象
解决这个问题,本质上就是实现父子孙等任意深度的组件通信能力。这里最适合的就是 Vue 向我们提供的 provide/inject 能力。
Vue-router 也是借助 provide
和 inject
实现 router
和 route 两个对象的传递。
非常简单,没有流程图,直接上代码:
for (const key in START_LOCATION_NORMALIZED) {
reactiveRoute[key] = computed(() => currentRoute.value[key]);
}
app.provide(routerKey, router);
app.provide(routeLocationKey, reactive(reactiveRoute));
值得注意的是,每一个 reactiveRoute[key]
都相当于拿到了 currentRoute.value[key]
,并且当currentRoute.value[key]
发生变化后,通过 app.inject(routeLocationKey)
拿到的值也会发生变化并派发通知。你可以简单的理解成,这里 app.provide
就是向子组件实例提供了 currentRoute响应式路由信息。在后面讲到的 router-view 组件中,拿到的响应式路由信息也可以理解成是currentRoute。
3. pushWithRedirect() 实现 “执行一系列导航守卫” → “更新浏览器地址栏的地址”
pushWithRedirect()
方法可以说是在 vue-router 的核心实现中承担着无可替代的重要角色。它并不是 createRoute 中初始化导航阶段时专用的方法。实际上执行 router.push/repace
等会触发”导航一条龙“的过程都会调用 pushWithRedirect()
。想要弄懂 vue-router 的核心实现,pushWithRedirct()
必须弄懂。
先来看下pushWithRedirect()实现流程:

在 pushWithRedirect()
的流程中,最重要的三步就是 resolve
、navigate
、finalizeNavigation
,他们分别对应着
- resolve:根据当前当行路径或命名找到对应的
matcher
。 - navigate:收集全局和组件内的各种守卫,并串行执行这些守卫。
- finializeNavigation:调用原生方法,改变浏览器地址栏中的输入地址,同时更新
currentRoute
响应式路由信息。在首次执行时,还会将”导航一条龙“推入 popstate 的listeners
回调队列中。
接下来,我们将逐个分析这三个步骤的流程。
3.1 resolve:根据当前当行路径或命名找到对应的matcher
先来看看流程图:

resolve
接受的参数 rawLocation
就是我们在项目中代入 router.push
的参数,参数的形式多种多样,可以直接是希望跳转的路由路径字符串,也可以是通过对象的形式携带 path
或 name
属性。
resolve
的职责就是能根据用户输入的不同类型参数,从 matchers
数组或 matcherMap
中找到对应 matcher
,然后用这么 matcher
组成 matched
作为 targetLocation
的属性返回。还记得 matchers
和 matcherMap
么? 他们早在 createMatcherMap
的过程中就创建好了,忘记的小伙伴可以看看本文 2.2.1 的部分。
根据参数找到对应 matcher
的方式很简单,如果是路径字符串,或是携带了 path
属性的对象,那就直接通过正则表达式匹配的方式,从 matchers
中找到对应的 matcher
。
正则表达式匹配的这里,每一个 matcher
都有一个属性 re
,这个属性是基于当前路由配置对象的 path
和先前所有父级路由配置对象的 path
生成的正则表达式。

举个例子,比如我们有这样的routes
:
const routes = [
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
children: [{
path: 'about-detail',
component: () => import('../components/about-detail.vue')
}]
}
]
对应生成的 matchers 数组如下:
[{
children: []
parent: {re: /^\/about\/?$/i, score: Array(1), keys: Array(0), parse: ƒ, stringify: ƒ, …}
re: /^\/about\/about-detail\/?$/i
record: {path: '/about/about-detail', redirect: undefined, name: undefined, meta: {…}, aliasOf: undefined, …}
},{
children: [{…}]
parent: undefined
re: /^\/about\/?$/i
record: {path: '/about', redirect: undefined, name: 'about', meta: {…}, aliasOf: undefined, …}
}]
其中的 re
属性就是一个正则表达式。path
为 '/about/about-detail'
的子路由的 re
就是 /^\/about\/about-detail\/?$/i
。
假设此时浏览器地址栏中的路径为以下几种情况时,只有 第3项 和 第4项 是可以匹配到 path
为 '/about/about-detail'
的子路由的。
const reg = /^\/about\/about-detail\/?$/i
const path1 = '/about'
const path2 = '/about/'
const path3 = '/about/about-detail'
const path4 = '/about/about-detail/'
const path5 = '/about/about-detail//'
const path6 = '/about/about-detail/detail-list'
reg.test(path1), // false
reg.test(path2), // false
reg.test(path3), // true
reg.test(path4), // true
reg.test(path5), // false
reg.test(path6), // false
这就是 resolve
流程中,会根据浏览器地址栏中的路径找到对应 matcher
的原理。用代码表达就是下面这样:
matcher = matchers.find(m => m.re.test(path));
守卫的写法有很多,可以是同步的,亦可以是异步的,返回值可以是boolean,也可以是返回路由路径,每一个钩子都有着自己的特点,你可以通过官网关于导航守卫的介绍来回想起他们的用法和特性。
进一步拆解 navigate 的需求,可以拆分为:
- 串行执行守卫,守卫返回promise实例,也要等到promise状态确定后才能决定是否继续执行下一个钩子;
- 任何守卫都可以传递一个可选的参数next,可通过next来确定下一步导航结果。
整个 navigate 的核心过程可以如下图所示:

关于收集各方导航守卫钩子函数组成 guards
数组,以及处理执行顺序的过程,这里不多讲,你只需要知道,最终 guards
中的守卫钩子是一个接一个的按既定顺序执行的就好。
那如何实现钩子函数的一个接一个按顺序执行?vue-router是通过 promise
链的方式串联这些钩子函数的,但导航守卫并不一定会返回 promise 实例,这就需要 vue-router 在串联这些钩子函数前,先将这些钩子函数进行 promise化。
promise化的过程就是通过 guardsToPromiseFn()
实现的。
3.2.1 guardsToPromiseFn 实现守卫函数 Promise 化
guardToPromiseFn
中的首个参数 guard
, 就是当前需要promise化的守卫钩子函数,这里只是简单的将 guard
包装了一层 Promise 后返回了,重点还是上方流程图右侧的包装过程。
首先定义 next
函数,上面对 navigate
做需求分析的时候说过,任何守卫都可以传递一个可选的参数 next
,可通过 next
来确定下一步导航结果。“确定下一步导航结果”经过进一步需求拆解后,会发现需求目标非常简单,无非就是根据 next
的参数进行 resolve
或者 reject
,控制是否进入后面还未完成的 “导航一条龙” 过程:

- 如果代入
next
的参数为false
,直接调用reject
抛出错误,错误会传递到navigate
函数的catch
流程中从而不会进入finializeNavigation
的过程,从而终止了 “导航一条龙” 的过程。忘记的小伙伴可以回看下标题3下的第一张流程图; - 如果代入
next
的参数为Error
实例,同样reject
。效果同上; - 如果代入
next
的参数为 字符串 或者 对象类型,那么就视为是新的导航信息,此时应该重定向,重新开始 “导航一条龙” 流程,因此也会reject
。效果同上; - 如果代入
next
的参数为其他情况,那么就直接resolve
,继续后面finializeNavigation
,完成 “导航一条龙” 的后续过程。
至此 next
函数的指责就算是全部完成。接下来再回到 guardToPromiseFn
返回的 Promise
函数的过程中。接下来,就会执行通过 guard.call
的方式执行 guard
函数,并将 next
函数带入 guard
中。
vue-router 虽然允许我们向任何守卫函数中代入 next
,但 next
并不是一个必要参数,通常情况下都可以直接通过守卫函数 return
布尔值、路径字符串、路由信息对象的方式来代替 next。所以在实现上,守卫函数 return
的后的处理逻辑要和 next
相同,vue-router 会先判断守卫函数的形参数量是否小于 3,如果满足条件,自然是没有使用到 next
参数,此时只需要通过 Promise.resolve().then(next)
的方式将返回值带入next执行后续逻辑即可。
至此,guardToPromiseFn 实现 promise 化的过程就完成了。
3.2.2 runGuardQueue 串行执行 promise 化的守卫函数
当我们有一堆返回 promise 实例的函数,想要让他们串行执行的实现方式多种多样。这里 runGuardQueue
的 promise
串行实现方式非常简洁,是一种非常值得学习的最佳实践:
function runGuardQueue(guards) {
return guards.reduce((promise, guard) => promise.then(() => guard()), Promise.resolve());
}
其中 guards
是由 guardToPromiseFn(guard)
进行 Promise化后的守卫函数们组成的数组。vue-router 巧妙地通过 Array.prototype.reduce
的方式实现了promise化后的守卫函数串行执行。
runGuardQueue
的过程中,如果有任何守卫的 next
流程中出现 reject
,都会被 navigate()
的 catch()
捕获,阻止 navigate
后 finializeNavigation
的执行,从而终止“导航一条龙”的后续流程。
3.3 finializeNavigation:更新导航地址,触发视图更新导火索
finializeNavigation
是 vue-router 在导航处理阶段的最后一步,也是负责“导航一条龙”中第二步——“更新浏览器地址栏地址”的主要负责人。
在分析前,依旧先分析 finializeNavigation
的需求点是什么:
- 改变地址栏中地址路径;
- 更新
currentRoute
响应式路径信息; - 对 popstate 事件同样实现 “导航一条龙” 过程。
让我们来看一下 finializeNavigation
的流程是什么样子的:
3.3.1 isPush 区分“主动”和“popstate”触发场景
首先会判断 isPush
的真假,isPush
来自 finializeNavigation
执行时的第三个参数。在思考 isPush
是干什么的之前,我们可以先思考一个问题:
什么时候需要调用 window.history.push()
改变地址栏中的路由地址?什么时候不用?
先假设这样一种情况,如果用户是通过编程式调用 router.push()
,此时 router.push()
内部若没有调用 window.history.push()
,那么即使导航守卫执行,路由视图改变,但浏览器地址栏中的路由地址还没有得到改变,此时就需要调用 window.history.push()
去手动改变地址栏中的地址,这种编程式的改变路由行为我们可以理解为 “主动” 的。
相对的,所谓“被动”改变路由的行为,就是不需要调用window.history.push()
就可以实现浏览器地址栏中地址的改变。目前在浏览器环境中,只有在“浏览器后退按钮”、history.go/back()
等场景下触发的路由地址改变是自动的。这类 “被动” 改变地址事件,可以被 window 的 popstate 原生事件所监听到。既然路由改变,“导航一条龙”流程就不能少,守卫和路由视图都要随着路由改变得到执行或更新。
上面流程图中,routeHistory
就可以理解成原生 history
对象,isPush
就是用来区分当前路由改变的场景,如果 isPush
为 true
,就是通过编程式 “主动”触发的,否则就是通过 “popstate”事件被动触发的。
3.3.2 改变 currentRoute.value 触发 router-view 组件更新
是否还记得,在 createRouter()
环节,初始化了一个响应式路由变量 currentRoute
,忘记的小伙伴可以回看下文章 2.2.2 的部分。
组件是承载路由视图的组件,组件的原理我们文章后面会讲,这里暂不做深入探究,你只需要知道组件通过 inject
的方式导入并依赖了 currentRouter
这个响应式变量就好,让 currentRoute
发生改变,的 render effect 函数就会得到更新,从而更新视图。
在这里,finializeNavigation
就是将 currentRoute.value
的值更新成即将跳转后的路由信息,即更新成了在标题3.1部分中讲到的 resolve
的返回值 targetLocation
,从而触发了上述 router-view 的视图更新过程。
3.3.3 markAsReady() 将 “导航一条龙” 推入 listeners
在2.1.2中讲到过,想要在popstate事件触发时执行类似 router.push()
的后续“导航一条龙”流程,无非就是将“导航一条龙“作为回调,通过 listen
方法收集进 listeners
数组即可。
将 “导航一条龙” 推入 listeners
的过程只需要进行一次,可以通过 ready
标志位来区分是否是首次执行markAsReady()
。
接下来就会通过 setupListeners()
方法,调用 routeHistory.listen()
将”导航一条龙”收纳进 listeners
数组中。这里 routeHistory.listen
方法其实就是 useHistoryListeners()
中的 listen
方法。在这里值得注意的是,此时代入 finalizeNavigation
中的第三个参数 isPush
的值是 false
,表示此时的 “导航一条龙” 是由 popstate 事件触发的“被动”触发场景。
至此实现用户点击浏览器后退按钮,那么视图也会得到相应的更新。
4. 组件的视图更新
目前我们已经讲完了 vue-router 绝大部分核心原理的实现,现在回看一下文章在最初部分谈到的 vue-router 中心法则,我们已经讲到了这4步的前3.5步骤。为什么是3.5步?因为最后一步 “router-view
处的组件视图更新”这一步我们只讲了一半。前面文章中提到过,router-view
的更新时因为依赖了响应式路由信息 currentRoute
,currentRoute
更新就会派发通知,使得 router-view
的 render effect 函数执行,进而重新渲染 router-view
的组件模板,这个效果就是所谓的“路由改变,视图更新”。
我们还是老规矩,先拆解一下 router-view 函数的核心需求:
- router-view要能感知到自己的嵌套层级;
- 拿到响应式路由信息currentRoute,并能根据嵌套层级拿到需要对应渲染的组件;
- router-view支持通过默认的作用域插槽方式处理需要渲染的组件,也可不通过插槽,直接渲染组件。
4.1 setup流程
在vue3中,所有组件的初始化流程都是从组件的 setup
中开始的,我们先看看下 router-view
的 setup
核心流程实现是怎么样的:
上图中,左侧部分是通过代码角度对 setup
流程的描述,右侧部分则是翻译成人话的版本解释。
首先,通过 inject
的方式拿到响应式路由信息 currentRoute
和 depth
,currentRoute
通过 provide
下发过程是在 router.install()
阶段发生,忘记的小伙伴可以回看下标题2.3.1的部分,而 depth
则是通过上级 router-view
组件的 setup
传递下来的,它表示当前 router-view
的嵌套层级。
还记得响应式路由信息 currentRoute 有一个 matched
属性么?它是一个数组,其中成员是当前路由路径下,不同路由层级所对应的路由配置信息 record
,是在 pushWithRedirect
阶段整理出来的,忘记的小伙伴可以回看下 标题3.1 部分的上下文。每一个 record
可以理解为代入 createRouter(options)
中的 options.routes
中的单个 route
对象,并且这些 record
在 matched
中的排列顺序就是根据路由嵌套层级从小到大排列的.
这里举个例子,如果我们有以下的路由配置信息:
const routes = [
{
path: '/about',
name: 'about',
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue'),
children: [{
path: 'about-detail',
component: () => import('../components/about-detail.vue')
}]
}
]
那么通过 router.push('/about/about-detail')
跳转时 currentRoute.matched
就是这样的:
[
{
"path": "/about",
"name": "about",
"children": [
{
"path": "about-detail",
"component":() => {…}
}
],
"components": {default: () => {…}}
},
{
"path": "/about/about-detail",
"children": [],
"components": {default: () => {…}}
}
]
假如此时的 router-view
的嵌套层级是1(表示第2层,depth是从0开始的),那么这里的 matchedRouteRef
便是 computed(() => currentRoute.matched[1])
,matchedRouteRef.value
即:
{
"path": "/about/about-detail",
"children": [],
"components": {default: () => {…}}
}
拿到 matchedRouteRef
后,这个 router-view
就拿到当前路由的一切信息,比如在渲染时,就可以通过matchedRouteRef.value.components
找到自己所需要渲染的组件。接下来 setup
就会将 depth+1
,并通过 provide 的方式传递给子组件,以告知子组件的嵌套层级。
在 setup
逻辑的最后,返回了 router-view
的 render函数。组件渲染的关键还是在这个 render函数里,接下来我们在来分析下render函数的执行流程是怎样的。
4.2 render函数
render函数的实现意味着 “导航一条龙” 的终点实现。在前文对router-view组件的需求分析中说过,router-view需要支持通过默认的作用域插槽方式处理需要渲染的组件,也可不通过插槽,直接渲染组件。来看下render函数的流程实现:
render
函数的实现非常简单,本质上就是将 setup
中拿到的 matchedRoute
取值,得到当前需要渲染的组件。router-view
会将用户写在 router-view
标签上的属性透传给内部需要渲染的组件,然后判断 router-view
的默认插槽中是否有内容,如果有内容,则向插槽中传递需要渲染的组件,如果默认插槽中没有内容,则直接返回需要渲染的组件的渲染函数,相当于用需要渲染的组件替换了 router-view
组件。
值得注意的是,这里 router-view
的 render
函数访问了 matchedRouteRef
,在 setup
函数中提到过,他是一个依赖了 currentRoute
的计算属性。这会使得 router-view
的 render effect 函数会被作为依赖被 matchedRouteRef
收集。如果在未来某个时刻, currentRoute
通发生改变,就会触发 matchedRouteRef
的重新计算,而 matchedRouteRef
的改变,则会触发 router-view
组件的render effect的执行,从而 render
函数会被重新执行,router-view
处的组件视图得到更新。
此时,如果通过 router.push()
等触发路由路径的改变,就会在 finializeNavigation
阶段改变currentaRoute(详见标题3.3),过程将如上所述,触发视图更新,“导航一条龙”过程圆满结束。
5. 最终总结
我们通过接近2万字的文章,结合流程图的方式剖析了vue-router4的核心实现原理。现在回过头来看,核心就是对下面中心法则中“导航更新一条龙”的实现。
为了方便大家理解,下面是我总结的全流程地图,相信它可以帮大家将上文全部内容串联起来。
鼠标点击图片可以放大哦~
转载自:https://juejin.cn/post/7234733569792098364