路由的概念相信大部分同学并不陌生,我们在用 Vue
开发过实际项目的时候都会用到 Vue-Router
这个官方插件来帮我们解决路由的问题。它的作用就是根据不同的路径映射到不同的视图。本文不再讲述路由的基础使用和API
,不清楚的同学可以自行查阅官方文档vue-router3 对应 vue2 和 vue-router4 对应 vue3。
今天我们主要是谈谈Vue-Router
的实现原理,感兴趣的小伙伴可以继续往下看,大佬请止步。
本文 vue-router 版本为 3.5.3
既然我们在分析路由,我们首先来说说什么是路由,什么是后端路由、什么是前端路由。
路由就是根据不同的 url
地址展示不同的内容或页面,早期路由的概念是在后端出现的,通过服务器端渲染后返回页面,随着页面越来越复杂,服务器端压力越来越大。后来ajax
异步刷新的出现使得前端也可以对url
进行管理,此时,前端路由就出现了。【学习视频分享:vue视频教程、web前端视频】
我们先来说说后端路由
后端路由又可称之为服务器端路由,因为对于服务器来说,当接收到客户端发来的HTTP
请求,就会根据所请求的URL
,来找到相应的映射函数,然后执行该函数,并将函数的返回值发送给客户端。
对于最简单的静态资源服务器,可以认为,所有URL
的映射函数就是一个文件读取操作。 对于动态资源,映射函数可能是一个数据库读取操作,也可能是进行一些数据的处理,等等。
然后根据这些读取的数据,在服务器端就使用相应的模板来对页面进行渲染后,再返回渲染完毕的HTML
页面。早期的jsp
就是这种模式。
刚刚也介绍了,在前后端没有分离的时候,服务端都是直接将整个 HTML
返回,用户每次一个很小的操作都会引起页面的整个刷新(再加上之前的网速还很慢,所以用户体验可想而知)。
在90年代末的时候,微软首先实现了 ajax(Asynchronous JavaScript And XML)
这个技术,这样用户每次的操作就可以不用刷新整个页面了,用户体验就大大提升了。
虽然数据能异步获取不用每个点击都去请求整个网页,但是页面之间的跳转还是会加载整个网页,体验不是特别好,还有没有更好的方法呢?
至此异步交互体验的更高级版本 SPA单页应用
就出现了。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的。既然页面的跳转是无刷新的,也就是不再向后端请求返回 HTML
页面。
页面跳转都不从后端获取新的HTML
页面,那应该怎么做呢?所以就有了现在的前端路由。
可以理解为,前端路由就是将之前服务端根据 url 的不同返回不同的页面的任务交给前端来做。在这个过程中,js会实时检测url的变化,从而改变显示的内容。
前端路由优点是用户体验好,用户操作或页面跳转不会刷新页面,并且能快速展现给用户。缺点是首屏加载慢,因为需要js
动态渲染展示内容。而且由于内容是js
动态渲染的所以不利于SEO
。
下面我们正式进入Vue-Router
原理分析阶段。
我们先来看看install.js
,这个方法会在Vue.use(VueRouter)
的时候被调用。
// 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 = Vue const isDef = v => v !== undefined // 为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) } } Vue.mixin({ beforeCreate () { // 这里只会进来一次,因为只有Vue根实例才会有router属性。 if (isDef(this.$options.router)) { // 所以这里的this就是Vue根实例 this._routerRoot = this this._router = this.$options.router this._router.init(this) // 将 _route 变成响应式 Vue.util.defineReactive(this, '_route', this._router.history.current) } else { // 子组件会进入这里,这里也是把Vue根实例保存带_routerRoot属性上 this._routerRoot = (this.$parent && this.$parent._routerRoot) || this } // 为router-view组件关联路由组件 registerInstance(this, this) }, destroyed () { // destroyed hook触发时,取消router-view和路由组件的关联 registerInstance(this) } }) // 在原型上注入$router、$route属性,方便快捷访问 Object.defineProperty(Vue.prototype, '$router', { // 上面说到每个组件的_routerRoot都是Vue根实例,所以都能访问_router get () { return this._routerRoot._router } }) // 每个组件访问到的$route,其实最后访问的都是Vue根实例的_route Object.defineProperty(Vue.prototype, '$route', { get () { return this._routerRoot._route } }) // 注册router-view、router-link两个全局组件 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
逻辑只执行一次,用了 install.installed
变量做已安装的标志位。
用一个全局的 _Vue
来接收参数 Vue
,因为作为 Vue
的插件对 Vue
对象是有依赖的,但又不能去单独去 import Vue
,因为那样会增加包体积,所以就通过这种方式拿到 Vue
对象。
Vue-Router
安装最重要的一步就是利用 Vue.mixin
,在beforeCreate
和destroyed
生命周期函数中注入路由逻辑。
Vue.mixin
我们知道就是全局 mixin
,所以也就相当于每个组件的beforeCreate
和destroyed
生命周期函数中都会有这些代码,并在每个组件中都会运行。
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) } })
在这两个钩子中,this
是指向当时正在调用钩子的vue实例
。
这两个钩子中的逻辑,在安装流程中是不会被执行的,只有在组件实例化时执行到钩子时才会被调用
先看混入的 beforeCreate
钩子函数
它先判断了this.$options.router
是否存在,我们在new Vue({router})
时,router
才会被保存到到Vue根实例
的$options
上,而其它Vue实例
的$options
上是没有router
的,所以if
中的语句只在this === new Vue({router})
时,才会被执行,由于Vue根实例
只有一个,所以这个逻辑只会被执行一次。
对于根 Vue
实例而言,执行该钩子函数时定义了 this._routerRoot
表示它自身(Vue
根实例);this._router
表示 VueRouter
的实例 router
,它是在 new Vue
的时候传入的;
另外执行了 this._router.init()
方法初始化 router
,这个逻辑在后面讲初始化的时候再介绍。
然后用 defineReactive
方法把 this._route
变成响应式对象,保证_route
变化时,router-view
会重新渲染,这个我们后面在router-view
组件中会细讲。
我们再看下else
中具体干了啥
主要是为每个组件定义_routerRoot
,对于子组件而言,由于组件是树状结构,在遍历组件树的过程中,它们在执行该钩子函数的时候 this._routerRoot
始终指向的离它最近的传入了 router
对象作为配置而实例化的父实例(也就是永远等于根实例)。
所以我们可以得到,在每个vue
组件都有 this._routerRoot === vue根实例
、this._routerRoot._router === router对象
对于 beforeCreate
和 destroyed
钩子函数,它们都会执行 registerInstance
方法,这个方法的作用我们也是之后会介绍。
$route、$router
属性接着给 Vue
原型上定义了 $router
和 $route
2 个属性的 get
方法,这就是为什么我们可以在任何组件实例上都可以访问 this.$router
以及 this.$route
。
Object.defineProperty(Vue.prototype, '$router', { get () { return this._routerRoot._router } }) Object.defineProperty(Vue.prototype, '$route', { get () { return this._routerRoot._route } })
我们可以看到,$router
其实返回的是this._routerRoot._router
,也就是vue
根实例上的router
,因此我们可以通过this.$router
来使用router
的各种方法。
$route
其实返回的是this._routerRoot._route
,其实就是this._router.history.current
,也就是目前的路由对象,这个后面会细说。
通过 Vue.component
方法定义了全局的 b988a8fd72e5e0e42afffd18f951b277
和 975b587bf85a482ea10b0a28848e78a4
2 个组件,这也是为什么我们在写模板的时候可以直接使用这两个标签,它们的作用我想就不用笔者再说了吧。
最后设置路由组件的beforeRouteEnter
、beforeRouteLeave
、beforeRouteUpdate
守卫的合并策略。
那么到此为止,我们分析了 Vue-Router
的安装过程,Vue
编写插件的时候通常要提供静态的 install
方法,我们通过 Vue.use(plugin)
时候,就是在执行 install
方法。Vue-Router
的 install
方法会给每一个组件注入 beforeCreate
和 destoryed
钩子函数,在beforeCreate
做一些私有属性定义和路由初始化工作。并注册了两个全局组件,然后设置了钩子函数合并策略。在destoryed
做了一些销毁工作。
下面我们再来看看Vue-Router
的实例化。
前面我们提到了在 install
的时候会执行 VueRouter
的 init
方法( this._router.init(this)
),那么接下来我们就来看一下 init
方法做了什么。
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) } } // 1.setupListeners 里会对 hashchange或popstate事件进行监听 const setupListeners = routeOrError => { history.setupListeners() handleInitialScroll(routeOrError) } // 2.初始化导航 history.transitionTo( history.getCurrentLocation(), setupListeners, setupListeners ) } // 3.路由全局监听,维护当前的route // 当路由变化的时候修改app._route的值 // 由于_route是响应式的,所以修改后相应视图会同步更新 history.listen(route => { this.apps.forEach(app => { app._route = route }) }) }
这里主要做了如下几件事情:
const setupListeners = routeOrError => { history.setupListeners() handleInitialScroll(routeOrError) }
这里会根据当前路由模式监听hashchange
或popstate
事件,当事件触发的时候,会进行路由的跳转。(后面说到路由模式的时候会细说)
history.transitionTo( history.getCurrentLocation(), setupListeners, setupListeners )
进入系统会进行初始化路由匹配,渲染对应的组件。因为第一次进入系统,并不会触发hashchange
或者popstate
事件,所以第一次需要自己手动匹配路径然后进行跳转。
history.listen(route => { this.apps.forEach(app => { app._route = route }) })
当路由变化的时候修改app._route
的值。由于_route
是响应式的,所以修改后相应视图会同步更新。
这里主要是做了一些初始化工作。根据当前路由模式监听对应的路由事件。初始化导航,根据当前的url渲染初始页面。最后切换路由的时候修改_route
,由于_route
是响应式的,所以修改后相应视图会同步更新。
实例化就是我们new VueRouter({routes})
的过程,我们来重点分析下VueRouter
的构造函数。
constructor (options: RouterOptions = {}) { // ... // 参数初始化 this.app = null this.apps = [] this.options = options this.beforeHooks = [] this.resolveHooks = [] this.afterHooks = [] // 创建matcher this.matcher = createMatcher(options.routes || [], this) // 设置默认模式和做不支持 H5 history 的降级处理 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 // 根据不同的 mode 实例化不同的 History 对象 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}`) } } }
这里主要做了如下几件事情:
我们看到在最开始有些参数的初始化,这些参数到底是什么呢?
this.app
用来保存根 Vue
实例。
this.apps
用来保存持有 $options.router
属性的 Vue
实例。
this.options
保存传入的路由配置,也就是前面说的RouterOptions
。
this.beforeHooks
、 this.resolveHooks
、this.afterHooks
表示一些钩子函数。
this.fallback
表示在浏览器不支持 history
新api
的情况下,根据传入的 fallback
配置参数,决定是否回退到hash
模式。
this.mode
表示路由创建的模式。
matcher
,匹配器。简单理解就是可以通过url
找到我们对应的组件。这一块内容较多,这里笔者就不再详细分析了。
路由模式平时都会只说两种,其实在vue-router
总共实现了 hash
、history
、abstract
3 种模式。
VueRouter
会根据options.mode
、options.fallback
、supportsPushState
、inBrowser
来确定最终的路由模式。
如果没有设置mode
就默认是hash
模式。
确定fallback
值,只有在用户设置了mode:history
并且当前环境不支持pushState
且用户没有主动声明不需要回退(没设置fallback
值位undefined
),此时this.fallback
才为true
,当fallback
为true
时会使用hash
模式。(简单理解就是如果不支持history
模式并且只要没设置fallback
为false
,就会启用hash
模式)
如果最后发现处于非浏览器环境,则会强制使用abstract
模式。
根据mode
属性值来实例化不同的对象。VueRouter
的三种路由模式,主要由下面的四个核心类实现
History
src/history/base.js
HTML5History
pushState
的浏览器src/history/html5.js
HashHistory
pushState
的浏览器src/history/hash.js
AbstractHistory
src/history/abstract.js
HTML5History
、HashHistory
、AbstractHistory
三者都是继承于基础类History
。
这里我们详细分析下HTML5History
和HashHistory
类。
当我们使用history
模式的时候会实例化HTML5History类
// src/history/html5.js ... export class HTML5History extends History { _startLocation: string constructor (router: Router, base: ?string) { // 调用父类构造函数初始化 super(router, base) this._startLocation = getLocation(this.base) } // 设置监听,主要是监听popstate方法来自动触发transitionTo setupListeners () { if (this.listeners.length > 0) { return } const router = this.router const expectScroll = router.options.scrollBehavior const supportsScroll = supportsPushState && expectScroll // 若支持scroll,初始化scroll相关逻辑 if (supportsScroll) { this.listeners.push(setupScroll()) } const handleRoutingEvent = () => { const current = this.current // 某些浏览器,会在打开页面时触发一次popstate // 此时如果初始路由是异步路由,就会出现`popstate`先触发,初始路由后解析完成,进而导致route未更新 // 所以需要避免 const location = getLocation(this.base) if (this.current === START && location === this._startLocation) { return } // 路由地址发生变化,则跳转,如需滚动则在跳转后处理滚动 this.transitionTo(location, route => { if (supportsScroll) { handleScroll(router, route, current, true) } }) } // 监听popstate事件 window.addEventListener('popstate', handleRoutingEvent) this.listeners.push(() => { window.removeEventListener('popstate', handleRoutingEvent) }) } // 可以看到 history模式go方法其实是调用的window.history.go(n) go (n: number) { window.history.go(n) } // push方法会主动调用transitionTo进行跳转 push (location: RawLocation, onComplete?: Function, onAbort?: Function) { const { current: fromRoute } = this this.transitionTo(location, route => { pushState(cleanPath(this.base + route.fullPath)) handleScroll(this.router, route, fromRoute, false) onComplete && onComplete(route) }, onAbort) } // replace方法会主动调用transitionTo进行跳转 replace (location: RawLocation, onComplete?: Function, onAbort?: Function) { const { current: fromRoute } = this this.transitionTo(location, route => { replaceState(cleanPath(this.base + route.fullPath)) handleScroll(this.router, route, fromRoute, false) onComplete && onComplete(route) }, onAbort) } ensureURL (push?: boolean) { if (getLocation(this.base) !== this.current.fullPath) { const current = cleanPath(this.base + this.current.fullPath) push ? pushState(current) : replaceState(current) } } getCurrentLocation (): string { return getLocation(this.base) } } export function getLocation (base: string): string { let path = window.location.pathname const pathLowerCase = path.toLowerCase() const baseLowerCase = base.toLowerCase() // base="/a" shouldn't turn path="/app" into "/a/pp" // https://github.com/vuejs/vue-router/issues/3555 // so we ensure the trailing slash in the base if (base && ((pathLowerCase === baseLowerCase) || (pathLowerCase.indexOf(cleanPath(baseLowerCase + '/')) === 0))) { path = path.slice(base.length) } return (path || '/') + window.location.search + window.location.hash }
可以看到HTML5History
类主要干了如下几件事。
继承于History类
,并调用父类构造函数初始化。
实现了setupListeners
方法,在该方法中检查了是否需要支持滚动行为,如果支持,则初始化滚动相关逻辑,监听了popstate事件
,并在popstate
触发时自动调用transitionTo
方法。
实现了go、push、replace
等方法,我们可以看到,history
模式其实就是使用的history api
。
// 可以看到 history模式go方法其实是调用的window.history.go(n) go (n: number) { window.history.go(n) } // push、replace调用的是util/push-state.js,里面实现了push和replace方法 // 实现原理也是使用的history api,并且在不支持history api的情况下使用location api export function pushState (url?: string, replace?: boolean) { ... const history = window.history try { if (replace) { const stateCopy = extend({}, history.state) stateCopy.key = getStateKey() // 调用的 history.replaceState history.replaceState(stateCopy, '', url) } else { // 调用的 history.pushState history.pushState({ key: setStateKey(genStateKey()) }, '', url) } } catch (e) { window.location[replace ? 'replace' : 'assign'](url) } } export function replaceState (url?: string) { pushState(url, true) }
总结
所以history
模式的原理就是在js
中路由的跳转(也就是使用push
和replace
方法)都是通过history api
,history.pushState
和 history.replaceState
两个方法完成,通过这两个方法我们知道了路由的变化,然后根据路由映射关系来实现页面内容的更新。
对于直接点击浏览器的前进后退按钮或者js
调用 this.$router.go()
、this.$router.forward()
、this.$router.back()
、或者原生js
方法history.back()
、history.go()
、history.forward()
的,都会触发popstate
事件,通过监听这个事件我们就可以知道路由发生了哪些变化然后来实现更新页面内容。
注意history.pushState
和 history.replaceState
这两个方法并不会触发popstate
事件。在这两个方法里面他是有手动调用transitionTo
方法的。
接下来我们再来看看HashHistory类
当我们使用hash
模式的时候会实例化HashHistory类
。
//src/history/hash.js ... 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 () { if (this.listeners.length > 0) { return } const router = this.router const expectScroll = router.options.scrollBehavior const supportsScroll = supportsPushState && expectScroll if (supportsScroll) { this.listeners.push(setupScroll()) } const handleRoutingEvent = () => { const current = this.current if (!ensureSlash()) { return } this.transitionTo(getHash(), route => { if (supportsScroll) { handleScroll(this.router, route, current, true) } if (!supportsPushState) { replaceHash(route.fullPath) } }) } // 事件优先使用 popstate // 判断supportsPushState就是通过return window.history && typeof window.history.pushState === 'function' const eventType = supportsPushState ? 'popstate' : 'hashchange' window.addEventListener( eventType, handleRoutingEvent ) this.listeners.push(() => { window.removeEventListener(eventType, handleRoutingEvent) }) } // 其实也是优先使用history的pushState方法来实现,不支持再使用location修改hash值 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 ) } // 其实也是优先使用history的replaceState方法来实现,不支持再使用location修改replace方法 replace (location: RawLocation, onComplete?: Function, onAbort?: Function) { const { current: fromRoute } = this this.transitionTo( location, route => { replaceHash(route.fullPath) handleScroll(this.router, route, fromRoute, false) onComplete && onComplete(route) }, onAbort ) } // 也是使用的history go方法 go (n: number) { window.history.go(n) } ensureURL (push?: boolean) { const current = this.current.fullPath if (getHash() !== current) { push ? pushHash(current) : replaceHash(current) } } getCurrentLocation () { return getHash() } } function checkFallback (base) { const location = getLocation(base) if (!/^\/#/.test(location)) { window.location.replace(cleanPath(base + '/#' + location)) return true } } function ensureSlash (): boolean { const path = getHash() if (path.charAt(0) === '/') { return true } replaceHash('/' + path) return false } // 获取 # 后面的内容 export function getHash (): string { // We can't use window.location.hash here because it's not // consistent across browsers - Firefox will pre-decode it! let href = window.location.href const index = href.indexOf('#') // empty path if (index < 0) return '' href = href.slice(index + 1) return href } function getUrl (path) { const href = window.location.href const i = href.indexOf('#') const base = i >= 0 ? href.slice(0, i) : href return `${base}#${path}` } function pushHash (path) { if (supportsPushState) { pushState(getUrl(path)) } else { window.location.hash = path } } function replaceHash (path) { if (supportsPushState) { replaceState(getUrl(path)) } else { window.location.replace(getUrl(path)) } }
可以看到HashHistory
类主要干了如下几件事。
继承于History类
,并调用父类构造函数初始化。这里比HTML5History
多了回退操作,所以,需要将history
模式的url
替换成hash
模式,即添加上#
,这个逻辑是由checkFallback
实现的
实现了setupListeners
方法,在该方法中检查了是否需要支持滚动行为,如果支持,则初始化滚动相关逻辑。 监听了popstate事件或hashchange事件
,并在相应事件触发时,调用transitionTo
方法实现跳转。
通过
const eventType = supportsPushState ? 'popstate' : 'hashchange'
我们可以发现就算是hash
模式优先使用的还是popstate
事件。
实现了go、push、replace
等方法。
我们可以看到,hash
模式实现的push、replace
方法其实也是优先使用history
里面的方法,也就是history api
。
// 可以看到 hash 模式go方法其实是调用的window.history.go(n) go (n: number) { window.history.go(n) } // 在支持新的history api情况下优先使用history.pushState实现 // 否则使用location api function pushHash (path) { if (supportsPushState) { pushState(getUrl(path)) } else { window.location.hash = path } } // 在支持新的history api情况下优先使用history.replaceState实现 // 否则使用location api function replaceHash (path) { if (supportsPushState) { replaceState(getUrl(path)) } else { window.location.replace(getUrl(path)) } }
总结
在浏览器链接里面我们改变hash
值是不会重新向后台发送请求的,也就不会刷新页面。并且每次 hash
值的变化,还会触发hashchange
这个事件。
所以hash
模式的原理就是通过监听hashchange
事件,通过这个事件我们就可以知道 hash
值发生了哪些变化然后根据路由映射关系来实现页面内容的更新。(这里hash
值的变化不管是通过js
修改的还是直接点击浏览器的前进后退按钮都会触发hashchange
事件)
对于hash
模式,如果是在浏览器支持history api
情况下,hash
模式的实现其实是和history
模式一样的。只有在不支持history api
情况下才会监听hashchange
事件。这个我们可以在源码中看出来。
总的来说就是使用 Vue.util.defineReactive
将实例的 _route
设置为响应式对象。在push, replace
方法里会主动更新属性 _route
。而 go,back,forward
,或者通过点击浏览器前进后退的按钮则会在 hashchange
或者 popstate
的回调中更新 _route
。_route
的更新会触发 RoterView
的重新渲染。
对于第一次进入系统,并不会触发hashchange
或者popstate
事件,所以第一次需要自己手动匹配路径然后通过transitionTo
方法进行跳转,然后渲染对应的视图。
以上是一文聊聊Vue-Router的实现原理的详细内容。更多信息请关注PHP中文网其他相关文章!