vue-router-analysis-part3

vue-router 源码解析 | 6k 字 | 多图预警 - 【下】

滚动处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

const router = new VueRouter({
routes: [...],
scrollBehavior (to, from, savedPosition) {
// return 期望滚动到哪个的位置
}
})

// 滚动到指定坐标
scrollBehavior (to, from, savedPosition) {
return { x: 0, y: 0 }
}


// 滚动到指定页面锚点
scrollBehavior (to, from, savedPosition) {
if (to.hash) {
return {
selector: to.hash
}
}
}

// v2.8.0+ 异步滚动
scrollBehavior (to, from, savedPosition) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ x: 0, y: 0 })
}, 500)
}
)
}
  • 既支持滚动到指定位置,也可以滚动页面某个页面锚点位置和异步滚动
  • 那它是如何做到呢?具体的逻辑又是怎样的呢?
  • 我们前面都知道HTML5History在初始化时和HashHistorysetupListener时都会调用setupScroll函数,初始化滚动相关的逻辑
  • 并在popstatehashchange事件触发路由跳转时,调用handleScroll处理滚动行为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/history/hash.js

 setupListeners () {
    const router = this.router
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll

    // 若支持scroll,初始化scroll相关逻辑
    if (supportsScroll) {
      setupScroll()
    }
    // 添加事件监听
    window.addEventListener(
      supportsPushState ? 'popstate' : 'hashchange'// 优先使用popstate
      () => {
        const current = this.current
        if (!ensureSlash()) {
          return
        }
        this.transitionTo(getHash(), route => {
          if (supportsScroll) {
            handleScroll(this.router, /* to*/route, /* from*/current, true)
          }
          // 不支持pushState,直接替换记录
          if (!supportsPushState) {
            replaceHash(route.fullPath)
          }
        })
      }
    )
  }
  • 我们先看滚动的初始化

setupScroll

  • 代码位于src/util/scroll.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 初始化滚动相关逻辑
export function setupScroll() {
// Fix for #1585 for Firefox
// Fix for #2195 Add optional third attribute to workaround a bug in safari https://bugs.webkit.org/show_bug.cgi?id=182678
// Fix for #2774 Support for apps loaded from Windows file shares not mapped to network drives: replaced location.origin with
// window.location.protocol + '//' + window.location.host
// location.host contains the port and location.hostname doesn't
const protocolAndPath = window.location.protocol + '//' + window.location.host
const absolutePath = window.location.href.replace(protocolAndPath, '') // preserve existing history state as it could be overriden by the user // 拷贝一份state,防止用户覆盖

const stateCopy = extend({}, window.history.state)
stateCopy.key = getStateKey() // 语法定义:history.replaceState(stateObj, title[, url]);

window.history.replaceState(stateCopy, '', absolutePath) // 监听popstate(只能通过浏览器的 前进/后退 按钮触发),保存滚动位置,更新stateKey

window.addEventListener('popstate', (e) => {
saveScrollPosition()
if (e.state && e.state.key) {
setStateKey(e.state.key)
}
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/util/state-key.js

// use User Timing api (if present) for more accurate key precision
const Time =
inBrowser && window.performance && window.performance.now
? window.performance
: Date

// 生成唯一key,用来在state中标识每个路由
export function genStateKey(): string {
return Time.now().toFixed(3)
}

let _key: string = genStateKey()
export function getStateKey() {
return _key
}

export function setStateKey(key: string) {
return (_key = key)
}
  • 可以看到声明了一个_key,其是一个三位的时间戳,更新和读取都是操作这一个_key
  • setupScroll时,首先拷贝了当前的state,并为其生成一个唯一key
  • 通过replaceState将添加了keystate保存到当前路由的absolutePath
  • 然后监听popstate事件,其只能通过浏览器的 前进/后退 按钮触发
  • 触发后会保存当前位置,并更新_key
  • 这样就可以在路由发生变化触发popstate时,保存当前位置并设置唯一_key
  • 看下其是如何存取位置信息的
1
2
3
4
5
6
7
8
9
10
11
12
// src/util/scroll.js

const positionStore = Object.create(null) // 保存页面滚动位置
export function saveScrollPosition() {
const key = getStateKey()
if (key) {
positionStore[key] = {
x: window.pageXOffset,
y: window.pageYOffset,
}
}
}
  • 其利用positionStore对象配合唯一的_key来存取位置
  • handleScroll时就可以通过_key取出之前保存的位置

handleScroll

  • 处理滚动的代码位于src/util/scroll.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

export function handleScroll (
  router: Router,
  to: Route,
  from: Route,
  isPop: boolean// 是否popstate,只有浏览器的 前进/后退 按钮才会触发,也只有popstate时,才会保存滚动位置
{

  if (!router.app) {
    return
  }
  const behavior = router.options.scrollBehavior
  if (!behavior) {
    return
  }
  if (process.env.NODE_ENV !== 'production') {
    assert(typeof behavior === 'function'`scrollBehavior must be a function`)
  }

  // wait until re-render finishes before scrolling
  // 重新渲染结束,再处理滚动
  router.app.$nextTick(() => {
    const position = getScrollPosition() // 获取之前保存的滚动位置

    // https://router.vuejs.org/zh/guide/advanced/scroll-behavior.html#%E6%BB%9A%E5%8A%A8%E8%A1%8C%E4%B8%BA
    const shouldScroll = behavior.call(
      router,
      to,
      from,
      isPop ? position : null // 第三个参数 savedPosition 当且仅当 popstate 导航 (通过浏览器的 前进/后退 按钮触发) 时才可用。,所以是popstate时,才有savedPosition
    )
    // 返回一个falsy值时,代表不需要滚动
    if (!shouldScroll) {
      return
    }

    // v.2.8.0支持异步滚动
    // https://router.vuejs.org/zh/guide/advanced/scroll-behavior.html#%E5%BC%82%E6%AD%A5%E6%BB%9A%E5%8A%A8
    if (typeof shouldScroll.then === 'function') {
      shouldScroll
        .then(shouldScroll => {
          scrollToPosition((shouldScroll: any), position)
        })
        .catch(err => {
          if (process.env.NODE_ENV !== 'production') {
            assert(false, err.toString())
          }
        })
    } else {
      scrollToPosition(shouldScroll, position)
    }
  })
}
  • $nextTick中调用getScrollPosition获取之前保存好的位置
  • 再调用我们传入的scrollBehavior查看其返回值来确定是否需要进行滚动
  • 还判断了一波是否是异步滚动
  • 若是,则等待其resolved再调用scrollToPosition
  • 否则直接调用scrollToPosition
  • getScrollPositionscrollToPosition代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/util/scroll.js

// 获取保存的滚动位置
function getScrollPosition(): ?Object {
const key = getStateKey() // 取唯一key
if (key) {
return positionStore[key] // 取位置
}
}

// 滚动到指定位置,支持滚动到特定元素
// https://router.vuejs.org/zh/guide/advanced/scroll-behavior.html#%E6%BB%9A%E5%8A%A8%E8%A1%8C%E4%B8%BA
function scrollToPosition(shouldScroll, position) {
const isObject = typeof shouldScroll === 'object' // 滚动到特定dom

if (isObject && typeof shouldScroll.selector === 'string') {
// getElementById would still fail if the selector contains a more complicated query like #main[data-attr]
// but at the same time, it doesn't make much sense to select an element with an id and an extra selector
const el = hashStartsWithNumberRE.test(shouldScroll.selector) // $flow-disable-line
? document.getElementById(shouldScroll.selector.slice(1)) // $flow-disable-line
: document.querySelector(shouldScroll.selector)
if (el) {
let offset =
shouldScroll.offset && typeof shouldScroll.offset === 'object'
? shouldScroll.offset
: {}
offset = normalizeOffset(offset)
position = getElementPosition(el, offset)
} else if (isValidPosition(shouldScroll)) {
position = normalizePosition(shouldScroll)
}
} else if (isObject && isValidPosition(shouldScroll)) {
// 直接滚动到指定位置
position = normalizePosition(shouldScroll)
}
if (position) {
window.scrollTo(position.x, position.y)
}
}
  • 获取滚动位置,是利用_keypositionStore上读取之前保存的位置信息
  • scrollToPosition的逻辑很清晰,其处理了滚动到指定dom和直接滚动到特定位置的场景

小结

  • vue-router处理滚动主要利用了History API可以保存状态的特性实现
  • 在路由进入前保存滚动位置,并在下次路由变化时,尝试取回之前位置,在$nextTick中真正的处理滚动
  • 其支持滚动到指定位置、指定 DOM、异步滚动等场景

view 组件

  • vue-router内置了router-viewrouter-link两个组件
  • 前者负责在匹配到路由记录后将对应路由组件渲染出来
  • 后者支持用户在具有路由功能的应用中 (点击) 导航
  • 我们先来看router-view组件

router-view

  • router-view的主要职责就是将路由组件渲染出来
  • 定义位于src/components/view.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// src/components/view.js

export default {
name: 'RouterView',
functional: true, // 函数式组件,没有thishttps://cn.vuejs.org/v2/guide/render-function.html#函数式组件
props: {
name: {
type: String,
default: 'default',
},
}, // _为h即createElement,但router-view没有使用自身的h,而是使用了父节点的h
render(/* h*/ _, /* context*/ { props, children, parent, data }) {
// used by devtools to display a router-view badge
data.routerView = true // 标识当前组件为router-view // 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 // 依赖父节点的$route,而在install.js中我们知道,所有组件访问到的$route其实都是_routerRoot._route,即Vue根实例上的_route;当路由被确认后,调用updateRoute时,会更新_routerRoot._route,进而导致router-view组件重新渲染 // 缓存
const cache = parent._routerViewCache || (parent._routerViewCache = {}) // determine current view depth, also check to see if the tree // has been toggled inactive but kept-alive.

let depth = 0 // 当前router-view嵌套深度
let inactive = false // 是否被keep-alive包裹并处于非激活状态 // 向上查找,计算depth、inactive // 当parent指向Vue根实例结束循环
while (parent && parent._routerRoot !== parent) {
const vnodeData = parent.$vnode ? parent.$vnode.data : {}
if (vnodeData.routerView) {
depth++
} // 处理keep-alive // keep-alive组件会添加keepAlive=true标识 // https://github.com/vuejs/vue/blob/52719ccab8fccffbdf497b96d3731dc86f04c1ce/src/core/components/keep-alive.js#L120

if (vnodeData.keepAlive && parent._directInactive && parent._inactive) {
inactive = true
}
parent = parent.$parent
}

data.routerViewDepth = depth // render previous view if the tree is inactive and kept-alive // 如果当前组件树被keep-alive包裹,且处于非激活状态,则渲染之前保存的视图

if (inactive) {
const cachedData = cache[name]
const cachedComponent = cachedData && cachedData.component // 找到缓存的组件
if (cachedComponent) {
// #2301
// pass props
// 传递缓存的props
if (cachedData.configProps) {
fillPropsinData(
cachedComponent,
data,
cachedData.route,
cachedData.configProps
)
}
return h(cachedComponent, data, children)
} else {
// 未找到缓存的组件
// render previous empty view
return h()
}
} // 通过depth获取匹配的route record // 由于formatMatch是通过unshift添加父route record的 // 所以route.matched[depth]正好能取到匹配的route record

const matched = route.matched[depth]
const component = matched && matched.components[name] // 取出路由组件 // render empty node if no matched route or no config component // 找不到,渲染空组件
if (!matched || !component) {
cache[name] = null
return h()
} // cache component // 缓存组件

cache[name] = { component } // attach instance registration hook // this will be called in the instance's injected lifecycle hooks // 为路由记录绑定路由组件,在所有组件的beforeCreate、destoryed hook中调用,见install.js中的registerInstance方法 // 此方法只在router-view上定义了 // vm,val都为路由组件实例 // 如下 // matched.instances:{ //   default:VueComp, //   hd:VueComp2, //   bd:VueComp3 // }

data.registerRouteInstance = (vm, val) => {
// val could be undefined for unregistration
const current = matched.instances[name]
if (
(val && current !== vm) || // 绑定
(!val && current === vm)
) {
// 若val不存在,则可视为解绑
matched.instances[name] = val
}
} // also register instance in prepatch hook // in case the same component instance is reused across different routes // 当相同组件在不同路由间复用时,也需要为router-view绑定路由组件
;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
matched.instances[name] = vnode.componentInstance
} // register instance in init hook // in case kept-alive component be actived when routes changed // keep-alive组件被激活时,需要为router-view注册路由组件

data.hook.init = (vnode) => {
if (
vnode.data.keepAlive &&
vnode.componentInstance &&
vnode.componentInstance !== matched.instances[name]
) {
matched.instances[name] = vnode.componentInstance
}
} // route record设置了路由传参;动态路由传参;https://router.vuejs.org/zh/guide/essentials/passing-props.

const configProps = matched.props && matched.props[name] // save route and configProps in cachce // 如果设置了路由传参,则缓存起来,并将填充props

if (configProps) {
extend(cache[name], {
route,
configProps,
})
fillPropsinData(component, data, route, configProps)
}
return h(component, data, children)
},
}
  • 其被定义成一个函数式组件,这代表它没有状态和实例(this 上下文),只接收了name来做命名视图
  • 我们重点看下render方法
  • 由于其是一个函数式组件,所以很多操作是借助父节点来完成的
    • 为了支持解析命名插槽,其没有使用自己的createElement方法,而是使用父节点的createElement方法
    • 由于没有 this 上下文,无法通过this.$route获得当前路由对象,干脆就直接使用父节点的$route
  • 可以看到添加了一个标志量routerView,主要用来在vue-devtools中标识view组件和在查找深度时用
  • 然后声明了一个缓存对象_routerViewCache并赋值给cache变量,用来在keep-alive激活时快速取出被缓存的路由组件
  • 开始从当前节点往上查找Vue根实例,在查找的过程中计算出view组件的深度以及是否被kepp-alive包裹并处于inative状态
  • depth主要用来获取当前view对应的路由记录
    • 前面说过,vue-router是支持嵌套路由的,对应的view也是可以嵌套的
    • 而且在匹配路由记录时,有下面的逻辑,当一个路由记录匹配了,如果其还有父路由记录,则父路由记录肯定也是匹配的,其会一直向上查找,找到一个父记录,就通过unshift塞入route.matched数组中的,所以父记录肯定在前,子记录在后,当前精准匹配的记录在最后
      • src/util/route.js formatMatch方法
    • depth的计算在遇到父view组件时,自增 1,通过不断向上查找,不断自增depth,直到找到Vue根实例才停止
    • 停止时route.matched[depth]值就是当前view对应的路由记录
    • 有了路由记录,我们就可以从上取出对应的路由组件实例,然后渲染即可
      • 关于路由记录和路由组件实例是如何绑定的,我们下面会讲
  • 我们先看非inactive状态是如何渲染路由组件实例的
    • 通过route.matched[depth]取出当前view匹配的路由记录
    • 然后再取出对应的路由组件实例
    • 如果路由记录和路由组件实例有一个不存在,则渲染空结点,并重置cache[name]
    • 如果都能找到,则先把组件实例缓存下来
      • 如果有配置动态路由参数,则把路由参数缓存到路由组件实例上,并调用fillPropsinData填充props
    • 调用h渲染对应的路由组件实例即可
  • 当组件处于inactive状态时,我们就可以从cache中取出之前缓存的路由组件实例和路由参数,然后渲染就可以了
  • 主流程如上,但还有一个重要的点没提
    • 路由记录和路由组件实例是如何绑定的?
    • 相信你已经注意到data.registerRouteInstance方法,没错,他就是用来为路由记录绑定路由组件实例的

registerInstance

  • 我们先看下调用的地方
  • 主要在src/install.js的全局混入中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function install(Vue){
...

// 注册全局混入
  Vue.mixin({
    beforeCreate () {
... 

      // 为router-view组件关联路由组件
      registerInstance(thisthis)
    },
    destroyed () {
      // destroyed hook触发时,取消router-view和路由组件的关联
      registerInstance(this)
    }
  })
}
  • 可以看到其在全局混入的beforeCreatedestroyed钩子中都有被调用
  • 前者传入了两个 vm 实例,后者只传入了一个 vm 实例
  • 我们看下实现,代码也位于src/install.js
1
2
3
4
5
6
7
// 为路由记录、router-view关联路由组件
const registerInstance = (vm, callVal) => {
let i = vm.$options._parentVnode // 调用vm.$options._parentVnode.data.registerRouteInstance方法 // 而这个方法只在router-view组件中存在,router-view组件定义在(../components/view.js @71行) // 所以,如果vm的父节点为router-view,则为router-view关联当前vm,即将当前vm做为router-view的路由组件
if (isDef(i) && isDef((i = i.data)) && isDef((i = i.registerRouteInstance))) {
i(vm, callVal)
}
}
  • 可以看到其接收一个vm实例callVal做为入参
  • 然后取了vm的父节点做为 i 的初值
  • 接着一步一步给i赋值,同时判断i是否定义
  • 到最后,i的值为vm.$options._parentVnode.data.registerRouteInstance
  • 然后将两个入参传入i中调用
  • 注意,这时的 i 是 vm 父节点上的方法,并不是 vm 上的方法
  • 我们全局检索下registerRouteInstance关键字,发现其只被定义在了view.js中,也就是router-view组件中
    • 结合上面一条,i 即registerRouteInstancevm父节点上的方法,而只有router-view组件定义了registerRouteInstance
    • 所以,只有当vmrouter-view的子节点时,registerRouteInstance方法才会被调用
    • i(vm, callVal)可以表达为vm._parentVnode.registerRouteInstance(vm,vm)
  • 看下registerRouteInstance的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/components/view.js
...

// 为路由记录绑定路由组件,在所有组件的beforeCreate、destoryed hook中调用,见install.js中的registerInstance方法
    // 此方法只在router-view上定义了
    // vm,val都为路由组件实例
    // 如下
    // matched.instances:{
    //   default:VueComp,
    //   hd:VueComp2,
    //   bd:VueComp3
    // }
    data.registerRouteInstance = (vm, val) => {
      // val could be undefined for unregistration
      const current = matched.instances[name]
      if (
        (val && current !== vm) || // 绑定
        (!val && current === vm) // 若val不存在,则可视为解绑
      ) {
        matched.instances[name] = val
      }
    }
  • matched保存的是当前匹配到的路由记录,name是命名视图名
  • 如果val存在,并且当前路由组件和传入的不同,重新赋值
  • 如果val不存在,且当前路由组件和传入的相同,也重新赋值,但是此时 val 为undefined,相当于解绑
  • 可以看到参数数量不同,一个函数实现了绑定和解绑的双重操作
  • 通过这个方法就完成了路由记录和路由组件实例的绑定与解绑操作
  • 这样就可以在view组件render时,通过route.matched[depth].components[name]取到路由组件进行渲染
  • 还有些场景也需要进行绑定
    • 当相同组件在不同路由间复用时,需要为路由记录绑定路由组件
    • keep-alive组件被激活时,需要为路由记录绑定路由组件

小结

  • router-view是一个函数式组件,有时需要借助父节点的能力,例如使用父节点的渲染函数来解析命名插槽
  • 通过routerView来标识view组件,方便vue-devtools识别出view组件和确定view组件深度
  • 通过向上查找,确定当前view的深度depth,通过depth取到对应的路由记录
  • 再取出通过registerInstance绑定的路由组件实例
  • 如果有动态路由参数,则先填充props然后再渲染
  • 如果viewkeep-alive包裹并且处于inactive状态,则从缓存中取出路由组件实例并渲染

如何触发重新渲染

  • 在导航解析的章节,我们提过,导航解析成功后
  • 会调用updateRoute方法,重新为全局的_routerRoot._route$route赋值
1
2
3
4
5
6
7
8
9
10
// src/history/base.js

// 更新路由,触发afterEach钩子
  updateRoute (route: Route) {
    const prev = this.current
    this.current = route// 更新current

    this.cb && this.cb(route) // 调用updateRoute回调,回调中会重新为_routerRoot._route赋值,进而触发router-view的重新渲染
...
  }
  • view组件中,会使用$parent.$route即全局的_routerRoot._route
1
2
3
4
5
6
7
8
9
10
 // src/components/view.js

...
  render (/* h*/_, /* context*/{ props, children, parent, data }) {
...

const route = parent.$route // 依赖父节点的$route,而在install.js中我们知道,所有组件访问到的$route其实都是_routerRoot._route,即Vue根实例上的_route;当路由被确认后,调用updateRoute时,会更新_routerRoot._route,进而导致router-view组件重新渲染

...
}
  • 而在install.js的全局混入中,将_route定义为响应式的,依赖了_route的地方,在_route发生变化时,都会重新渲染
1
2
3
4
5
6
7
8
9
10
// src/install.js

  // 注册全局混入
  Vue.mixin({
    beforeCreate () {
     ...
        // 响应式定义_route属性,保证_route发生变化时,组件(router-view)会重新渲染
        Vue.util.defineReactive(this'_route'this._router.history.current)
      }
  })
  • 这样就完成了渲染的闭环,view依赖$route,导航解析成功更新$route,触发view渲染
  • 看完了view组件,我们来看下另外一个组件router-link
  • router-link组件被定义在src/components/link.js
  • 主要用来支持用户在具有路由功能的应用中 (点击) 导航
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
/* @flow */
import { createRoute, isSameRoute, isIncludedRoute } from '../util/route'
import { extend } from '../util/misc'
import { normalizeLocation } from '../util/location'
import { warn } from '../util/warn'

// work around weird flow bug
const toTypes: Array<Function> = [String, Object]
const eventTypes: Array<Function> = [String, Array]
const noop = () => {}

export default {
name: 'RouterLink',
props: {
to: {
type: toTypes, // string | Location
required: true,
},
tag: {
type: String,
default: 'a', // 默认a标签
},
exact: Boolean, // 是否精确匹配
append: Boolean, // 是否追加
replace: Boolean, // 为true,调用router.replace否则调用router.push
activeClass: String, // 激活的类名
exactActiveClass: String, // 精确匹配的类名
ariaCurrentValue: {
// 无障碍化
type: String,
default: 'page',
},
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 classes = {}
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
const activeClass =
this.activeClass == null ? activeClassFallback : this.activeClass
const exactActiveClass =
this.exactActiveClass == null
? exactActiveClassFallback
: this.exactActiveClass // 目标route,用来比较是否和当前route是相同route

const compareTarget = route.redirectedFrom
? createRoute(null, normalizeLocation(route.redirectedFrom), null, router)
: route
classes[exactActiveClass] = isSameRoute(current, compareTarget)
classes[activeClass] = this.exact
? classes[exactActiveClass]
: isIncludedRoute(current, compareTarget) // 非精准匹配时,判断目标route path是否包含当前route path
const ariaCurrentValue = classes[exactActiveClass]
? this.ariaCurrentValue
: null // 事件处理

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)) {
this.event.forEach((e) => {
on[e] = handler
})
} else {
on[this.event] = handler
}
const data: any = { class: classes } // 读取作用域插槽

const scopedSlot =
!this.$scopedSlots.$hasNormal &&
this.$scopedSlots.default &&
this.$scopedSlots.default({
href,
route,
navigate: handler,
isActive: classes[activeClass],
isExactActive: classes[exactActiveClass],
})

if (scopedSlot) {
// 作用域插槽仅有一个子元素
if (scopedSlot.length === 1) {
return scopedSlot[0]
} else if (scopedSlot.length > 1 || !scopedSlot.length) {
// 作用域插槽提供多个后代或未提供后,给予提示
if (process.env.NODE_ENV !== 'production') {
warn(
false,
`RouterLink with to="${this.to}" is trying to use a scoped slot but it didn't provide exactly one child. Wrapping the content with a span element.`
)
} // 有多个后代时,在外层用一个span包裹
return scopedSlot.length === 0 ? h() : h('span', {}, scopedSlot)
}
} // tag为a

if (this.tag === 'a') {
data.on = on
data.attrs = { href, 'aria-current': ariaCurrentValue }
} else {
// tag不为a,则找后代首个a绑定事件
// find the first <a> child and apply listener and href

const a = findAnchor(this.$slots.default)
if (a) {
// in case the <a> is a static node
a.isStatic = false
const aData = (a.data = extend({}, a.data))
aData.on = aData.on || {} // transform existing events in both objects into arrays so we can push later // a上可能还绑定有其他事件,需要兼容
for (const event in aData.on) {
const handler = aData.on[event]
if (event in on) {
aData.on[event] = Array.isArray(handler) ? handler : [handler]
}
} // append new listeners for router-link // 绑定其他事件处理器
for (const event in on) {
if (event in aData.on) {
// on[event] is always a function
aData.on[event].push(on[event])
} else {
aData.on[event] = handler
}
}
const aAttrs = (a.data.attrs = extend({}, a.data.attrs))
aAttrs.href = href
aAttrs['aria-current'] = ariaCurrentValue
} else {
// doesn't have <a> child, apply listener to self
// 没找到,则给当前元素绑定事件
data.on = on
}
}

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

// 特殊场景,点击不做跳转响应
function guardEvent(e) {
// don't redirect with control keys
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return // don't redirect when preventDefault called
if (e.defaultPrevented) return // don't redirect on right click
if (e.button !== undefined && e.button !== 0) return // don't redirect if `target="_blank"`
if (e.currentTarget && e.currentTarget.getAttribute) {
const target = e.currentTarget.getAttribute('target')
if (/\b_blank\b/i.test(target)) return
} // this may be a Weex event which doesn't have this method
if (e.preventDefault) {
e.preventDefault()
}
return true
}

// 递归查找后代a标签
function findAnchor(children) {
if (children) {
let child
for (let i = 0; i < children.length; i++) {
child = children[i]
if (child.tag === 'a') {
return child
}
if (child.children && (child = findAnchor(child.children))) {
return child
}
}
}
}
  • 其实现就是一个普通的组件,实现了点击时跳转到to对应的路由功能
  • 由于支持点击时需要标识样式类、精准匹配exact场景,所以通过sameRouteisIncludedRoute来实现样式类的标识和精准匹配标识
  • 在点击时,屏蔽了部分特殊场景,如点击时同时按下ctrlaltshiftcontrol keys时,不做跳转
  • 看完组件后,我们再来看看router还给我们提供哪些实例方法

实例属性、方法

  • router对外暴露了很多属性和方法
  • 这些属性和方法在前面的源码部分也都有用过

实例属性

  • router.app
    • 配置了 router 的 Vue 根实例
  • router.mode
    • 路由使用的模式
  • router.currentRoute
    • 当前路由对象,等同于this.$route

实例方法

  • 用注册全局导航守卫
    • router.beforeEach
    • router.beforeResolve
    • router.afterEach
  • 编程式导航相关
    • router.push
    • router.replace
    • router.go
    • router.back
    • router.forward
  • 服务端渲染相关
    • router.getMatchedComponents
      • 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)
    • router.onReady
      • 该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件
    • router.onError
      • 注册一个回调,该回调会在路由导航过程中出错时被调用
  • 动态路由
    • router.addRoutes
      • 动态添加路由规则
  • 解析
    • router.resolve - 传入一个对象,尝试解析并返回一个目标位置

总结

  • 至此,我们完成了vue-router@2的所有源码分析 🎉
  • 如果您觉得还可以,记得帮我点个赞 👍

参考

PS

npm 包

交流

  • 如果有问题,可以加微信交流,共同成长,共同进步~