Vue Router 是如何工作的?

相信所有的 Vue 开发者都用过 Vue Router 管理路由,今天我们就来聊聊 Vue Router 是如何工作的。

碍于篇幅原因,本文只解释路由的基础功能,展示部分关键的代码。

本文的讲解顺序依照我们习惯的使用方式,主要有以下内容:

  1. 选择历史记录模式——createWebHistory 与 createWebHashHistory
  2. 创建路由器——routes 与 createRouter
  3. 注册路由插件——app.use 与 router.install
  4. 全局路由组件——RouterLink 与 RouterView
  5. 函数式导航——push 与 replace
  6. 执行导航守卫

本文介绍的版本是 vue-router@4,参照的也是该版本的源码

历史记录模式

我们使用 Vue Router,首先肯定要选择我们的历史记录模式,有三种模式供我们选择:

  • Hash 模式:我们自己开发项目时最常用的模式,使用路径中的 hash 值来控制路由。缺点在于 URL 中有个 #,如 https://example.com/#/home,这样看起来不美观,而且对 SEO 不友好。
  • HTML5 模式:这一模式解决了 Hash 模式存在的问题,URL 会看起来很 “正常”,例如 https://example.com/home,缺点在于后端服务器要做额外的配置。
  • 内存模式:这个模式的主要目的是处理 SSR,我们基本用不到,本文并不讲解,详情前往官网查看。

服务器配置的原因

我们这里只介绍为什么需要服务器配置,关于具体的配置官方已经给出了很多示例供读者参考

我们要知道,URL 中,在 # 后面的部分属于该路径的 hash 值,而路径的 hash 值是不会发送给后端的

也就是说在 hash 模式下,不管你的页面路径是 https://example.com/#/https://example.com/#/home 还是 https://example.com/#/login,打开/刷新页面时往后端发送的请求都是 https://example.com/,后端只需要处理这一种请求就好了。

而在 HTML5 模式下, https://example.com/https://example.com/homehttps://example.com/login 页面往后端发送的请求路径都是不同的,后端要对所有可能的请求路径作出处理,一般都是全部回退到根路径。

使用 Vue Router 的方式(如 RouterLink、router.push)跳转路由是不会向后端发送请求的,只有在首次进入,或刷新页面时,才会去请求该路径的资源

两种模式的区别

创建两种历史记录模式对应的 api 分别是 createWebHashHistorycreateWebHistory

在源码中,历史记录的逻辑都是在 createWebHistory 中实现的,createWebHashHistory 只是为基础路径前面增加了一个 #,就去调用 createWebHistory 了。

基础路径默认是 location.pathname + location.search,所以 HTML5 模式的默认基础路径是空字符串 "",而 hash 模式是 "#"。用户也可以通过函数传参/定义 <base> 标签的形式改变基础路径。

两种模式在 Vue Router 的实现上,其实只有基础路径的区别

历史记录对象

我们知道了,两种模式都会去调用 createWebHistory, 该函数会创建出一个历史记录对象并返回,然后作为参数传给 createRouter

createWebHistory 内部,一共做了三件事

  1. 创建历史导航对象。在这一步,Vue Router 从 location 属性中提取了当前网页的路径信息,存入闭包中并实时维护,并将状态信息存入 history.state 中;封装了 historypushStatereplaceState 方法(通过这两个方法改变路径并不会向后端发送请求),将这些属性与方法添加到历史记录对象身上。
  2. 创建历史记录监听器,在这一步创建了历史记录监听器,监听了 window 的 popstate 事件,在用户手动改变页面路径时,维护历史导航对象,并执行监听器列表中的回调函数。
  3. 创建历史记录对象,其实就是将历史导航对象和历史记录监听器上的属性和方法集中返回

其实 Vue Router 还做了关于页面/路由销毁时的行为,比如在 beforeunload 事件中清除监听器等。本文主要以功能实现的原理为主,关于删除/销毁的功能适当省略。

总结

这一部分完成时,我们得到了一个历史记录对象,该对象包含当前路径下的所有信息,以及一些状态信息,包含前一路由的历史导航对象(forward)、页面滚动位置(position)等

并且通过给 window 注册事件,即使用户手动修改页面路径,Vue Router 也能成功跟踪。

可以参照下图:

Vue Router 是如何工作的?

创建路由器

接下来,我们定义我们的路由列表(routes),包含我们的所有路由,每一项都具有路径(path)与组件(component)属性,部分也具有名称(name)、子路由(children)、重定向(redirect)别名(alias)、路由守卫等属性

然后调用 createRouter 函数,传入路由列表、历史记录对象等配置项,最终获取一个路由器

在这个 createRouter 中,一共进行了以下几步:

  1. 整理路由列表,初始化一个路径匹配器。由于我们通过 push 方法,to 属性传递的路由原始位置(RouteLocationRaw),可以是很多种类型,所有需要一个路径匹配器正确匹配要导向的路由。这部分的算法较复杂,读者只需要知道之后能够根据路由原始位置从一个 Map 中获取对应的路由对象就好了。
  2. 提取历史记录对象,在后续的一些路径解析和路由导航中都会用到它。
  3. 初始化全局守卫列表,在后续路由导航时依次执行。
  4. 创建一个当前路由的响应式对象(currentRoute),将在后续被 RouterView 所依赖,路由导航时会修改此对象,进而触发 RouterView 的更新
  5. 在路由器上定义了一大堆方法(详见下图),并返回

函数主要流程参考下图,路由器身上的方法将在后文讲解

Vue Router 是如何工作的?

注册路由插件

我们创建了路由器之后,会将其作为插件传入 Vue App 的 use 方法中

const app = createApp({})
app.use(router)

这样会调用路由器的 install 方法,这个方法会接受 vue 的示例作为参数,具体实现了以下功能:

  1. 注册全局组件 RouterLink 与 RouterView
  2. 注册全局属性 $router$route
  3. 根据当前路径设置 currentRoute 的内容,进行第一次路由跳转
  4. 定义响应式路由对象
  5. 向所有子组件注入路由器响应式路由对象当前路由对象(下方代码)
// routerKey,routeLocationKey,routerViewLocationKey 是 symbol 变量
app.provide(routerKey, router)
app.provide(routeLocationKey, reactiveRoute)
app.provide(routerViewLocationKey, currentRoute)

我们常用的 useRouteruseRoute,就是通过依赖注入的方式获取路由器响应式路由对象

function useRouter() {
  return vue.inject(routerKey)
}
function useRoute() {
  return vue.inject(routeLocationKey)
}

这里解释一下当前路由对象响应式路由对象的区别,二者返回的其实是一份数据。前者是一个 shallowRef,负责控制路由导航,一般由 Vue Router 内部使用;后者则是一个 reactive 通过 computed 将其所有属性设置成只读的,公开给用户

可以看下方代码

// 初始路由对象
const START_LOCATION_NORMALIZED = {
  path: '/',
  name: undefined,
  params: {},
  query: {},
  hash: '',
  fullPath: '/',
  matched: [],
  meta: {},
  redirectedFrom: undefined,
}

// 当前路由对象
const currentRoute = shallowRef(START_LOCATION_NORMALIZED)

// 响应式路由对象
const reactiveRoute = reactive({})
for (const key in START_LOCATION_NORMALIZED) {
  reactiveRoute[key] = computed(() => currentRoute.value[key])
}

全局路由组件

在本节我们介绍 Vue Router 注册的两个全局组件

在这两个组件中,通过依赖注入的方式获取了路由器当前路由对象

const router = vue.inject(routerKey)
const injectedRoute = vue.inject(routerViewLocationKey)

RouterLink

RouterLink 包含很多 props,其中 to 是必传的,表示到跳转的路径

还有一些其他参数:

  • replace:指定路由导航时调用 replace 方法
  • active-class:设置当前路由激活时的类名
  • custom:创建自定义 RouterLink
  • ……

这个组件会默认使用一个 <a> 标签包裹用户传入的插槽,然后其身上添加了一个点击事件,触发时会去调用路由器上的 push 获取 replace 方法,实现路由跳转

RouterView

RouterView 可以说是 Vue Router 的核心,但其逻辑其实蛮简单的

在这个组件的渲染函数中,会去监听当前路由对象,根据当前路径从路由器的路径匹配器中获取对应的模板,然后将其作为模板渲染。

由于当前路由对象是一个响应式对象,使用 computed 就可以轻松监听,每次路由器进行路由导航时都会修改其内容,然后触发 RouterView 的依赖更新

函数式导航

在本节,我们介绍之前反复提到的,路由器的导航方法,以 push 方法举例,该方法接受一个 to 参数,然后进行以下步骤:

  1. 根据要前往的原始路由位置,使用路由器路径匹配器中获取对应的路由对象
  2. 检查该路由是否配置了重定向,如果配置了,则使用重定向的位置重复第一步
  3. 获取该路由的全部导航守卫,以及全局注册的导航前守卫,依次执行(执行顺序见下一节)
  4. 修改当前路由对象的值,触发 RouterView 模板更新
  5. 如果这是第一次导航,则会为历史记录对象的监听器列表中添加一个监听器,使当前路由对象在用户在手动修改路径时也会更新

Vue Router 是如何工作的?

导航守卫

执行流程

这里展示官网完整的导航解析流程:

  1. 导航被触发。
  2. 在失活的组件里调用 beforeRouteLeave 守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫。
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve 守卫。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。

执行守卫

我们创建导航守卫都是传递一个函数,而在 Vue Router 内部,会将我们的守卫函数使用 Promise 封装,然后链式调用

每执行完一个导航守卫函数,会根据函数的参数个数和返回值判断是否继续执行

  • 如果守卫函数接受 3 个参数,会检测传入的第三个 next 函数是否被严格调用一次,并根据调用的结果决定下一步
  • 如果守卫函数接受 2 个参数,则会调用 next 函数,并将守卫函数的返回值传入

next 的可能参数及作用:

  • Promise:执行该 Promise
  • false: 取消导航(需要严格等于===)
  • RouteLocationRaw:重定向到一个不同的位置
  • (vm: ComponentPublicInstance) => any:导航完成后执行的回调,接收路由组件实例作为参数(仅适用于 beforeRouteEnter)。
  • 其余: 正确导航

总结

本文讲解了 Vue Router 的主要功能与内部的一些逻辑,最后用一张图来帮助读者理解 Vue Router 是如何将浏览器路径、路由导航、RouterView 连起来的。

如果喜欢或有所帮助的话,希望能点赞关注,鼓励一下作者。

如果文章有不正确或存疑的地方,欢迎评论指出。

Vue Router 是如何工作的?