从源码角度解析点击<router-link>后发生了什么

749 阅读3分钟

前言

看了这篇文章后:juejin.cn/post/699164…,作者写得很不错,由浅入深,但因为确实很详细,导致如果不是一次性静下心来研究的话,容易看了下面,忘了上面,这里通过一个跳转例子来看看路由跳转的过程中发生了哪些事,这里使用的例子是vue-routergithub源码里的example/hash-mode🌰。

起步

先看看这个例子里的代码

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

// 1. 定义路由组建
const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

// 2. 创建路由
const router = new VueRouter({
  mode: 'hash',
  base: __dirname,
  routes: [
    { path: '/', component: Home, name: 'Home' }, // all paths are defined without the hash.
    { path: '/foo', component: Foo, name: 'Foo' },
    { path: '/bar', component: Bar },
  ]
})

const vueInstance = new Vue({
  router,
  template: `
    <div id="app">
      <h1>Mode: 'hash'</h1>
      <ul>
        <li><router-link to="/">/</router-link></li>
        <li><router-link to="/foo">/foo</router-link></li>
        <li><router-link to="/bar">/bar</router-link></li>
        <router-link tag="li" to="/bar">/bar</router-link>
      </ul>
      <router-view class="view"></router-view>
    </div>
  `,
  methods: {}
}).$mount('#app')

document.getElementById('unmount').addEventListener('click', () => {
  vueInstance.$destroy()
  vueInstance.$el.innerHTML = ''
})

这里对源码进了一下删减,主要是去除一些跟路由跳转无关的展示性的代码,可以看到,代码就是很常规的定义路由信息跟创建路由,接着使用<router-link><router-vue>来进行路由跳转跟路由内容展示。

既然是使用<router-link>来出发跳转逻辑,那么跳转的开始也就是在定义<router-link>组件的link.js中,先看看触发的逻辑

const handler = e => { // 触发的路由跳转逻辑
  if (guardEvent(e)) { // 这里是去除默认的点击行为
    if (this.replace) {
      router.replace(location, noop)
    } else {
      router.push(location, noop)
    }
  }
}

const on = { click: guardEvent } // 为点击时间添加清除默认行为的逻辑
if (Array.isArray(this.event)) { // event是我们传进来的,用于指定支持跳转逻辑的事件,默认是click
  this.event.forEach(e => {
    on[e] = handler
  })
} else {
  on[this.event] = handler
}

if (this.tag === 'a') { // 这段逻辑是找到触发跳转事件的html标签,默认是用<a>标签
  data.on = on
  data.attrs = { href, 'aria-current': ariaCurrentValue }
} else {
  // find the first <a> child and apply listener and href
  const a = findAnchor(this.$slots.default)
  if (a) {
  } else {
    // doesn't have <a> child, apply listener to self
    data.on = on
  }
}
return h(this.tag, data, this.$slots.default) // 生成触发跳转逻辑的vNode

这里主要做两件事:

  • 找到真正出发跳转逻辑的节点,默认是<a>标签,否则查找子节点,有<a>标签则使用这个<a>,否则将事件添加到tag属性传入的标签。
  • 添加跳转逻辑:跳转逻辑默认是由click事件触发,但我们也可以通过传入event来决定哪些可以触发跳转逻辑,跳转逻辑实际上就是调用router抛出的方法,既然是我们改写了跳转逻辑,那么就得调用guardEvent来清楚默认的跳转行为。

所以点击节点后,触发的就是handler()函数,接下来看看handler()函数的定义,其实就是调用router,而router其实就是VueRouter实例。

VueRouter

上面的handler()函数里调用的router方法是在VueRouter类定义的实例方法,这里以router.push()举例,router.push()的源码如下在VueRouter类中:

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)
  }
}

push方法接收三个参数,第一个是跳转的路由信息,其他两个是在导航成功完成 (在所有的异步钩子被解析之后) 或终止 (导航到相同的路由、或在当前导航完成之前导航到另一个不同的路由) 的时候进行相应的调用。 这里调用的是this.history.push,而this.history是由我们路由配置信息里的mode决定的,在VueRouter类的构造函数里定义的

constructor (options: RouterOptions = {}) {
  ...
  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}`)
      }
  }
}

既然我们使用的是examples/hash-mode那么就是走到case 'hash'里了,这样就走到HashHistory

HashHistory

由上面可知,this.history.push()实际上调用的是HashHistory类的push()方法,源码如下:

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
  )
}

可以看到,主要就是两部分:获取跳转源和执行this.transitionTo()方法,transitonTo()方法并不是在HashHistory类里定义的,是通过继承History类而来的,所以得去看下base.js里的定义

transitionTo (
  location: RawLocation,
  onComplete?: Function,
  onAbort?: Function
) {
  let route
  route = this.router.match(location, this.current)
  ...
  this.confirmTransition(
    route,
    () => {
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()
      this.router.afterHooks.forEach(hook => {
        hook && hook(route, prev)
      })

      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => {
          cb(route)
        })
      }
    },
    ...
  )
}

先看参数:

  • location:目标信息对象,就是我们目标路由信息,我们经常调用路由的push方法就是传loaction对象,只不过这里是通过<router-link>触发push方法的。
  • onComplete: 将会在导航成功完成 (在所有的异步钩子被解析之后) 执行
  • onAbort: 终止 (导航到相同的路由、或在当前导航完成之前导航到另一个不同的路由) 的时候进行相应的调用

接下来首先通过locationthis.current(当前路由信息)来调用路由的match方法得出目标路由,所以这里看下match方法

function match (
 raw: RawLocation,
 currentRoute?: Route,
 redirectedFrom?: Location
): Route {
  const location = normalizeLocation(raw, currentRoute, false, router) // 格式化路由信息
  const { name } = location

  if (name) { // 通过name匹配
    const record = nameMap[name]
    ...
    location.path = fillParams(record.path, location.params, `named route "${name}"`)
    return _createRoute(record, location, redirectedFrom)
  } else if (location.path) { // 通过path匹配
    location.params = {}
    for (let i = 0; i < pathList.length; i++) {
      const path = pathList[i]
      const record = pathMap[path]
      if (matchRoute(record.regex, location.path, location.params)) {
        return _createRoute(record, location, redirectedFrom)
      }
    }
  }
  // no match
  return _createRoute(null, location)
}

可以看到,这里就是通过两种匹配方式来创建最终路由,主要是通过我们路由配置信息的name或者path,之所以能通过这两个匹配,是因为源码会将我们传为vue-router的所有路由配置信息转成由path/name组成的映射对象,匹配这里的_createRoute是用来统一创建路由的,让所有路由具有相同的属性、方法。

ok,继续看transitionTo方法,接下来调用了this.confirmTransiton方法,这里使用this.confirmTransiton的目的是:

  • 对于跳转同一个路由直接abort
  • 执行生命周期,因为在发生路由跳转的前后,我们需要进行执行对应的生命周期,所以这里会对执行进行编排,先执行路由跳转前的生命周期,再执行onComplete,接着执行路由跳转后的生命周期。(这里可以通过在组件内部打debugger来看调用栈)

接下来看看onComplete的逻辑:

route => {
  pushHash(route.fullPath)
  handleScroll(this.router, route, fromRoute, false)
  onComplete && onComplete(route)
}

onComplete就是一开始传进去的这段代码,这里我们看下pushHash就行

function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path
  }
}

很简单就是进行导航了hash的变更。

到了这里,导航拦的hash就变更成最终路由的hash了,但页面并没有更新,因为这块更新逻辑还未执行。

怎么让<router-view>更新?需要做到

  • 知道路由变更
  • 获取最终的路由信息,然后调用渲染函数

响应路由变更

可以通过监听$route对象。这个就是当前路由信息,因为我们是进行路由的修改,所以这个对象更新时,就是代表需要路由更新了,所以得先对其赋值并设置为响应式,这样他一改变,<router-view>就能收到通知,如下:

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)
    }
  }
})
Object.defineProperty(Vue.prototype, '$route', {
  get () { return this._routerRoot._route }
})

可以看到,是调用Vue的defineReactive来将_route设置为响应数据,_route就是指向当前的路由,而$route也就是指向_route。所以我们需要做两件事:

  • 更新_route,这样监听他的就会收到响应,执行更新逻辑
  • 订阅_route

更新_route

上面代码调用了init()函数,这个函数代码是VueRouter实例的一个方法,主要做如下事情:

init (app: any /* Vue component instance */) {
  ...
  app.$once('hook:destroyed', () => { // 监听根实例销毁,执行卸载逻辑
    // clean out app from this.apps array once destroyed
    const index = this.apps.indexOf(app)
    if (index > -1) this.apps.splice(index, 1)
    // ensure we still have a main app or null if no apps
    // we do not release the router so it can be reused
    if (this.app === app) this.app = this.apps[0] || null

    if (!this.app) this.history.teardown()
  })
  
  if (this.app) { // 避免重复注册
      return
  }
  ...
  history.listen(route => { // 这里就是更新_route的逻辑
    this.apps.forEach(app => {
      app._route = route
    })
  })
}

可以看到,更行_route的逻辑是通过history.listen触发,但这里的触发只是在路由初始化时触发,所以需要将传给history.listen的回调保存下来,在后续路由更新时进行调用,这里的history.listen调用的是Histor类listen方法

listen (cb: Function) {
  this.cb = cb
}

就是将回调保存在this.cb上,那么接下来就是路由更新时调用this.cb即可,路由的更新时发生在History里的transitionTo里的,这个我们上面分析过,再简单看下

transitionTo (
  ...
) {
  ...
    route = this.router.match(location, this.current) // 获取得到最新的路由信息
	...
  this.confirmTransition(
    route,
    () => {
      this.updateRoute(route)
      ...
    },

可以看到,我们先通过this.router.match获取得到最新路由信息,然后调用了this.updateRoute

updateRoute (route: Route) {
  this.current = route
  this.cb && this.cb(route)
}

updateRoute里调用了之前保存的cb,从而更新了_route,这样监听了_route<router-view>就能收到更新了,接下来看看做了啥:

  • 获取最行路由信息
  • 判断是否keep-alive:如果是keep-alive就去根节点的_routerViewCache里找是否有匹配的
  • 匹配路由组件:通过routematch函数匹配需要渲染的组件
  • 执行路由生命周期函数
  • 调用渲染函数并返回结果

至此,结束。