背景
随着前后端分离的发展,单页面应用的盛行,页面路由控制权便由早期的根据 url 从后端获取渲染好的页面模板,转向交由前端自主控制。耳熟能详的路由框架有 vue-router、react-router, 本质上,这是一种不重载页面,通过监听页面 url 变化,使用 js 更替页面元素的技术。前端路由的好处是,不需要每次从服务端取页面,因此能快速响应。缺点是路由切换的页面,不能被浏览器缓存,当通过浏览器前进后退时,页面会重新发起请求。
这篇文章,就来看看 vue-router 是如何实现页面切换的。最后读完源码,从框架数据数据模型上看核心组成是:路由匹配器(路由配置信息添加、查询、获取)、路由处理器(History 监听、变更等)、组件视图容器(RouterView)。
原理
哈希模式
哈希模式就是在浏览器地址带上 #,比如:xxx://www.xxx.cn/#test
。# 后面的值变化是可以通过原生事件 window.hashchange
监听到的,因此可以在监听到浏览器地址的 hash 值变化的时候去变更页面元素。下面几种情况都可以采用该事件监听:
下面直接上代码,模拟这个行为:
// 1. 在控制台先执行这段代码
const body = document.body;
window.addEventListener('hashchange', function(){
switch(location.hash){
case '#/p1':
body.innerHTML = 1;
return
case '#/p2':
body.innerHTML = 2;
return
default:
body.innerHTML = 3;
}
})
// 2. 然后依次执行以下代码
window.location.hash='#/p1' // 页面输出: 1
window.location.hash='#/p2' // 页面输出: 2
window.location.hash='#/p0' // 页面输出: 3
复制代码
哈希模式比较简单,按照这个例子,便能够验证了。当然了这只是个玩具。
历史模式
历史模式会比哈希模式来得麻烦些,它是依赖于 H5 的 History 接口。下面先弥补下这块知识吧,毕竟平常也常用。
History 接口
History 接口允许操作浏览器的曾经在标签页或者框架里访问的会话历史记录。
属性
History.length
返回一个整数,该整数表示会话历史中元素的数目,包括当前加载的页。
History.state
返回一个表示历史堆栈顶部的状态值。
History.scrollRestoration
允许 Web 应用程序在历史导航上显式地设置默认滚动恢复行为。此属性可以是自动的(auto)或者手动的(manual)。
方法
History.pushState()
按指定的名称和 URL(如果提供该参数)将数据 push 进会话历史栈,数据被 DOM 进行不透明处理。
History.replaceState()
按指定的数据,名称和 URL(如果提供该参数),更新历史栈上最新的入口。
/**
* @Param 状态对象(可以是能被序列化的任何东西)
* @Param 标题(可以忽略此参数)
* @Param URL
*/
history.pushState({id:1},'','1.html')
复制代码
这两个方法的参数都是一样的,history.state
获取的就是状态值。
重点:onpopstate 事件
调用 history.pushState()
或者 history.replaceState()
不会触发 popstate
事件.。popstate
事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在 JavaScript 中调用history.back()
、history.forward()
、history.go()
方法),此外,a 标签的锚点也会触发该事件。
原理实现
我们需要借助 history.pushState()
和 history.replaceState()
两个 API 能够改变浏览器地址而不刷新页面的特性,来实现我们的前端路由。但是这两个方法不能被 onpopstate
事件监听,因此我们要转变下思路,当进行 pushState()
或者 replaceState()
调用时,去获取浏览器地址然后去变更页面。 代码如下:
// 1. 页面初始化函数
const body = document.body;
function ListenPathChange(){
switch(location.pathname){
case '/p1':
body.innerHTML = 1;
return;
case '/p2':
body.innerHTML = 2;
return;
default:
body.innerHTML =3;
}
}
// 2. 依次执行如下代码
history.pushState({id:1},'','/p1')
ListenPathChange() // 页面变更为:1
history.pushState({id:2},'','/p2')
ListenPathChange() // 页面变更为:2
history.pushState({id:0},'','/p0')
ListenPathChange() // 页面变更为:3
复制代码
至此我们便晓得了哈希模式和历史模式,改变页面的玩具玩法,当然也是前端路由的原理。下面我们看看前端路由框架 Vue-Router 是如何实现这块的。
源码实现
这里以哈希模式的路由,阅读从 Vue-Router 初始化到在 Router-View 组件完成渲染的全链路流程,理解它的设计思想和实现。
1. Vue 的插件
VueRouter 是通过 Vue.use()
完成注册。按照 Vue 插件的规范,插件可以存在两种形式,一种是函数,另一种是存在 install
方法的对象,并且在执行插件初始化的时候,会传递 Vue,也就方便插件无需引入 Vue 包。明白这些就很明显了,Vue-Router 初始化如下:
export let _Vue
export function install (Vue) {
_Vue = Vue
}
复制代码
2. 插件初始化
router 实例化后,会注入根组件。代码如下:
const router = new VueRouter({})
const app = new Vue({
router,
})
复制代码
路由的初始化都需要做什么事情呢?下面看看源码:
export function install (Vue) {
if (install.installed && _Vue === Vue) return
install.installed = true
_Vue = Vue
const isDef = v => v !== undefined
Vue.mixin({
beforeCreate () {
// this.$options.router 注入到根组件的路由实例
if (isDef(this.$options.router)) {
// 只有根组件的$options才有router
this._routerRoot = this
// 配置的路由信息
this._router = this.$options.router
// 路由初始化
this._router.init(this)
// **** 这里是定义了个响应式的 _route 属性,值为当前页面路由
// **** 很重要,vue-router就是靠这玩意取组件完成页面渲染的
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// 子组件获取父组件的_routerRoot并在自身注册
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
}
})
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
// 组件初始化
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
}
复制代码
插件初始化,主要完成了几件事:
保存个 Vue,并导出供其他模块使用
通过 Vue.mixin
完成 _routerRoot
在所有组件的注册
初始化两个原型属性$router
, $route
返回内部 _routerRoot
的响应信息
初始化全局路由组件 RouterView
、RouterLink
3. 创建匹配器
VueRouter 核心是根据不同的路由,跳转不同的页面。因此在实例化的过程中,会根据配置的 routes 创建一个匹配器,方便通过路由路径或者路由名称好匹配到对应的路由详细信息。当然匹配器并不能刷新页面组件。可以把它理解为一个提供路由信息查询、新增和获取的实体。
// 路由类
export default class VueRouter {
constructor (options: RouterOptions = {}) {
// 其他代码略
// 路由初始化,创建一个匹配器
this.matcher = createMatcher(options.routes || [], this)
}
init(){}
}
复制代码
匹配器具体代码:
export function createMatcher (routes,router){
// createRouteMap:扁平化用户传入的数据,创建路由映射表,内部通过递归children路由信息,具体可以看源码,这里不介绍
// pathList : [/,'/path','/path/a']
// pathMap:{'/':'组件A','/path':'组件B','/path/a':'组件C'}
const { pathList, pathMap, nameMap } = createRouteMap(routes)
// 动态添加路由配置:将routes拍平添加到pathList这些
function addRoutes (routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}
// 匹配路由
function match(raw,currentRoute){}
// ...
return {
match,
addRoute,
getRoutes,
addRoutes
}
}
复制代码
4. 路由监听
插件初始化的时候,执行了路由实例的初始化方法,那这个方法都干了什么呢?猜测一下,能看得出来是调用 HashHistory 的方法去监听路由信息变化!并且当当前路由发生了变化,会将最新路由值赋值到组件实例 _route
上,又因为当前属性是响应式的,因此将会触发视图的变更。看下代码(抽离了下核心代码):
export default class VueRouter{
init(app){
// 省略其他代码
if (history instanceof HTML5History || history instanceof HashHistory) {
// 这里调用了history去监听路由信息变化
const setupListeners = routeOrError => {
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),
setupListeners,
setupListeners
)
}
// 监听路由变化,重新设置组件实例_route的值,这个值做了响应式处理,所以变更后,会触发视图更新
// 这里算是采用了发布订阅的模式
history.listen(route => {
this.apps.forEach(app => {
app._route = route
})
})
}
}
复制代码
setupListeners
下面就通过哈希模式的 setupListeners
监听到 hash 变化是如何处理路由的。其他 push、replace、go 等方式都是一样,不多细说。 以下代码是抽离了核心逻辑代码。
export class HashHistory extends History {
constructor (router, base, fallback) {
super(router, base)
// ...
ensureSlash()
}
// 监听路由变化
setupListeners(){
const router = this.router
const handleRoutingEvent = () => {
const current = this.current
this.transitionTo(getHash(), route => {
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
}
// 通过hashchange 监听到路由变化,然后去执行 handleRoutingEvent 变化
const eventType = supportsPushState ? 'popstate' : 'hashchange'
window.addEventListener(
eventType,
handleRoutingEvent
)
}
push(){}
replace(){}
go(){}
}
复制代码
监听到路由变化会执行一个transitionTo
方法,具体路由是否跳转,路由守卫应该也就是在此函数内执行了。
5. 路由守卫
监听到路由变化,到最终变更替换为真正的路由是需要经过一系列自定义拦截行为,统称为路由的钩子函数。精简后的核心代码如下。
export class History {
constructor(){
this.current = START
}
listen (cb: Function) {
this.cb = cb
}
updateRoute (route: Route) {
this.current = route
this.cb && this.cb(route)
}
transitionTo(){
this.confirmTransition(
route,
()=>{
// 更新路由信息
this.updateRoute(route)
}
)
}
confirmTransition(){
const queue = [].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)
)
const iterator = (hook, next) => {
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
try {
hook(route, current, (to: any) => {
if (to === false) {
// next(false) -> abort navigation, ensure current URL
this.ensureURL(true)
abort(createNavigationAbortedError(current, route))
} else if (isError(to)) {
this.ensureURL(true)
abort(to)
} else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {
// next('/') or next({ path: '/' }) -> redirect
abort(createNavigationRedirectedError(current, route))
if (typeof to === 'object' && to.replace) {
this.replace(to)
} else {
this.push(to)
}
} else {
// confirm transition and pass on the value
next(to)
}
})
} catch (e) {
abort(e)
}
}
runQueue(queue, iterator, () => {
// wait until async components are resolved before
// extracting in-component enter guards
const enterGuards = extractEnterGuards(activated)
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
this.pending = null
onComplete(route)
if (this.router.app) {
this.router.app.$nextTick(() => {
handleRouteEntered(route)
})
}
})
})
}
}
复制代码
路由守卫相关简易去看下文档,加深理解,这里只梳理下流程,只有路由变更前需经过一系列路由守卫钩子函数处理。当通过时会执行 updateRoute
方法,更新当前路由的值。并且执行了 listen
函数传入的cb
回调函数,这个异步操作的一种形式,该 listen 函数在 VueRouter 初始化的时候完成了注册。
6. 页面刷新
VueRouter 的视图刷新是在 RouterView 组件内部完成的切换,这里可能涉及 RouterView 嵌套 RouterView 的情况,所以要做层级的处理取组件。抽离了核心代码如下。##
export default {
name: 'RouterView',
functional: true,
props: {
name: {
type: String,
default: 'default'
}
},
render (_, { props, children, parent, data }) {
// router-view的标记
data.routerView = true
// 直接使用父上下文的createElement()函数,方便由router视图呈现的组件可以解析命名插槽
const h = parent.$createElement
const name = props.name
const route = parent.$route // $route 即 _route的值,
const cache = parent._routerViewCache || (parent._routerViewCache = {})
// 确定当前视图深度,可能是嵌套多个RouterView
let depth = 0
while (parent && parent._routerRoot !== parent) {
const vnodeData = parent.$vnode ? parent.$vnode.data : {}
if (vnodeData.routerView) {
depth++
}
parent = parent.$parent
}
data.routerViewDepth = depth
// 获取到匹配的组件
const matched = route.matched[depth]
const component = matched && matched.components[name]
// 没有匹配到组件或未配置组件,渲染空节点
if (!matched || !component) {
cache[name] = null
return h()
}
// cache component
cache[name] = { component }
return h(component, data, children)
}
}
复制代码
总结
至此便大概梳理了下哈希模式下 VueRouter 的源码整体流程,发现整体模型上不多,路由匹配器、History、路由组件三者构成了 VueRouter 的核心组成。当然为了路由安全,在流程链路上又搭配了守卫系统。所以从模型上理解路由源码,就简单,大三核心功能大致如下:
路由匹配器:提供对路由信息的匹配(路径匹配路由信息)、新增(动态路由)、获取三大功能
History:监听、改变页面路由的处理器,并保存当前页面路由 current,当为三核心沟通钥匙
路由组件:RouterView 为匹配的路由组件渲染切换容器
评论