Vue router 核心源码分析,面试问不怕了!

Vue router 源码分析

源码版本:3.6.2 阅读源码的目标:

  1. 作为 Vue 开发的三板斧,我需要清晰的对其背后的原理有一个认知。
  2. 学习插件设计的模式,思考之后如果也有同样需求时,我可以怎么去设计开发。
  3. 阅读源码的同时也能提高自己的逻辑编码能力。

习惯性给自己使绊子(带着疑问看源码)

  • ① 大致的目录结构设计?
  • ② 核心的源码逻辑?
  • ③ 如何组合 Vue 使用的?
  • ④ 如何监听 Url 变化,从而结合 Vue 的响应式原理更新视图?

撸起袖子,咱们开干!

作为 Vue 的一个插件被执行

Vue.use(plugin)

插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。官方对此的使用介绍如下:

一个插件可以是一个拥有 install() 方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给 app.use() 的额外选项作为参数:

javascript 复制代码
import VueRouter from 'vue-router'

Vue.use(VueRouter, {
  /* 可选的选项 */
})

趁热打铁,我也翻阅了 Vue.use 源码部分的实现,再结合官方给出的解释就比较清晰可见了,将我们用到的插件推入到全局的插件储存仓库中并调用执行 install 函数。如下:

javascript 复制代码
  export function initUse (Vue: GlobalAPI) {
    // 用法:Vue.use(VueRouter)
    Vue.use = function (plugin: Function | Object) {
      const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
      if (installedPlugins.indexOf(plugin) > -1) { // 判断有没有这个插件,如果有返回
        return this
      }

      // 获取选项配置参数
      const args = toArray(arguments, 1)
      args.unshift(this)
      if (typeof plugin.install === 'function') {
        plugin.install.apply(plugin, args)
      } else if (typeof plugin === 'function') {
        plugin.apply(null, args)
      }
      installedPlugins.push(plugin)
      return this
    }
  }

Install

vue-router 的 install 函数位于 src/install.js, 它会被作于 VueRouter 类实现的一个静态方法引入

javascript 复制代码
/* src/router.js */
import { install } from './install'

export default class VueRouter {}

VueRouter.install = install
javascript 复制代码
/* src/install.js */
import View from './components/view'
import Link from './components/link'

export let _Vue

export function install (Vue) {
  if (install.installed && _Vue === Vue) return
  install.installed = true

  // Vue 对象作为参数被传入,插件编写不需要再额外 import Vue, 减少了插件包体积 
  _Vue = Vue
  
  // ...

  // 应用一个全局 mixin (适用于该应用的范围)。一个全局的 mixin 会作用于应用中的每个组件实例。内部实现通过 mergeOptions 函数(一系列合并策略,可自查 Vue 部分的源码实现)。
  Vue.mixin({
    beforeCreate () {
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
        this._router.init(this)  // this => 根实例 new Vue()
        // 通过 Vue 的响应式监听 _route 的变化, 从而更新渲染视图,可以结合下文中的路由调整去解答疑问 ④
        // TIP: 同时这里有一个细节处理,如果我们在 History 类中直接更换current对象值,响应式是丢失的,需要我们手动更新 _route 值的。
        // class History {
        //   updateRoute (route: Route) {
        //     this.current = route
        //     + this.cb && this.cb(route)
        //   }
        // }
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 后代组件增加 _routerRoot,指向根实例
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })

  // 代理实例上的 $router 属性,this.$router 指向根实例的的 _router
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  // 同上理
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

  // 注册全局组件,RouterView 组件中有
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)

  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

我们大概捋一下 install 函数的职责:

  1. 通过 Vue.mixin 将 beforeCreated 周期钩子函数注入到每一个组件模块中,赋值 _router 给根实例完成 router 的初始化调用 this._router.init();
  2. 通过 Vue.util.defineReactive 给根实例添加了一个响应式属性 _route, 配合路由变化时去更新渲染视图;
  3. 在 Vue 原型上代理了 $router、$route 属性,方便子组件模块的调用;
  4. 通过 Vue.component 全局注册了 <router-link> 、<router-view> 组件;

VueRouter 类的实现

这里只抽取了主要的逻辑代码来分析, 文件位置 src/router.js

javascript 复制代码
export default class VueRouter {
  install

  constructor (options = {}) {
    
    if (process.env.NODE_ENV !== 'production') {
      warn(this instanceof VueRouter, `Router must be called with the new operator.`)
    }
    this.app = null
    this.apps = []
    this.options = options
    // 三个路由守卫的钩子函数待执行存储器
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []

    this.matcher = createMatcher(options.routes || [], this) // 路由匹配器,匹配或者添加路由

    // 路由模式匹配
    let mode = options.mode || 'hash'
    this.fallback =
      mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode

    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }

  // ...

  init (app: any /* Vue component instance */) {
    this.apps.push(app)

    // main app previously initialized
    // return as we don't need to set up new history listener
    if (this.app) {
      return
    }

    this.app = app

    const history = this.history

    if (history instanceof HTML5History || history instanceof HashHistory) {
      // 处理滚动条相关逻辑
      const handleInitialScroll = routeOrError => {
        const from = history.current
        const expectScroll = this.options.scrollBehavior
        const supportsScroll = supportsPushState && expectScroll

        if (supportsScroll && 'fullPath' in routeOrError) {
          handleScroll(this, routeOrError, from, false)
        }
      }

      // 对应路由模式类的侦听器,开启路由监听,执行响应事件
      const setupListeners = routeOrError => {
        history.setupListeners()
        handleInitialScroll(routeOrError)
      }

      // 路由视图首次渲染启动函数执行
      history.transitionTo(
        history.getCurrentLocation(),
        setupListeners,
        setupListeners
      )
    }

    // 监听路由变化,执行改变根实例的响应式属性_route值
    history.listen(route => {
      this.apps.forEach(app => {
        app._route = route
      })
    })
  }

  // 路由守卫钩子函数,通过 registerHook 函数,将待处理事件添进对应的钩子函数待执行存储器

  beforeEach (fn: Function): Function {
    return registerHook(this.beforeHooks, fn)
  }

  beforeResolve (fn: Function): Function {
    return registerHook(this.resolveHooks, fn)
  }

  afterEach (fn: Function): Function {
    return registerHook(this.afterHooks, fn)
  }

  // ...

  // 路由跳转逻辑 push \ replace
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.push(location, resolve, reject)
      })
    } else {
      this.history.push(location, onComplete, onAbort)
    }
  }

  replace () {
    // 逻辑同 push, API 变更为 replace
  }

  // ...

  addRoute (parentOrRoute: string | RouteConfig, route?: RouteConfig) {
    // 调用 matcher.addRoute
  }

  addRoutes (routes: Array<RouteConfig>) {
    // 调用 matcher.addRoutes
  }
}

constructor

在 VueRouter 类的构造函数中,定义相关的私有属性。三个路由守卫的钩子函数待执行存储器:this.beforeHooks、resolveHooks、afterHooks;通过 createMatcher 函数生成一个路由匹配器,该函数返回了match、addRoutes、addRoute、getRoutes四个子功能函数;随后通过 options.mode 进行了路由模式匹配:hash、history、abstract, 返回了对应路由监听实例,下文会对这几个实例进行详细分析。

init

前面提到 init 函数的执行时机,在 install 方法中,我们通过 Vue.mixin 将 beforeCreate 生命周期函数注入了每个子组件中,根实例实例化(new Vue())的时候便会调用 init 函数。

history.transitionTo 做了啥?

前面我有思考过路由变化的时候,会根据 URL 的变化响应式更新渲染页面,阅读到这里我又产生了新的疑问:当我们的应用首次刷新渲染时,那么我们如何通过 url 的值去展示对应的渲染组件呢?popstate/hashchange 可不会干这件事。这里先下猜测吧:transitionTo 函数会匹配 url 值处理后续的组件渲染逻辑,这个我们在后文的路由模式中去讲解。

history.listen

上文中有提到我们在 History 类中直接更换current对象值,响应式是丢失的,需要我们手动更新 _route 值的。history.listen 就恰好帮我们处理了这件事:

javascript 复制代码
history.listen((newRoute) => {
  app._route = newRoute
})

matcher

createMatcher

javascript 复制代码
/**src/create-matcher.js */
export function createMatcher (
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes)

  // 动态添加多个个路由规则
  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }

  // 动态添加单个路由规则
  function addRoute (parentOrRoute, route) { 
    const parent = (typeof parentOrRoute !== 'object') ? nameMap[parentOrRoute] : undefined
    // $flow-disable-line
    createRouteMap([route || parentOrRoute], pathList, pathMap, nameMap, parent)

    // add aliases of parent
    if (parent && parent.alias.length) {
      createRouteMap(
        // $flow-disable-line route is defined if parent is
        parent.alias.map(alias => ({ path: alias, children: [route] })),
        pathList,
        pathMap,
        nameMap,
        parent
      )
    }
  }
  
}

梳理:路由匹配器, 通过 createMatcher 函数返回了match、addRoutes、addRoute、getRoutes四个子功能函数;

createRouteMap

javascript 复制代码
/**src/create-route-map.js */
export function createRouteMap (
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>,
  parentRoute?: RouteRecord
): {
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>
} {
  // 路由路径列表
  const pathList: Array<string> = oldPathList || []
  // 路由路径映射一份 RouteRecord
  const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
  // 组件模块name映射一份 RouteRecord
  const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)

  routes.forEach(route => {
    // RouteRecord 路由记录生成器
    addRouteRecord(pathList, pathMap, nameMap, route, parentRoute)
  })

  // ...

  return {
    pathList,
    pathMap,
    nameMap
  }
}

梳理:这个函数主要是根据我们给入的 routes,创建了 pathMap、nameMap 映射表,通过 addRouteRecord 给对应的 path\name 映射路由记录,完善了单个路由模块的一些信息。

路由模式

vue-router 主要用到两种路由模式:

  1. hash 模式:也被成为锚点模式。Url 中的哈希部分(#号后的内容)会被作为路由的标识符。监听事件:popstate、hashchange 。
  2. history 模式:使用完整的 Url 路径去匹配路由。监听事件:popstate

衍生知识点:popstate事件触发时机

当用户点击浏览器的后退、前进按钮,在 js 中调用 HTML5 history API,如 history.back()、history.go()、history.forward(),或者通过 location.hash = 'xxx' 都会触发 popstate 事件 和 hashchange 事件,需要注意的是调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件 和 hashchange 事件。

我们继续往下一步探讨,看看 vue-router 中 HashHistory 模式的实现方式。

HashHistory

javascript 复制代码
import { History } from './base'
export class HashHistory extends History {
  constructor (router: Router, base: ?string, fallback: boolean) {
    super(router, base)
    // ...

    // 初始化url,给一个默认的hash值、
    ensureSlash()
  }

  // this is delayed until the app mounts
  // to avoid the hashchange listener being fired too early
  setupListeners () {
    if (this.listeners.length > 0) {
      return
    }

    const router = this.router
    
    // ...

    const handleRoutingEvent = () => {
      const current = this.current
      if (!ensureSlash()) {
        return
      }
      this.transitionTo(getHash(), route => {
        // ...
        if (!supportsPushState) {
          replaceHash(route.fullPath)
        }
      })
    }
    const eventType = supportsPushState ? 'popstate' : 'hashchange'
    window.addEventListener(
      eventType,
      handleRoutingEvent
    )
    this.listeners.push(() => {
      window.removeEventListener(eventType, handleRoutingEvent)
    })
  }

  // location 就是在做路由跳转携带的路由对象信息
  // router.push({ name: 'xxx', query: { xxx: aaa } })
  // router.push({ path: 'xxx/yyy', params: { xxx: aaa } })
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(
      location,
      route => {
        pushHash(route.fullPath)
        handleScroll(this.router, route, fromRoute, false)
        onComplete && onComplete(route)
      },
      onAbort
    )
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // ...
  }
   
  // ...
}

setupListener

路由监听器:当用户点击浏览器的后退、前进按钮后,路由监听到 hash 值变化之后大致执行以下几个步骤:

  • handleRoutingEvent 函数执行
  • 处理滚动条行为,当切换到新路由时,想要页面滚到顶部,或者是保持原先的滚动位置,就像重新加载页面那样。这里可以参照官网的讲解
  • 调用 transitionTo 方法执行路由切换, 该方法位于 src/history/base.js 的基类 History 中,通过VueRouter 类中的 match 方法匹配到要加载的路由组件,将匹配到的组件交给 confirmTransition 执行最终的路由切换任务,这里面也包含了 vue-router 中的路由守卫设置以及执行的逻辑。我直接把主要功能且带注释的代码贴出来,这部分还是需要花点时间才能梳理通的,涉及的知识点也是需要细嚼慢咽,我这会也是快噎住了~。

confirmTransition

javascript 复制代码
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
  const current = this.current
  this.pending = route

  // ...
  
  // route.matched 一个数组,包含当前路由的所有嵌套路径片段的路由记录 。路由记录就是 routes 配置数组中的对象副本 (还有在 children 数组)。
  const lastRouteIndex = route.matched.length - 1
  const lastCurrentIndex = current.matched.length - 1
  if (
    isSameRoute(route, current) &&
    // in the case the route map has been dynamically appended to
    lastRouteIndex === lastCurrentIndex &&
    route.matched[lastRouteIndex] === current.matched[lastCurrentIndex]
  ) {
    // ...
  }

  // updated:  需要添加 beforeRouteUpdate 守卫的已存在组件列表
  // activated: 新的活跃组件
  // deactivated: 需要添加 beforeRouteLeave 守卫的新活跃组件列表
  const { updated, deactivated, activated } = resolveQueue(
    this.current.matched,
    route.matched
  )

  // 队列从前往后执行,这里进入队列的任务就是路由守卫的执行顺序, runQueue 中还有一个组件内的前置路由守卫
  const queue: Array<?NavigationGuard> = [].concat(
    // 提取组件内部的 beforeRouteLeave 钩子函数
    extractLeaveGuards(deactivated),
    // 全局的前置路由守卫
    this.router.beforeHooks,
    // 提取组件内部的 beforeRouteUpdate 钩子函数
    extractUpdateHooks(updated),
    // 路由配置的独享守卫(激活的路由)
    activated.map(m => m.beforeEnter),
    // async components
    resolveAsyncComponents(activated)
  )

  const iterator = (hook: NavigationGuard, next) => {
    if (this.pending !== route) {
      return abort(createNavigationCancelledError(current, route))
    }
    try {
      hook(route, current, (to: any) => {
        // ... 
      })
    } catch (e) {
      abort(e)
    }
  }

  runQueue(queue, iterator, () => {
    // wait until async components are resolved before
    // 具体可以查看 src/util/async.js 文件中的 runQueue,函数执行控制权交换
    // 提取组件内部的 beforeRouteUpdate 钩子函数
    const enterGuards = extractEnterGuards(activated)
    const queue = enterGuards.concat(this.router.resolveHooks)
    runQueue(queue, iterator, () => {
      if (this.pending !== route) {
        return abort(createNavigationCancelledError(current, route))
      }
      this.pending = null
      onComplete(route)
      if (this.router.app) {
        // 等待所有守卫执行完,nextTick 开启一个微任务执行组件渲染事件,感兴趣的同学也可以看看 nextTick 的源码,从 nextTick 着手对浏览器的事件循环机制有个大概的了解。都是面试中高频的考点!
        this.router.app.$nextTick(() => {
          handleRouteEntered(route)
        })
      }
    })
  })
}

Html5History 模式,使用 history.pushState/replaceState API 来完成 Url 跳转,使用onpopstate 事件监听路由变化, 内部的大方向执行逻辑与 HashHistory 相似,这里就不做分析了。

路由切换

通过 HashHistory 模式路由调整流程分析下来,我们也初步掌握了路由跳转的原理了,接下来我们顺便再看下 router-link 组件是如何实现路由跳转的, 我们可以翻阅到 src/components/link.js 看看组件内部实现 (同样只贴出关键代码部分):

javascript 复制代码
  export default {
  name: 'RouterLink',
  props: {
    to: {
      type: toTypes,
      required: true
    },
    tag: {
      type: String,
      default: 'a'
    },
    // ...
    event: {
      type: eventTypes,
      default: 'click'
    }
  },
  render (h: Function) {
    const router = this.$router
    const current = this.$route
    const { location, route, href } = router.resolve(
      this.to,
      current,
      this.append
    )

    // ...

    // 元素点击事件
    const handler = e => {
      if (guardEvent(e)) {
        // 这里可以分析出,元素点击事件触发后执行的正是上文中提到的 VueRouter 类的 replace/push 两个方法
        if (this.replace) {
          router.replace(location, noop)
        } else {
          router.push(location, noop)
        }
      }
    }

    const on = { click: guardEvent }
    // ...
    if (this.tag === 'a') {
      data.on = on
      data.attrs = { href, 'aria-current': ariaCurrentValue }
    }

    // ...
    return h(this.tag, data, this.$slots.default)
  }
}

组件的路由跳转实现主要是靠 VueRouter 类的 replace/push 两个方法。

router-view 试图的响应式更新

router-view 组件便是我们实现响应式的重要环节,Vue 中想要实现响应式视图更新的前提便是设置的响应式属性在依赖中被读取,在该组件中的表现形式便是通过对上文 install 函数提及的代理响应式属性对象 <math xmlns="http://www.w3.org/1998/Math/MathML"> r o u t e 的读取,之后便把该试图作为依赖收集,在 route 的读取,之后便把该试图作为依赖收集,在 </math>route的读取,之后便把该试图作为依赖收集,在route 变化的时候触发 setter 通知依赖更新试图:

javascript 复制代码
export default {
  name: 'RouterView',
  functional: true,
  props: {
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    // used by devtools to display a router-view badge
    data.routerView = true

    // directly use parent context's createElement() function
    // so that components rendered by router-view can resolve named slots
    const h = parent.$createElement
    const name = props.name

    const route = parent.$route // 响应式属性读取,依赖收集
    // ...
    return h(component, data, children)
  }
}

结语

以上便是我对 vue-router 源码的一个大致解读和知识的输出,第一次尝试去写一篇源码分析的文章,文中如果有描述不对的地方,还请大佬们指正,我也会及时更新到文章中。再说一点感想吧,整篇文章写完后,我对 vue-router 的原理有了更进一步的认知,相信后续在使用它时会更佳得心应手,馒头要细嚼慢咽,源码部分还得再深入的去学习理解,之后也会尝试对别的一些三方库进行源码分析解读,欢迎大家一起探讨问题。

相关推荐
前端郭德纲4 分钟前
深入浅出ES6 Promise
前端·javascript·es6
天天进步201525 分钟前
Lodash:现代 JavaScript 开发的瑞士军刀
开发语言·javascript·ecmascript
王哲晓31 分钟前
第六章 Vue计算属性之computed
前端·javascript·vue.js
假装我不帅34 分钟前
js实现类似与jquery的find方法
开发语言·javascript·jquery
究极无敌暴龙战神X37 分钟前
CSS复习2
前端·javascript·css
Hadoop_Liang1 小时前
Docker Compose一键部署Spring Boot + Vue项目
vue.js·spring boot·docker
GISer_Jing2 小时前
React面试常见题目(基础-进阶)
javascript·react.js·前端框架
有梦想的咕噜2 小时前
Electron 是一个用于构建跨平台桌面应用程序的开源框架
前端·javascript·electron
yqcoder2 小时前
electron 监听窗口高端变化
前端·javascript·vue.js
bjzhang752 小时前
Depcheck——专门用于检测 JavaScript 和 Node.js 项目中未使用依赖项的工具
javascript·node.js·depcheck