Vue源码阅读笔记之路由管理(六)
我们可以通过Vue
和Vue-router
来构建单页面应用。在传统的web项目里面,往往存在多个页面映射不同的功能,而在单页面应用中,不同的页面对应的是不同的组件。
在之前的博客同构渲染实践和history与单页面应用路由中,我尝试实现了单页面的路由管理,不过十分简陋,现在让我们来看看Vue-router的实现原理。
参考:
前言
vue-router一个单独的项目,然后作为插件与vue进行绑定的,这赋予了开发者更大的选择自由。
跟分析Vue一样,克隆项目,安装依赖,然后从package.json
的npm run dev
脚本入手,该命令会通过express开启一个静态服务器,返回examples
下相关的静态页面。examples
目录下包含了相关的API使用方法,这对于源码分析很有帮助,基本上就不用我们自己写Demo了
在传统的后台MVC框架中,通过URL路由映射到对应的控制器方法,然后执行相关逻辑,最后展示视图。路由的声明又可以分为:
- 显式声明,手动指定URL和对应的控制器方法,如Laravel、Express等
- 隐式声明,利用语言的自动加载机制,将URL映射到对应目录路径下的文件控制器方法,如ThinkPHP等
那么,单页面应用中的路由该是什么样子的呢?首先,我们肯定需要关联路由和需要展示的视图;其次,我们需要模拟浏览器前进、后退等操作。
回想一下,Vue-router的使用方式十分简单,
- 声明对应的页面组件,
- 通过组件构造
routes
数组,通过routes
和其他配置参数实例化router
对象 - 为Vue实例传入对应的
router
配置参数,在模板中通过router-view
和router-link
实现组件的跳转
下面使用了examples中的一个例子,展示了一个基础的router对象实例化过程
// /examples/basic/app.js
// 注册插件
Vue.use(VueRouter)
// 定义组件
const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }
// 关联url和对应组件,组成构造参数,实例化router对象
const router = new VueRouter({
mode: 'history',
base: __dirname,
routes: [
{ path: '/', component: Home },
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar }
]
})
// 将router作为配置参数,获得vm实例
new Vue({
router,
template: `
<div id="app">
<h1>Basic</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" :event="['mousedown', 'touchstart']">
<a>/bar</a>
</router-link>
</ul>
<router-view class="view"></router-view>
</div>
`
}).$mount('#app')
通过上面这个流程,需要弄明白的一些问题有:
- VueRouter插件注册及
router-view
和router-link
组件 - 配置参数
mode
和routes
的处理
- router对象的属性和方法
按照上面demo程序执行的流程,我们一步一步进行分析。
插件安装
插件注册是在install方法中进行的,
// /src/install.js
export function install (Vue) {
// 全局混合,注册_router属性
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
this._routerRoot = this
// 设置this._router
this._router = this.$options.router
// 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)
}
})
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
// 注册router-view和router-link全局组件
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
}
可见在install主要就是为vm实例注入了_router对象,然后执行对应router的init方法。我们先来看看router对象的构造函数
VueRouter构造函数
在前面的例子中,首先需要初始化路由构造参数,包括url和与之对应的组件,然后传入VueRouter构造函数实例一个router对象。我们来看看构造函数中是如何处理这些参数的
// /src/index.js
export default class VueRouter {
constructor (options: RouterOptions = {}) {
// ...初始化相关属性
this.options = options
// 关联声明的路由
this.matcher = createMatcher(options.routes || [], this)
let mode = options.mode || 'hash'
// ...根据配置参数运行环境确定mode类型
this.mode = mode
// 根据mode实例化对应的history对象
// 我们上面选择的是history,因此暂时只需要了解HTML5History即可
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}`)
}
}
}
}
// 实现插件的注册函数
VueRouter.install = install
// 在浏览器中自动注册,这是Vue插件的基本实现形式
if (inBrowser && window.Vue) {
window.Vue.use(VueRouter)
}
VueRouter构造函数内只做了两件事:初始化this.matcher
和初始化this.history
,我们暂时不要去深入这两个属性的作用,只需要知道他们在构造函数中被初始化即可。
路由匹配matcher
所谓路由,最基本的作用就是通过给定的url获得对应的视图内容,这就是this.matcher
需要完成的工作。我们来看看matcher
匹配器的初始化过程
// /src/create-matcher.js
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
// pathList是路由路径的数组,["", "/foo", "/bar"]
// pathMap是一个以路径为键值的对象,对应每个路径相关的配置,这种形式被称为RouteRecord
// nameMap是一个以路由名称为键值的对象,与pathMap对应路径指向的路由配置相同
const { pathList, pathMap, nameMap } = createRouteMap(routes)
// ...手动添加路由的接口,会更新pathList、pathMap和nameMap
function addRoutes (routes){}
// 传入url匹配对应的路由对象,路由对象是根据路由记录初始化的
function match (
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {}
// ...一些其他辅助函数
// mather对象上包含这两个方法
return {
match,
addRoutes
}
}
可以看见,createMatcher
主要的作用就是通过options.routes
,将对应的路由解析成字典,然后返回match
和addRoutes
两个闭包函数
RouteRecord
在match函数的返回值中提到了路由对象和路由记录的概念,路由对象我们都比较熟悉了,即vm.$route
,那么路由记录是什么呢?我们先看看pathMap的形式
// 转换前的route形式
{
path: '/foo',
component: Foo
}
// pathMap的形式,这里只展示了部分路由
// 路径作为属性值,用于后面的路由匹配
{
"/foo": {
path: "/foo",
regex: { keys: [] },
components: { default: { template: "<div>foo</div>" } },
instances: {},
meta: {},
props: {}
},
}
可见pathMap即以url为键名,一个保存路由相关信息的对象为键值的对象。将配置参数的route转换成pathMap
形式的路由配置是在addRouteRecord
中进行的
// /src/create-route-map.js
function addRouteRecord (
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteCaddRouteRecordonfig, // 这里就是配置参数中的route选项
parent?: RouteRecord,
matchAs?: string
) {
// ...
// 路由记录就是一个用来保存每个url的相关信息,包括正则、组件、命名等的对象
const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props: route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}
// 递归为子路由生成RouteRecord
if (route.children) {
route.children.forEach(child => {}
}
// 生成pathMap
if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path] = record
}
}
route相关配置参数可以参考官方文档,上面展示了从配置参数到RouteRecord
的转换过程。其中,regex
值的生成使用了path-to-regexp这个库(对于反向生成正则我一直十分好奇~)。
上面的过程也比较清晰,解析options.routes
,然后生成对应URL的RouteRecord
并保存在pathMap
中
const { pathList, pathMap, nameMap } = createRouteMap(routes)
name选项并不是必须的,如果设置了该属性,则对应的路由会变成命名路由,所有的命名路由会解析到nameMap
中,通常地,使用命名路由的查找效率要快一些,因为在match中对pathMap
和nameMap
有些差异。
match
// /src/create-matcher.js
function match(
raw: RawLocation, // 目标url
currentRoute?: Route, // 当前url对应的route对象
redirectedFrom?: Location // 重定向
): Route {
// 从url中提取hash、path、query和name等信息
const location = normalizeLocation(raw, currentRoute, false, router);
const { name } = location;
if (name) {
// 处理命名路由
const record = nameMap[name];
const paramNames = record.regex.keys
.filter(key => !key.optional)
.map(key => key.name);
if (currentRoute && typeof currentRoute.params === "object") {
for (const key in currentRoute.params) {
if (!(key in location.params) && paramNames.indexOf(key) > -1) {
location.params[key] = currentRoute.params[key];
}
}
}
if (record) {
location.path = fillParams(
record.path,
location.params,
`named route "${name}"`
);
return _createRoute(record, location, redirectedFrom);
}
} else if (location.path) {
// 处理非命名路由
location.params = {};
// 这里会遍历pathList,找到合适的record,因此命名路由的record查找效率更高
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);
}
在match中,主要通过当前URL确定对应的RouteRecord
路由记录,然后调用_createRoute
,并返回当前url对应的route对象。
_createRoute
在_createRoute
中会根据RouteRecord
执行相关的路由操作,最后返回Route对象
function _createRoute (
record: ?RouteRecord,
location: Location,
redirectedFrom?: Location
): Route {
// 重定向
if (record && record.redirect) {
return redirect(record, redirectedFrom || location)
}
// 别名
if (record && record.matchAs) {
return alias(record, location, record.matchAs)
}
// 普通路由
return createRoute(record, location, redirectedFrom, router)
}
这里是重定向和别名路由的文档传送门。
现在我们知道了this.mather.match
最终返回的就是Route
对象,下面是一个Route对象包含的相关属性
// /src/util/route.js
export function createRoute (
record: ?RouteRecord,
location: Location,
redirectedFrom?: ?Location,
router?: VueRouter
): Route {
// ...
// 终于看到了路由记录对象的真面目,每个url都对应一个路由记录,
// 方便后续的根据url匹配对应的组件
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) : []
}
// 禁止route对象改变
return Object.freeze(route)
}
至此,我们理清了从url生成对应route的过程。那么,match方法在何处调用呢?
我现在的猜想是:如果路由发生了改变,比如点击了一个连接,就需要根据目标url,匹配到对应的路由记录,重新生成新的route对象,然后加载对应的组件。
路由初始化
在上面的install函数中,我们了解到VueRouter内部注册了一个全局混合,在beforeCreate
时会调用router.init
方法
init (app: any /* Vue component instance */) {
this.apps.push(app)
if (this.app) {
return
}
this.app = app
// 在构造函数中初始化history属性
const history = this.history
// 在下面这两种模式存在进入的不是默认页的情形,因此需要调用transitionTo
if (history instanceof HTML5History) {
history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {
// 绑定hashChange事件,在下面的路由变化章节会解释
const setupHashListener = () => {
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),
setupHashListener,
setupHashListener
)
}
// listen中初始化history.cb = cb
history.listen(route => {
this.apps.forEach((app) => {
// 为vm实例注册_route属性
app._route = route
})
})
}
可以看见,init函数内部主要调用了history.transitionTo
和history.listen
这两个方法,在上面的构造函数中我们知道了
this.history = new HTML5History(this, options.base)
接下来就去了解history对象。
history对象
在VueRouter的构造函数中,我们发现,router.history
实际上是根据mode
实例化不同的History对象,三种history对象都继承自History基类。
我们知道init
方法在history
和hash
模式下,会手动调用一次transitionTo
,这是因为这两种模式(浏览器模式)存在进入的不是默认页的情形。
transitionTo
在transitionTo中匹配目标rul的route对象,然后调用confirmTransition
// src/history/base.js
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
// 匹配目标url的route对象
const route = this.router.match(location, this.current)
// 调用this.confirmTransition,执行路由转换
this.confirmTransition(route, () => {
// ...跳转完成
this.updateRoute(route)
onComplete && onComplete(route)
this.ensureURL()
// fire ready cbs once
if (!this.ready) {
this.ready = true
this.readyCbs.forEach(cb => { cb(route) })
}
}, err => {
// ...处理异常
})
}
}
confirmTransition
confirmTransition主要作用是处理链接跳转过程中相关逻辑
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
const current = this.current
const abort = err => {
// ... 实现abort函数
}
const queue: Array<?NavigationGuard> = [].concat(
// ...保存需要处理的逻辑队列,
)
this.pending = route
// 迭代函数
const iterator = (hook: NavigationGuard, next) => {
if (this.pending !== route) {
return abort()
}
try {
hook(route, current, (to: any) => {
// ...
next(to)
})
} catch (e) {
abort(e)
}
}
// 依次执行队列
runQueue(queue, iterator, () => {
const postEnterCbs = []
const isValid = () => this.current === route
// 处理异步组件
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() })
})
}
})
})
其中需要注意的是这个runQueue
函数,他会依次将队列中的元素传入迭代器中执行,最后执行回调函数。
// /src/util/async.js
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
const step = index => {
if (index >= queue.length) {
cb()
} else {
if (queue[index]) {
fn(queue[index], () => {
step(index + 1)
})
} else {
step(index + 1)
}
}
}
step(0)
}
上面整理了transitionTo
的工作原理,其主要任务就是跳转到对应的url上,然后加载对应的视图,并触发相应的钩子函数。
路由变化
除了在init方法中会手动调用transitionTo
之外,其他的调用应当是是通过注册url发生变化时的事件处理函数进行的,其中,
- 点击链接会触发url的变化
- 浏览器后退也会触发url的变化
- 手动调用路由接口,也会触发url的变化
router-link
现在让我们来看看router-link
组件的实现,组件的相关属性可参考官方文档
export default {
name: 'RouterLink',
props: {}, // ...相关属性
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 => {
// guardEvent函数用来判断当前路由跳转是否是有效的
if (guardEvent(e)) {
// 替换路由或新增路由
if (this.replace) {
router.replace(location)
} else {
router.push(location)
}
}
}
const on = { click: guardEvent }
// 相关的事件都会触发handler
if (Array.isArray(this.event)) {
this.event.forEach(e => { on[e] = handler })
} else {
on[this.event] = handler
}
const data: any = {
class: classes
}
// ... 对渲染的标签进行处理
data.on = on
// 执行渲染函数
return h(this.tag, data, this.$slots.default)
}
}
可见在点击(或者是其他指定的事件)router-link组件时,会调用router.push
或router.replace
方法
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.push(location, onComplete, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.replace(location, onComplete, onAbort)
}
在不同的模式下,浏览器对应的事件是不一样的,这也是为什么会实例化不同的history的原因。接下来看看浏览器环境下相关事件与transitionTo
是如何关联起来的
HTML5History
在HTML5History的构造函数中,会监听popstate
事件,关于HTML5提供的history API,可以参看MDN文档,其中
push
是由history.pushState
实现的replace
是由history.replaceState
实现的- 浏览器后退由监听
popstate
事件实现的
export class HTML5History extends History {
constructor (router: Router, base: ?string) {
// 父类构造函数
super(router, base)
// 滚动处理
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
const initLocation = getLocation(this.base)
// 监听浏览器的后退事件变化,执行transitionTo
window.addEventListener('popstate', e => {
const current = this.current
const location = getLocation(this.base)
if (this.current === START && location === initLocation) {
return
}
this.transitionTo(location, route => {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})
}
// 实现
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
pushState(cleanPath(this.base + route.fullPath))
// ...
}, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
replaceState(cleanPath(this.base + route.fullPath))
// ...
}, onAbort)
}
}
HashHistory
hashChange
事件可查看MDN文档
export class HashHistory extends History {
constructor (router: Router, base: ?string, fallback: boolean) {
super(router, base)
// check history fallback deeplinking
if (fallback && checkFallback(this.base)) {
return
}
ensureSlash()
}
// setupListeners在router.init中才会被调用,防止过早触发
setupListeners () {
const router = this.router
// ... 处理滚动
// 监听url变化,如果不支持popstate则会监听hashchange事件
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)
}
})
})
}
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
pushHash(route.fullPath)
// ...
}, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
replaceHash(route.fullPath)
// ...
}, onAbort)
}
}
OK,现在我们知道了路由变化相关处理函数的注册过程。大体来说,就是根据选择的模式,底层调用浏览器不同的接口来实现的。
视图变化
当路由改变时,对应的router-view会加载相关的视图,我们来看看这部分是如何处理的
router-view
router-view
相关的属性和使用方法可以参考官方文档
export default {
name: 'RouterView',
functional: true,
props: {
name: {
type: String,
default: 'default'
}
},
render (_, { props, children, parent, data }) {
data.routerView = true
const h = parent.$createElement
const name = props.name
// 目标url的route对象
const route = parent.$route
const cache = parent._routerViewCache || (parent._routerViewCache = {})
// ...处理data.routerViewDepth
// render previous view if the tree is inactive and kept-alive
if (inactive) {
return h(cache[name], data, children)
}
const matched = route.matched[depth]
// 找到name对应的组件
const component = cache[name] = matched.components[name]
// ...关联registration钩子
// 注册data.hook.prepatch钩子函数
;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
matched.instances[name] = vnode.componentInstance
}
// ...处理props
// 渲染组件
return h(component, data, children)
}
}
可以看见,当route发生变化时,其内部会渲染目标url所匹配的路由记录保存的组件,这就是上面在router的配置函数传入,然后通过addRouteRecord
生成的。
link与view的关联
上面提到了router-view的更新是根据route进行的,那么vm.$route
的变化与路由的变化时如何关联起来的呢?回到前面,在transitionTo
中向 confirmTransition
传入了onComplete
函数
// /src/history/base.js
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const route = this.router.match(location, this.current)
this.confirmTransition(route, () => {
// ...
this.updateRoute(route)
})
}
updateRoute (route: Route) {
const prev = this.current
this.current = route
// cb是在router.init中调用listen注册
this.cb && this.cb(route)
this.router.afterHooks.forEach(hook => {
hook && hook(route, prev)
})
}
listen (cb: Function) {
this.cb = cb
}
history.listen(route => {
this.apps.forEach((app) => {
app._route = route
})
})
可见,在执行confirmTransition
完成路由的切换后,会调用updateRoute
,然后修改vm._route
,此时会重新渲染router-view
组件。至此整个流程已整理完毕
小结
上面整理了Vue-Router大致的运行流程和工作原理,了解了matcher的工作机制和不同模式下模拟的history实例,以及router-link和router-view的构造和关联。其中也有一些细节没有处理,比如滚动行为、avtiveClass、异步组件和keep-alive
等特性。
现在回头看看,这一个多月基本都在学习Vue相关源码,还是有一些收获的,包括从Vue的使用到内部实现的一些理解,以及阅读源码的经验积累,还有对于Vue之外的关于编码的思考。其中我觉得,阅读源码最难的不是思考某个机制的实现,而是思考为什么作者需要这么处理。
也许某一天Vue就不再流行了,也许Vue-Router会被更好的框架替代,但我相信这一段时间的学习经历,对于自己而言还是很有帮助的,还有很长的路要走,自勉之。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。