前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >vue router 4 源码篇:路由诞生——createRouter原理探索

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

作者头像
南山种子外卖跑手
发布2022-10-05 18:42:27
2.2K0
发布2022-10-05 18:42:27
举报
文章被收录于专栏:南山种子外卖跑手的专栏

theme: nico

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

开场

哈喽大咖好,我是跑手,本次给大家带来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文件夹,分多个项目管理。 大概结构如下:

项目结构

代码语言:javascript
复制
.
├── .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引入例子:

代码语言:javascript
复制
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定义:

代码语言:javascript
复制
createRouter(options: RouterOptions): Router { /**/ }

RouterOptions 就是我们创建路由传进去的配置项,可以参考官网介绍

返回项Router则是创建出来的全局路由对象,包含了路由实例和常用的内置方法。类型定义如下:

代码语言:javascript
复制
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 &amp; { 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列表:

代码语言:javascript
复制
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:在任何导航之后执行。

初始化源码如下:

代码语言:javascript
复制
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方法,利用回调函数实现守卫逻辑保存、执行以及重置。源码部分:

代码语言:javascript
复制
/**
 * 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相关

代码语言:javascript
复制
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

返回路由地址标准化版本。还包括一个包含任何现有 base 的 href 属性。这部分源码比较清晰不在这赘述了,主要包含path信息的组装返回。

push

push方法应该是路由跳转用的最多的功能了,它的原理基于h5的,实现前端url重写而不与服务器交互,达到单页应用改变组件显示的目的。使用场景:

代码语言:javascript
复制
// 浏览器带参数跳转有三种写法

router.push('/user?name=johnny')
router.push({path: '/user', query: {name: 'johnny'}})
router.push({name: 'user', query: {name: 'johnny'}})

push调用了pushWithRedirect源码),我们开始源码拆解分析:

代码语言:javascript
复制
// 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方法。

代码语言:javascript
复制
// 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 &amp;&amp; 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 方法大家有兴趣可以看看。

代码语言:javascript
复制
return (failure ? Promise.resolve(failure) : navigate(toLocation, from))

pushWithRedirect最后会返回一个Promise,在没有错误时会执行navigate方法。

关于navigate的逻辑,大致如下:

代码语言:javascript
复制
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 &amp;&amp; !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跳转过程,这里处理包含:

代码语言:javascript
复制
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

源码:

代码语言:javascript
复制
function replace(to: RouteLocationRaw) {
  return push(assign(locationAsObject(to), { replace: true }))
}

replace操作其实就是调用push,只是加了个{ replace: true }参数,这个参数的作用体现在上面讲到的finalizeNavigation方法里面对url的处理逻辑,相关源码如下:

代码语言:javascript
复制
  // 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 &amp;&amp; state &amp;&amp; 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打通,会新开一篇文章讲述。

导航守卫相关

代码语言:javascript
复制
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的前世今生

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-09-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 开场
  • 关于vue-router@4.x
  • pnpm的包管理模式
  • 项目结构
  • createRouter
    • 使用场景🌰
      • 函数定义
        • 实现流程图
          • createRouterMatcher
            • 最终输出
            • createRouterMatcher处理流程
            • addRoute处理流程
          • 导航守卫相关处理
            • 内置方法
              • matcher相关
              • path相关
              • 导航守卫相关
              • onError
              • install
          • 落幕
          相关产品与服务
          项目管理
          CODING 项目管理(CODING Project Management,CODING-PM)工具包含迭代管理、需求管理、任务管理、缺陷管理、文件/wiki 等功能,适用于研发团队进行项目管理或敏捷开发实践。结合敏捷研发理念,帮助您对产品进行迭代规划,让每个迭代中的需求、任务、缺陷无障碍沟通流转, 让项目开发过程风险可控,达到可持续性快速迭代。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档