一行源码没有,但讲Vue-router4核心实现!

1,570 阅读26分钟

前言

文章小小的有点标题党,毕竟是讲原理的文章,想要讲明白这个事,总会有那么一行两行源码的,但本文会竭尽全力的不贴源码。

全文字数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')

源码调试方法:

  1. 在 node_modules/vue-router/dist/vue-router.mjs 中打 debugger。
  2. 本地运行项目,开始调试。

1. 想想vue-router的核心需求

vue-router的核心需求从宏观来看,无非就是下图阐述的流程,可以将下面流程视为 vue-router 的中心法则:

截图.png

图中红色区域 “执行一系列导航守卫” → “更新浏览器地址栏的地址” -> “router-view处的组件视图更新” 这三步会被文内多处用到,他们就是vue-router一直围绕的主线流程,为了方便理解,我们称之为 “导航更新一条龙”

结合中心法则,开始宏观构思vue-router的实现方式,就可以理解成要实现以下4条需求:

  1. 实现更新路由Api,浏览器改变路由的其他方式应做到和更新路由Api同效。
  2. 注册的导航守卫,要能按照既定顺序链式执行,守卫可以终止或改变导航更新结果;
  3. 浏览器地址栏中的路由得到更新,视图更新,但不重刷页面;
  4. 不同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() 到底做了什么?

2.png

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同效”。

浏览器改变路由的其他方式有哪些?

  1. 浏览器后退前进按钮、
  2. history.go()
  3. ...

想要监听这类事件很容易:window.addEventListener('popstate', popStateHandler)

重点在于 popStateHandler 的实现,看看它是如何处理:

3.png

在上面流程图中,黄色背景的部分是useHistoryListeners的作用域空间,在useHistoryListeners中维护了一个listeners数组,这个数组主要用于储存popstate触发时的回调。可以通过listen方法将需要执行的回调推入listeners数组。其中listen方法会被useHistoryListeners返回以便在外部作用域调用。

popStateHandler只是将listeners按照出队顺序遍历执行。最后通过window.addEventListener('popstate', popStateHandler)的形式监听浏览器后退等行为的触发,当浏览器后退发生时,listeners中收集的回调就会依照出队的顺序遍历执行。

想要在popstate事件触发时执行类似router.push()的后续“导航一条龙”流程,无非就是将“导航一条龙“作为回调,通过listen的方式收集进listeners数组即可。

这一个过程的具体实现我们先留下疑问,后面在router.install()方法中就会提及。

假如现在浏览器后退按钮被点击时,listeners中收集的回调就会依照出队的顺序遍历执行。

现在createWebHistory的核心流程我们已经分析完了,总结一下:

  1. createWebHistory() 的返回值主要由useHistoryStateNavigation()useHistoryListeners() 两个方法的返回值融合而成;最终赋值给createRouter(options)的参数options.history属性。后面 options.history 会成为贯彻 vue-router 始终的 routerHistory 对象,你可以理解成,以后出现的 routerHistory 就是指 options.history
  2. useHistoryStateNavigation() 返回经过封装过的原生history.push/replace() 等方法,只要执行 routerHistory.push() 或 routerHistory.replace()本质相当于调用了history.push/replace()浏览器原生方法。
  3. useHistoryListeners():维护popstate事件触发的回调函数数组,并对外暴露了listen方法,未来只需要调用 routerHistory.listen(fn),就会将fn收集为回调,在 popstate 事件触发时就会执行 fn。

2.2 createRouter(options) 创建路由实例

createWebHistory()拿到options.history后,options就可以带入createRouter中执行router实例的创建流程。

在router实例创建前,还需要经过下面两个核心步骤:

  1. routes拍平,同时创建命名路由映射表
  2. 初始化 currentRoute 响应式路由信息
4.png

2.2.1 将routes拍平,同时创建命名路由映射表

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

5.png 6.png

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

addRoute中,route将改称为record,首先的第一步就是进入 createRouteRecordMatcher 方法。createRouteRecordMatcher 方法的作用就是将 record 转换为 matcher 对象:

7.png

所谓 matcher 对象,就是对 record 对象的包装对象,增加了一些其他的信息属性。但将 record 转换成 matcher 对象有什么用呢?

别忘了 record 的其实就是带入 createRouteroptions.routes 的成员 routerecord 是树形结构的,createRouterMatcher 的目标就是将 options.routes 的拍平,如果直接拍平成一维数组,层层嵌套的父子关系信息就随着树形结构的摊平不复存在,这不方便后续逻辑中找到某个 record 的父子关系信息。所以为了能保留 record 父子关系信息,就需要将 record 在向上包装一层对象,形成 matcher。在 matcher 中最重要的4个属性就是 recordparentchildrenre

  • recordrecord 自身,当前路由信息对象,相当于 routes[i]
  • parentrecordparent record,储存着当前路由之上的所有父路由信息;
  • childrenrecordchildren,储存着当前路由下的所有子路由信息;
  • re:由 record.path 解析出的正则表达式,凡是能被这个路径匹配到的url,说明当前url的路由信息就是此 record

只要能在后续拍平过程中,维护好每一个 matcher 的这三个属性,父子关系信息就能在拍平后得以保存了。

record 改造成 matcher 对象后,就会判断当前 record 是否有子路由配置对象,如果有,就将当前 record 作为 parent 代入递归执行 addRoute。如果没有,那就通过 insertMatcher 方法将当前 matcher 存储到局部变量 matchersmatcherMap 中,这两个变量虽然是局部变量,但却能在 createrRouterMatcher 外部通过闭包的方式访问到。

matcher 最终会被存储 matchersmatcherMap 哪个变量中,这还需要详细看看 insertMatcher 的实现:

8.png

首先 matcher 被无条件推入 matchers 数组,如果当前 record 还具备 name 属性,说明该路由还支持通过命名路由的方式进行跳转,将 name 作为键名,record 自身作为 value,加入 matcherMap 中,方便后面可以直接根据 name 快速找到对应的路由配置对象。

至此,我们可以总结一下:

  1. options.routes 已经被拍平成由 matcher 组成的 matchers 数组,并且可以从 matcherchildrenparent 属性中寻得它的父子路由配置信息;
  2. 命名路由可以根据 route.name 直接从 matcherMap 中直接找到对应的 matcher

但这里遗留了一个问题:

  • 为什么 vue-router 要将 routes 拍平?

关于这个问题,我们先不解答,因为在下面的流程中,这个问题很自然就会得到解释。

接下来我们回到 createRouter 的总体执行流程中,看看下一步 "初始化 currentRoute 响应式路由信息" 都做了些什么?

2.2.2 初始化 currentRoute 响应式路由信息

9.png

这一步非常简单纯粹,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 等种种工作都需要依赖 routerroute 两个变量。

因此,在 app.use(router) 中,必须实现app根实例下任何深度的子组件都能通过 $router$route 分别访问到 routerroute 两个对象,如果是在 setup 作用域下,也可以通过 useRouter()useRoute() 来访问到 routerroute。要想办法将 routerroute 通过某些方式传递给app根实例下的子组件们。

总结来看,下面两点就是在 router.install() 执行阶段,必须解决的两个需求:

  1. 根据初始化路径进行首次渲染;
  2. 向任何深度的子组件暴露 router 和 route 两个对象;

接下来,我们就心怀这两个需求点,来看看 router.install 都做了什么核心逻辑:

10.png

2.3.1 根据初始化路径进行首次渲染

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

11.png

这个 pushWithRedirct() 方法承担的就是”导航更新一条龙“ 的执行过程,是整个 vue-router 核心流程中的极其重要的角色,我们不在 router.install 中详细讨论,后面会单独对 pushWithRedirect() 进行剖析。现在你只需要知道,它的执行会导致全局守卫和组件内的路由守卫都会按照既定顺序进行执行,同时浏览器地址栏中的地址也会发生变化,并且 router-view 处渲染了此时路由配置中的组件。

2.3.2 向任何深度的子组件暴露 router 和 route 两个对象

解决这个问题,本质上就是实现父子孙等任意深度的组件通信能力。这里最适合的就是 Vue 向我们提供的 provide/inject 能力

Vue-router 也是借助 provideinject 实现 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()实现流程:

12.png

pushWithRedirect() 的流程中,最重要的三步就是 resolvenavigatefinalizeNavigation,他们分别对应着

  • resolve:根据当前当行路径或命名找到对应的 matcher
  • navigate:收集全局和组件内的各种守卫,并串行执行这些守卫。
  • finializeNavigation:调用原生方法,改变浏览器地址栏中的输入地址,同时更新 currentRoute 响应式路由信息。在首次执行时,还会将”导航一条龙“推入 popstate 的 listeners 回调队列中。

接下来,我们将逐个分析这三个步骤的流程。

3.1 resolve:根据当前当行路径或命名找到对应的matcher

先来看看流程图:

13.png

resolve 接受的参数 rawLocation 就是我们在项目中代入 router.push 的参数,参数的形式多种多样,可以直接是希望跳转的路由路径字符串,也可以是通过对象的形式携带 pathname 属性。

resolve 的职责就是能根据用户输入的不同类型参数,从 matchers 数组或 matcherMap 中找到对应 matcher,然后用这么 matcher 组成 matched 作为 targetLocation 的属性返回。还记得 matchersmatcherMap 么? 他们早在 createMatcherMap 的过程中就创建好了,忘记的小伙伴可以看看本文 2.2.1 的部分。

根据参数找到对应 matcher 的方式很简单,如果是路径字符串,或是携带了 path 属性的对象,那就直接通过正则表达式匹配的方式,从 matchers 中找到对应的 matcher

正则表达式匹配的这里,每一个 matcher 都有一个属性 re,这个属性是基于当前路由配置对象的 path 和先前所有父级路由配置对象的 path 生成的正则表达式。

14.png

举个例子,比如我们有这样的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 的需求,可以拆分为:

  1. 串行执行守卫,守卫返回promise实例,也要等到promise状态确定后才能决定是否继续执行下一个钩子;
  2. 任何守卫都可以传递一个可选的参数next,可通过next来确定下一步导航结果。

整个 navigate 的核心过程可以如下图所示:

15.png

关于收集各方导航守卫钩子函数组成 guards 数组,以及处理执行顺序的过程,这里不多讲,你只需要知道,最终 guards 中的守卫钩子是一个接一个的按既定顺序执行的就好。

那如何实现钩子函数的一个接一个按顺序执行?vue-router是通过 promise 链的方式串联这些钩子函数的,但导航守卫并不一定会返回 promise 实例,这就需要 vue-router 在串联这些钩子函数前,先将这些钩子函数进行 promise化。

promise化的过程就是通过 guardsToPromiseFn() 实现的。

3.2.1 guardsToPromiseFn 实现守卫函数 Promise 化

16.png

guardToPromiseFn 中的首个参数 guard, 就是当前需要promise化的守卫钩子函数,这里只是简单的将 guard 包装了一层 Promise 后返回了,重点还是上方流程图右侧的包装过程。

首先定义 next 函数,上面对 navigate 做需求分析的时候说过,任何守卫都可以传递一个可选的参数 next,可通过 next 来确定下一步导航结果。“确定下一步导航结果”经过进一步需求拆解后,会发现需求目标非常简单,无非就是根据 next 的参数进行 resolve 或者 reject,控制是否进入后面还未完成的 “导航一条龙” 过程:

17.png
  • 如果代入 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 实例的函数,想要让他们串行执行的实现方式多种多样。这里 runGuardQueuepromise 串行实现方式非常简洁,是一种非常值得学习的最佳实践:

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() 捕获,阻止 navigatefinializeNavigation 的执行,从而终止“导航一条龙”的后续流程。

3.3 finializeNavigation:更新导航地址,触发视图更新导火索

finializeNavigation 是 vue-router 在导航处理阶段的最后一步,也是负责“导航一条龙”中第二步——“更新浏览器地址栏地址”的主要负责人。

在分析前,依旧先分析 finializeNavigation 的需求点是什么:

  • 改变地址栏中地址路径;
  • 更新 currentRoute 响应式路径信息;
  • 对 popstate 事件同样实现 “导航一条龙” 过程。

让我们来看一下 finializeNavigation 的流程是什么样子的:

18.png

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 就是用来区分当前路由改变的场景,如果 isPushtrue,就是通过编程式 “主动”触发的,否则就是通过 “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 数组即可。

19.png

将 “导航一条龙” 推入 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 的更新时因为依赖了响应式路由信息 currentRoutecurrentRoute 更新就会派发通知,使得 router-view 的 render effect 函数执行,进而重新渲染 router-view 的组件模板,这个效果就是所谓的“路由改变,视图更新”。

20.png

我们还是老规矩,先拆解一下 router-view 函数的核心需求:

  1. router-view要能感知到自己的嵌套层级;
  2. 拿到响应式路由信息currentRoute,并能根据嵌套层级拿到需要对应渲染的组件;
  3. router-view支持通过默认的作用域插槽方式处理需要渲染的组件,也可不通过插槽,直接渲染组件。

4.1 setup流程

在vue3中,所有组件的初始化流程都是从组件的 setup 中开始的,我们先看看下 router-viewsetup 核心流程实现是怎么样的:

21.png

上图中,左侧部分是通过代码角度对 setup 流程的描述,右侧部分则是翻译成人话的版本解释。

首先,通过 inject 的方式拿到响应式路由信息 currentRoutedepthcurrentRoute通过 provide 下发过程是在 router.install() 阶段发生,忘记的小伙伴可以回看下标题2.3.1的部分,而 depth 则是通过上级 router-view 组件的 setup 传递下来的,它表示当前 router-view 的嵌套层级。

还记得响应式路由信息 currentRoute 有一个 matched 属性么?它是一个数组,其中成员是当前路由路径下,不同路由层级所对应的路由配置信息 record,是在 pushWithRedirect 阶段整理出来的,忘记的小伙伴可以回看下 标题3.1 部分的上下文。每一个 record 可以理解为代入 createRouter(options) 中的 options.routes 中的单个 route 对象,并且这些 recordmatched 中的排列顺序就是根据路由嵌套层级从小到大排列的.

这里举个例子,如果我们有以下的路由配置信息:

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函数的流程实现:

22.png

render 函数的实现非常简单,本质上就是将 setup 中拿到的 matchedRoute 取值,得到当前需要渲染的组件。router-view 会将用户写在 router-view 标签上的属性透传给内部需要渲染的组件,然后判断 router-view 的默认插槽中是否有内容,如果有内容,则向插槽中传递需要渲染的组件,如果默认插槽中没有内容,则直接返回需要渲染的组件的渲染函数,相当于用需要渲染的组件替换了 router-view 组件。

值得注意的是,这里 router-viewrender 函数访问了 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的核心实现原理。现在回过头来看,核心就是对下面中心法则中“导航更新一条龙”的实现。

23.png

为了方便大家理解,下面是我总结的全流程地图,相信它可以帮大家将上文全部内容串联起来。

鼠标点击图片可以放大哦~

24.png

25.png

26.png