vue router 4 源码篇:路由诞生——createRouter原理探索

简介: vue-router是vue官方指定的路由管理库,拥有21.2k star和 2039876的周下载量,实属难得的优秀开源库。那么vue-router是如何完美结合vue?我们一起揭秘它的神秘面纱!

本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐


源码专栏

感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:

开场

哈喽大咖好,我是跑手,本次给大家带来vue-router@4.x源码解读的一些干货。

众所周知,vue-routervue官方指定的路由管理库,拥有21.2k github star(18.9k for Vue 2 + 2.3k for Vue 3)和 2,039,876 的周下载量,实属难得的优秀开源库。

对很多开发者来讲,了解vue-router还是很有必要的,像React RouterVue Router这系列单页应用底层都是借助 H5 History API能力来实现的。

那么,Vue Router又是如何借用H5 History,完美与Vue结合在一起,并处理当中千丝万缕的联系的呢?在《Vue Router 4 源码探索系列》专栏中,我们一起揭秘它的神秘面纱。

那么今天,我们先来聊下大家在使用vue-router时候第一个用到的方法——createRoutercreateRouter作为vue-router最重要的方法之一,里面集合了路由初始化整个流程,核心路由方法的定义等职责。

在这篇文章里,你能获得以下增益:

  1. 了解vue-router的包管理模式 —— pnpm下对Monorepo的管理;
  2. 了解在vue3框架下,createRouter创建路由整个过程,以及它周边函数的功能职责;
  3. 了解router对象中getRoutespush等12个核心方法的实现原理;

关于vue-router@4.x

对于vue-router的版本3.x4.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

返回路由地址标准化版本。还包括一个包含任何现有 basehref 属性。这部分源码比较清晰不在这赘述了,主要包含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(由 createWebHashHistorycreateWebHistorycreateMemoryHistory 生成的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的前世今生

最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹

相关文章
|
3天前
|
JavaScript
vue页面加载时同时请求两个接口
vue页面加载时同时请求两个接口
|
3天前
|
JavaScript
vue里样式不起作用的方法,可以通过deep穿透的方式
vue里样式不起作用的方法,可以通过deep穿透的方式
12 0
|
3天前
|
移动开发 JavaScript 应用服务中间件
vue打包部署问题
Vue项目`vue.config.js`中,`publicPath`设定为&quot;/h5/party/pc/&quot;,在线环境基于打包后的`dist`目录,而非Linux的`/root`。Nginx代理配置位于`/usr/local/nginx/nginx-1.13.7/conf`,包含两个相关配置图。
vue打包部署问题
|
3天前
|
JavaScript 前端开发
iconfont 图标在vue里的使用
iconfont 图标在vue里的使用
15 0
|
2天前
|
JavaScript
vue打印v-model 的值
vue打印v-model 的值
|
3天前
|
JavaScript
Vue实战-组件通信
Vue实战-组件通信
5 0
|
3天前
|
JavaScript
Vue实战-将通用组件注册为全局组件
Vue实战-将通用组件注册为全局组件
5 0
|
3天前
|
JavaScript 前端开发
vue的论坛管理模块-文章评论02
vue的论坛管理模块-文章评论02
|
3天前
|
JavaScript Java
vue的论坛管理模块-文章查看-01
vue的论坛管理模块-文章查看-01
|
3天前
|
资源调度 JavaScript 前端开发
vue 项目运行过程中出现错误的问题解决
vue 项目运行过程中出现错误的问题解决