vue-router 源码概览

·  阅读 2427

源码这个东西对于实际的工作其实没有立竿见影的效果,不会像那些针对性极强的文章一样看了之后就立马可以运用到实际项目中,产生什么样的效果,源码的作用是一个潜移默化的过程,它的理念、设计模式、代码结构等看了之后可能不会立即知识变现(或者说变现很少),而是在日后的工作过程中悄无声息地发挥出来,你甚至都感觉不到这个过程

另外,优秀的源码案例,例如 vuereact这种,内容量比较庞大,根本不是三篇五篇十篇八篇文章就能说完的,而且写起来也很难写得清楚,也挺浪费时间的,而如果只是分析其中一个点,例如 vue的响应式,类似的文章也已经够多了,没必要再 repeat

所以我之前没专门写过源码分析的文章,只是自己看看,不过最近闲来无事看了 vue-router的源码,发现这种插件级别的东西,相比 vue这种框架级别的东西,逻辑简单清晰,没有那么多道道,代码量也不多,但是其中包含的理念等东西却很精炼,值得一写,当然,文如其名,只是概览,不会一行行代码分析过去,细节的东西还是要自己看看的

vue.use

vue插件必须通过 vue.use进行注册,vue.use的代码位于 vue源码的 src/core/global-api/use.js文件中,此方法的主要作用有两个:

  • 对注册的组件进行缓存,避免多次注册同一个插件
if (installedPlugins.indexOf(plugin) > -1) {
  return this
}
复制代码
  • 调用插件的 install方法或者直接运行插件,以实现插件的 install
if (typeof plugin.install === 'function') {
  plugin.install.apply(plugin, args)
} else if (typeof plugin === 'function') {
  plugin.apply(null, args)
}
复制代码

路由安装

vue-routerinstall方法位于 vue-router源码的src/install.js中 主要是通过 vue.minxin混入 beforeCreatedestroyed钩子函数,并全局注册 router-viewrouter-link组件

// src/install.js
Vue.mixin({
  beforeCreate () {
    if (isDef(this.$options.router)) {
      this._routerRoot = this
      this._router = this.$options.router
      this._router.init(this)
      Vue.util.defineReactive(this, '_route', this._router.history.current)
    } else {
      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
    }
    registerInstance(this, this)
  },
  destroyed () {
    registerInstance(this)
  }
})
...
// 全局注册 `router-view` 和 `router-link`组件
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
复制代码

路由模式

vue-router支持三种路由模式(mode):hashhistoryabstract,其中 abstract是在非浏览器环境下使用的路由模式,例如weex

路由内部会对外部指定传入的路由模式进行判断,例如当前环境是非浏览器环境,则无论传入何种mode,最后都会被强制指定为 abstract,如果判断当前环境不支持 HTML5 History,则最终会被降级为 hash模式

// src/index.js
let mode = options.mode || 'hash'
this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
if (this.fallback) {
  mode = 'hash'
}
if (!inBrowser) {
  mode = 'abstract'
}
复制代码

最后会对符合要求的 mode进行对应的初始化操作

// src/index.js
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}`)
    }
}
复制代码

路由解析

通过递归的方式来解析嵌套路由

// src/create-route-map.js
function addRouteRecord (
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>,
  route: RouteConfig,
  parent?: RouteRecord,
  matchAs?: string
) {
  ...
  route.children.forEach(child => {
    const childMatchAs = matchAs
      ? cleanPath(`${matchAs}/${child.path}`)
      : undefined
    addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
  })
  ...
}
复制代码

解析完毕之后,会通过 key-value对的形式对解析好的路由进行记录,所以如果声明多个相同路径(path)的路由映射,只有第一个会起作用,后面的会被忽略

// src/create-route-map.js
if (!pathMap[record.path]) {
  pathList.push(record.path)
  pathMap[record.path] = record
}
复制代码

例如如下路由配置,路由 /bar 只会匹配 Bar1Bar2这一条配置会被忽略

const routes = [
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar1 },
  { path: '/bar', component: Bar2 },
];
复制代码

路由切换

当访问一个 url的时候,vue-router会根据路径进行匹配,创建出一个 route对象,可通过 this.$route进行访问

// src/util/route.js
const route: Route = {
  name: location.name || (record && record.name),
  meta: (record && record.meta) || {},
  path: location.path || '/',
  hash: location.hash || '',
  query,
  params: location.params || {},
  fullPath: getFullPath(location, stringifyQuery),
  matched: record ? formatMatch(record) : []
}
复制代码

src/history/base.js源码文件中的 transitionTo()是路由切换的核心方法

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const route = this.router.match(location, this.current)
  this.confirmTransition(route, () => {
  ...
}
复制代码

路由实例的pushreplace等路由切换方法,都是基于此方法实现路由切换的,例如 hash模式的 push方法:

// src/history/hash.js
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const { current: fromRoute } = this
  // 利用了 transitionTo 方法
  this.transitionTo(location, route => {
    pushHash(route.fullPath)
    handleScroll(this.router, route, fromRoute, false)
    onComplete && onComplete(route)
  }, onAbort)
}
复制代码

transitionTo方法内部通过一种异步函数队列化执⾏的模式来更新切换路由,通过 next函数执行异步回调,并在异步回调方法中执行相应的钩子函数(即 导航守卫) beforeEachbeforeRouteUpdatebeforeRouteEnterbeforeRouteLeave

通过 queue这个数组保存相应的路由参数:

// src/history/base.js
const queue: Array<?NavigationGuard> = [].concat(
  // in-component leave guards
  extractLeaveGuards(deactivated),
  // global before hooks
  this.router.beforeHooks,
  // in-component update hooks
  extractUpdateHooks(updated),
  // in-config enter guards
  activated.map(m => m.beforeEnter),
  // async components
  resolveAsyncComponents(activated)
)
复制代码

通过 runQueue以一种递归回调的方式来启动异步函数队列化的执⾏:

// src/history/base.js
// 异步回调函数
runQueue(queue, iterator, () => {
  const postEnterCbs = []
  const isValid = () => this.current === route
  // wait until async components are resolved before
  // extracting in-component enter guards
  const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
  const queue = enterGuards.concat(this.router.resolveHooks)
  // 递归执行
  runQueue(queue, iterator, () => {
    if (this.pending !== route) {
      return abort()
    }
    this.pending = null
    onComplete(route)
    if (this.router.app) {
      this.router.app.$nextTick(() => {
        postEnterCbs.forEach(cb => { cb() })
      })
    }
  })
})
复制代码

通过 next进行导航守卫的回调迭代,所以如果在代码中显式声明了导航钩子函数,那么就必须在最后调用 next(),否则回调不执行,导航将无法继续

// src/history/base.js
const iterator = (hook: NavigationGuard, next) => {
  ...
  hook(route, current, (to: any) => {
    ...
    } else {
      // confirm transition and pass on the value
      next(to)
    }
  })
...
}
复制代码

路由同步

在路由切换的时候,vue-router会调用 pushgo等方法实现视图与地址url的同步

地址栏 url与视图的同步

当进行点击页面上按钮等操作进行路由切换时,vue-router会通过改变 window.location.href来保持视图与 url的同步,例如 hash模式的路由切换:

// src/history/hash.js
function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path
  }
}
复制代码

上述代码,先检测当前浏览器是否支持 html5History API,如果支持则调用此 API进行 href的修改,否则直接对window.location.hash进行赋值 history的原理与此相同,也是利用了 History API

视图与地址栏 url的同步

当点击浏览器的前进后退按钮时,同样可以实现视图的同步,这是因为在路由初始化的时候,设置了对浏览器前进后退的事件监听器

下述是 hash模式的事件监听:

// src/history/hash.js
setupListeners () {
  ...
  window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {
    const current = this.current
    if (!ensureSlash()) {
      return
    }
    this.transitionTo(getHash(), route => {
      if (supportsScroll) {
        handleScroll(this.router, route, current, true)
      }
      if (!supportsPushState) {
        replaceHash(route.fullPath)
      }
    })
  })
}
复制代码

history模式与此类似:

// src/history/html5.js
window.addEventListener('popstate', e => {
  const current = this.current

  // Avoiding first `popstate` event dispatched in some browsers but first
  // history route not updated since async guard at the same time.
  const location = getLocation(this.base)
  if (this.current === START && location === initLocation) {
    return
  }

  this.transitionTo(location, route => {
    if (supportsScroll) {
      handleScroll(router, route, current, true)
    }
  })
})
复制代码

无论是 hash还是 history,都是通过监听事件最后来调用 transitionTo这个方法,从而实现路由与视图的统一

另外,当第一次访问页面,路由进行初始化的时候,如果是 hash模式,则会对url进行检查,如果发现访问的 url没有带 #字符,则会自动追加,例如初次访问 http://localhost:8080这个 urlvue-router会自动置换为 http://localhost:8080/#/,方便之后的路由管理:

// src/history/hash.js
function ensureSlash (): boolean {
  const path = getHash()
  if (path.charAt(0) === '/') {
    return true
  }
  replaceHash('/' + path)
  return false
}
复制代码

scrollBehavior

当从一个路由 /a跳转到另外的路由 /b后,如果在路由 /a的页面中进行了滚动条的滚动行为,那么页面跳转到/b时,会发现浏览器的滚动条位置和 /a的一样(如果 /b也能滚动的话),或者刷新当前页面,浏览器的滚动条位置依旧不变,不会直接返回到顶部的 而如果是通过点击浏览器的前进、后退按钮来控制路由切换时,则部分浏览器(例如微信)滚动条在路由切换时都会自动返回到顶部,即scrollTop=0的位置 这些都是浏览器默认的行为,如果想要定制页面切换时的滚动条位置,则可以借助 scrollBehavior这个 vue-routeroptions

当路由初始化时,vue-router会对路由的切换事件进行监听,监听逻辑的一部分就是用于控制浏览器滚动条的位置:

// src/history/hash.js
setupListeners () {
  ...
  if (supportsScroll) {
    // 进行浏览器滚动条的事件控制
    setupScroll()
  }
  ...
}
复制代码

这个 set方法定义在 src/util/scroll.js,这个文件就是专门用于控制滚动条位置的,通过监听路由切换事件从而进行滚动条位置控制:

// src/util/scroll.js
window.addEventListener('popstate', e => {
  saveScrollPosition()
  if (e.state && e.state.key) {
    setStateKey(e.state.key)
  }
})
复制代码

通过 scrollBehavior可以定制路由切换的滚动条位置,vue-routergithub上的源码中,有相关的 example,源码位置在 vue-router/examples/scroll-behavior/app.js

router-view & router-link

router-viewrouter-link这两个 vue-router的内置组件,源码位于 src/components

router-view

router-view是无状态(没有响应式数据)、无实例(没有 this上下文)的函数式组件,其通过路由匹配获取到对应的组件实例,通过 h函数动态生成组件,如果当前路由没有匹配到任何组件,则渲染一个注释节点

// vue-router/src/components/view.js
...
const matched = route.matched[depth]
// render empty node if no matched route
if (!matched) {
  cache[name] = null
  return h()
}
const component = cache[name] = matched.components[name]
...
return h(component, data, children)
复制代码

每次路由切换都会触发 router-view重新 render从而渲染出新的视图,这个触发的动作是在 vue-router初始化 init的时候就声明了的:

// src/install.js
Vue.mixin({
  beforeCreate () {
    if (isDef(this.$options.router)) {
      this._routerRoot = this
      this._router = this.$options.router
      this._router.init(this)
      // 触发 router-view重渲染
      Vue.util.defineReactive(this, '_route', this._router.history.current)
      ...
})
复制代码

this._route通过 defineReactive变成一个响应式的数据,这个defineReactive就是 vue中定义的,用于将数据变成响应式的一个方法,源码在 vue/src/core/observer/index.js中,其核心就是通过 Object.defineProperty方法修改数据的 gettersetter

Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        // 进行依赖收集
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      ...
      // 通知订阅当前数据 watcher的观察者进行响应
      dep.notify()
    }
复制代码

当路由发生变化时,将会调用 router-viewrender函数,此函数中访问了 this._route这个数据,也就相当于是调用了 this._routegetter方法,触发依赖收集,建立一个 Watcher,执行 _update方法,从而让页面重新渲染

// vue-router/src/components/view.js
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
  // 触发依赖收集,建立 render watcher
  const route = parent.$route
  ...
}
复制代码

这个 render watcher的派发更新,也就是 setter的调用,位于 src/index.js

history.listen(route => {
  this.apps.forEach((app) => {
    // 触发 setter
    app._route = route
  })
})
复制代码

router-link

router-link在执行 render函数的时候,会根据当前的路由状态,给渲染出来的active元素添加 class,所以你可以借助此给active路由元素设置样式等:

// src/components/link.js
render (h: Function) {
  ...
  const globalActiveClass = router.options.linkActiveClass
  const globalExactActiveClass = router.options.linkExactActiveClass
  // Support global empty active class
  const activeClassFallback = globalActiveClass == null
    ? 'router-link-active'
    : globalActiveClass
  const exactActiveClassFallback = globalExactActiveClass == null
    ? 'router-link-exact-active'
    : globalExactActiveClass
    ...
}
复制代码

router-link默认渲染出来的元素是 <a>标签,其会给这个 <a>添加 href属性值,以及一些用于监听能够触发路由切换的事件,默认是 click事件:

// src/components/link.js
data.on = on
data.attrs = { href }
复制代码

另外,你可以可以通过传入 tag这个 props来定制 router-link渲染出来的元素标签:

<router-link to="/foo" tag="div">Go to foo</router-link>
复制代码

如果 tag值不为 a,则会递归遍历 router-link的子元素,直到找到一个 a标签,则将事件和路由赋值到这个 <a>上,如果没找到a标签,则将事件和路由放到 router-link渲染出的本身元素上:

if (this.tag === 'a') {
    data.on = on
    data.attrs = { href }
  } else {
    // find the first <a> child and apply listener and href
    // findAnchor即为递归遍历子元素的方法
    const a = findAnchor(this.$slots.default)
    ...
  }
}
复制代码

当触发这些路由切换事件时,会调用相应的方法来切换路由刷新视图:

// src/components/link.js
const handler = e => {
  if (guardEvent(e)) {
    if (this.replace) {
      // replace路由
      router.replace(location)
    } else {
      // push 路由
      router.push(location)
    }
  }
}
复制代码

总结

可以看到,vue-router的源码是很简单的,比较适合新手进行阅读分析

源码这种东西,我的理解是没必要非要专门腾出时间来看,只要你熟读文档,能正确而熟练地运用 API实现各种需求那就行了,轮子的出现本就是为实际开发所服务而不是用来折腾开发者的,注意,我不是说不要去看,有时间还是要看看的,就算弄不明白其中的道道,但看了一遍总会有收获的,比如我在看 vue源码的时候,经常看到类似于这种的赋值写法:

// vue/src/core/vdom/create-functional-component.js
(clone.data || (clone.data = {})).slot = data.slot
复制代码

如果是之前,对于这段逻辑我通常会这么写:

if (clone.data) {
  clone.data.slot = data.slot
} else {
  clone.data = {
    slot: data.slot
  }
}
复制代码

也不是说第一种写法有什么难度或者看不明白,只是习惯了第二种写法,平时写代码的过程中自然而然不假思索地就写出来了,习惯成自然了,但是当看到第一种写法的时候才会一拍脑袋想着原来这么写也可以,以前白敲了那么多次键盘,所以没事要多看看别人优秀的源码,避免沉迷于自己的世界闭门造车,这样才能查漏补缺,这同样也是我认为代码 review比较重要的原因,自己很难发现的问题,别人可能一眼就看出来了,此之谓当局者迷旁观者清也

收藏成功!
已添加到「」, 点击更改