什么是前端路由?
在单页面应用中,前端路由是描述url和UI之间的映射关系;url的变化显示对应的UI,无需刷新页面;
如何实现前端路由?
- url的变化不进行刷新页面如何显示对应的UI?
- 如何监听到url的变化?
通过H5的history和hash两种方式可以实现以上两个问题;
hash路由
hash路由也是锚点,带有#符号的url就是hash路由,#后面的就是hash;改变hash值是不会引起页面的刷新;
改变hash的3种方式:
- 浏览器的前进后退
- a标签中的跳转
- 通过window.location改变hash的值;
以上三种方式可以通过监听hashchange来监听到hash的变化,从而可以修改对应的UI进行显示; location.hash可以获取到当前的hash值
history路由
history是操作浏览器会话历史的对象,url中没有携带#符号;通过history的api修改路由是不会引起页面的刷新;
history的API:
- pushState(state,title,url)方法:添加一条历史记录,并且不刷新页面;state:状态对象,在popState事件触发的时候会把state作为参数传递给回调函数;title:新页面的标题;url:新的路由,必须和当前路由为同域,浏览器的地址栏会变为此地址;
- replaceState(state,title,url)方法:替换当前的历史记录,不会刷新页面;参数和pushState是一样的;
- popState事件: 当历史记录发生变化的时候会触发此事件;pushState和repalceState不会触发此事件;
- go方法:向前或向后跳转指定的页数,参数为负数就是向后跳转,参数为正数向前跳转,参数为0表示当前页面;
- back方法:向后跳转,和操作浏览器后退的按钮一样;
- forward方法:向前跳转,和操作浏览器的前进按钮一样;
- length属性: 获取当前历史堆栈中页面的数量;
- state属性:获取pushState或repalceState方法传递过来的状态;
改变history路由的三种方式
- 浏览器的前进后退按钮,可以触发popState事件
- pushState或replaceState方法不会触发popState事件,可以重写这两种方法达到监听的目的;
- go,back,forward方法会触发popState事件;
location.pathname可以获取到history的路由值;go,back,forward也可以触发hashchange事件;
Vue-router的实现
vue-router的使用
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'history',
[
{
path: '/bank',
name: 'bank',
component: import('@/views/Bank.vue'),
},
{
path: '/success',
name: 'success',
component: import('@/views/Success.vue'),
},
]
})
new Vue({
router,
render: h => h(App)
})
从使用上可以看出VueRouter应该是一个类,并且在类上具有install方法;因为通过vue.use作为插件使用;
class VueRouter {
constructor () {}
}
VueRouter.install = function () {}
在install中主要能够让所有的组件都获取到VueRouter的实例,并且给vue上添加route和router属性;进行初始的操作;
export let Vue
export default function install (_Vue) {
Vue = _Vue
// 所有的组件都会执行mixin方法
// 为什么是beforeCreate而不是created呢?因为如果是在created操作的话,$options已经初始化好了。
Vue.mixin({
beforeCreate () {
// 根组件
if (this.$options.router) {
// 创建一个属性存储当前实例
this._routerRoot = this
// 把当前的路由实例存储到当前实例的属性上
this._router = this.$options.router
// 进行初始化
this._router.init(Vue)
// 把当前的路由信息存储到route上 响应式定义_route属性,保证_route发生变化时,
// 组件(router-view)会重新渲染
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else { // 子组件
// 把父级的实例存储到子级的属性上
this._routerRoot = this.$parent && this.$parent._routerRoot
}
}
})
Object.defineProperty(Vue.prototype, '$router', {
get () {
return this._routerRoot?._router
}
})
Object.defineProperty(Vue.prototype, '$route', {
get () {
return this._routerRoot?._route
}
})
}
通过mixin把beforeCreate生命周期钩子混入到每个组件中;在根组件中把当前的实例和路由实例存储到对应的属性上,并且执行init进行初始化操作,给当前的实例上定义_route属性,并且是响应式的;在子组件中通过$parent._routerRoot获取到根组件的实例并且保存到_routerRoot属性上,这样所有的后代组件都可以通过_routerRoot属性获取到根组件的实例,从而就可以获取到路由的实例;在vue的原型上通过代理的形式添加了$router路由的实例和$route当前路由的信息两个属性;
继续分析VueRouter类;
const router = new VueRouter({
mode: 'history',
[
{
path: '/bank',
name: 'bank',
component: import('@/views/Bank.vue'),
children: [
{
path: '/bank/a',
name: 'bankA',
component: import('@/views/bankA.vue'),
},
]
},
],
})
传递了两个参数,分别为模式和路由信息,那么在构造器中肯定处理了这两个属性
class VueRouter {
constructor (options) {
// 获取到vue中的路由信息
let routes = options.routes || []
// 扁平化处理路由数据,并且返回添加和获取路由信息的方法
this.matcher = createMatcher(routes)
// 获取当前的路由模式默认是hash
const mode = options.mode || 'hash'
// 存放beforeEach钩子
this.beforeEachHooks = []
// 不同模式创建不同的实例
if (mode === 'history') {
this.history = new H5History(this)
} else {
this.history = new HashHistory(this)
}
}
}
构造器中获取到路由信息,通过createMatcher进行扁平化处理转换数据结构;获取到mode属性,通过mode属性判断是history还是hash,从而创建不同的类;
export default function createMatcher (routes) {
const pathMap = createRouteMap(routes)
function addRoutes (routes) {
createRouteMap(routes, pathMap)
}
function addRoute (routes) {
createRouteMap([routes], pathMap)
}
function match (location) {
return pathMap[location]
}
return {
addRoutes,
addRoute,
match,
}
}
createMatcher函数内部,通过createRouteMap处理了routes并且返回一个对象;定义了三个方法,分别为addRoutes添加多条路由信息,addRoute添加一条路由信息,match通过路由路径获取到对应的路由信息;addRoutes和addRoute方法就是动态的添加路由;
export default function createRouteMap (routes) {
let pathMap = pathMap || {}
routes.forEach(element => {
addRouteRecord(element, pathMap)
})
return pathMap
}
createRouteMap函数内部,遍历路由信息,通过addRouteRecord处理每一条路由信息;最后返回一个对象;
function addRouteRecord (route, pathMap, parentRecord) {
let path = parentRecord ? `${parentRecord.path === '/' ? '/' : parentRecord.path + '/'}` : '/'
let record = {
...route,
parent: parentRecord,
path: path,
}
if (!pathMap[path]) {
pathMap[path] = record
}
route.children && route.children.forEach(childRoute => {
addRouteRecord(childRoute, pathMap, record)
})
}
addRouteRecord函数,通过父级的parentRecord,解析当前路由的路径,path为带有父级以及祖先级的路径,定义当前的路由信息指定父级和path,通过path作为key存储到pathMap对象中;递归处理children属性,最终的pathMap为如下:
{
'a/b/': 'a/b/',
parent: {
'a/': 'a/'
...
},
....
}
以上就是路由信息的处理;处理的目的就是把路由信息进行扁平化处理,找到父级路由信息,找出对应的完成的路径,这样就可以通过地址栏中的路径找到对应的路由信息;下面接着分析根据模式的不同创建对应的实例;
if (mode === 'history') {
this.history = new H5History(this)
} else {
this.history = new HashHistory(this)
}
H5History类的实现
// 处理首次进入页面的路由
function ensureSlash () {
if (window.location.hash) {
return
}
window.location.hash = '/'
}
class H5History extends Base {
constructor (router) {
super(router)
ensureSlash()
}
setupListener () {
// 监听路由的变化
window.addEventListener('popstate', () => {
this.transitionTo(window.location.pathname)
})
}
// 获取当前的路径
getCurrentLocation () {
return window.location.pathname
}
push(location) {
return this.transitionTo(location, () => {
// 修改路由
window.history.pushState({},'',location)
})
}
}
H5History类中主要实现了对history路由变化的监听的方法setupListener,获取当前路由信息的方法getCurrentLocation,和路由跳转的方法push;H5History继承至Base类;
export default class Base {
constructor (router) {
this.router = router
// 当前路由的全部路径
this.current = createRoute(null, {
path: '/'
})
}
//
transitionTo (location, listener) {
// 通过路径获取到对应的路由信息
let record = this.router.match(location)
// 把当前路由转成{matched:[父级路由信息,当前路由信息],path:'/a/b'}的形式
const route = createRoute(record, { path: location })
// 判断是否重复进入同一个路由
if (location === this.current.path && route.matched.length === this.current.matched.length) {
return
}
// 存储对应的生命周期钩子
let queue = [].concat(this.router.beforeEachHooks)
// 执行钩子
runQueue(queue, this.current, route, () => {
// 保存当前路由信息
this.current = route
// 执行修改vue上route的方法进行修改
this.cb && this.cb(route)
listener && listener()
})
}
// 保存cb
listen (cb) {
this.cb = cb
}
}
function createRoute (record, location) {
const matched = []
while (record) {
matched.unshift(record)
record = record.parent
}
return {
...location,
matched,
}
}
// 执行生命周期钩子数组
function runQueue(queue, from ,to, cb){
function next (index) {
// 如果钩子数组执行完毕就执行cb回调
if (index >= queue.length) {
return cb()
}
// 获取到钩子
let hook = queue[index]
// 执行钩子,传递上个路由当前路由和next回调
hook(from, to, () => next(index+1))
}
next(0)
}
Base类中有两个方法分别为transitionTo和listen,transitionTo方法主要是通过路径获取到当前的路由信息,把父子路由存放到一个数组中;执行生命周期钩子,修改vue上的route属性,执行回调函数;listen方法主要是保存修改vue上的route属性的方法;
function getHash () {
return window.location.hash.slice(1)
}
class HashHistory extends Base {
constructor (router) {
super(router)
}
setupListener () {
window.addEventListener('hashchange', () => {
this.transitionTo(getHash())
})
}
// 获取当前的路径
getCurrentLocation () {
return getHash()
}
push(location) {
return this.transitionTo(location, () => {
window.location.hash = location
})
}
}
Hash类也是提供了两个方法,通过hashchange监听hash的变化,通过location.hash获取到路由;通过push修改路由;
history和hash都是通过push方法实现路由的跳转,并且其中都是调用了base中的transitionTo方法;
以上就是history和hash类的实现,下面继续分析VueRouter类的init方法;
// 初始化
init (app) {
let history = this.history
// 根据路径匹配对应的组件进行渲染,之后进行监听路由的变化
history.transitionTo(history.getCurrentLocation(), () => {
// 监听路由
history.setupListener()
})
// 路由变化之后重新给_route设置值
history.listen((newRoute) => {
app._route = newRoute
})
}
init方法是在install中的beforeCreate钩子中调用的,主要目的就是调用base类的transitionTo方法在页面首次加载的时候,获取到当前页面的路由,加载对应的组件,调用base的listen方法保存修改vue中的_route属性;
VueRouter中的match,go,back,forward和push方法
// 匹配路由并且返回路由的信息
match (location) {
return this.matcher.match(location)
}
push (location) { // 跳转路由
return this.history.push(location)
}
go (n) {
window.history.go(n)
}
back () {
this.go(-1)
}
forward () {
this.go(1)
}
push方法就是调用对应实例上的push方法,go,back,forward方法调用了history的go方法,hashchange是可以监听到history的go方法修改路由;
Base类,History类和Hash类和VueRouter类之间的关系图
以上实现了路由的跳转,下面实现路由的组件router-link和router-view
router-link
Vue.component('router-link', {
props: {
to: {
type: String
},
tag: {
type: String
}
},
methods: {
handler () {
this.$router.push(this.to)
}
},
render () {
const tag = this.tag
return <tag onClick={this.handler}>{this.$slots.default}</tag>
}
})
router-link很简单,通过render渲染元素,点击事件通过$router实例上的push进行跳转路由
routerView
export default {
functional: true, // 函数组件
render (h, { parent, data }) {
// 标记当前组件为routerView
data.routerView = true
// 获取到父级的路由信息
let route = parent.$route
// 当前路由的索引
let depth = 0
// 循环遍历获取父级
while(parent){
// 判断父级上是否有routerView,有索引就加1
if (parent.$vnode && parent.$vnode.data.routerView) {
depth++
}
parent = parent.$parent
}
// 通过索引获取到当前的路由信息
let record = route.matched[depth]
if (!record) {
return h()
}
// 渲染组件
return h(record.component, data)
},
}
routerView组件是函数组件;render中给data上添加routerView属性,表示当前组件为routerView组件,获取到$route当前路由信息,向上循环遍历判断父级的data中是否带有routerView属性,带有表示上层具有routerView组件,因此给depth加1,所有的上层都遍历结束,就能找到有几层是routerView组件,因此可以通过depth获取到当前路由对应的组件,从而通过h函数进行渲染;
在Base类中的transitionTo方法中通过createRoute函数处理当前的路由,并且返回以下格式的路由数据;
// a路由下有b路由,b路由下有c路由,并且a和b对应的组件下都有各自的routerView
{
matched: [{path:'a/',...},{path:'a/b',...},{path:'a/b/c/',...}],
path: 'a/b/c',
...
}
当前跳转到c路由,需要渲染c的组件,因此通过parent.$vnode.data.routerView一层一层的向上找,发现b组件有此属性,Depth加1此时为1,发现a组件也有此属性,depth加1此时为2;通过matched[2]就可以获取到当前c的路由信息;
总结:
- 浏览器的前进后退可以通过hashchange或popstate事件监听到
- 当调用路由提供的push方法进行路由的修改时,如果是hash模式则通过location.hash修改;history模式通过window.history.pushState修改;
- 当路由变化的时候都会去执行Base类提供的transitionTo方法,通过当前的路由获取到对应的路由信息;把路由信息的父子关系存储到一个数组中;修改vue实例上的route属性;route为响应式的;
- 修改vue实例上的route属性的时候,routerView组件中就会通过路由信息找到对应的组件,进行会重新渲染对应的组件;
以上1和2都是修改路由,3和4是当路由变化的时候渲染对应的组件
面试题:
如何实现路由权限?
通过路由钩子加上动态路由
导航守卫,从一个路由跳转到另一个路由发生了什么?
- 离开当前路由执行组件的beforeRouteLeave守卫
- 进入下一个路由之前会触发全局的beforeEach前置守卫
- 路由参数变化会执行组件的beforeRouteUpdate守卫
- 执行路由独享beforeEnter守卫
- 执行组件的beforeRouteEnter守卫
- 执行全局的beforeResolve解析守卫
- 执行全局的afterEach后置钩子
hash和history模式的区别?
- hash模式中的url携带#号,而history不存在;
- hash模式不利于seo搜索
vue-router3和vue-router4的区别?
- 原理上:vue-router3中支持ie9,所以hash模式是真正的hash相关的api;而router4中不再支持ie9,因此它里面的hash模式,都是修改地址栏上的url显示为hash模式,而内部实现还是history的api实现的;
export function createWebHashHistory(base?: string): RouterHistory {
// 基础路径处理
base = location.host ? base || location.pathname + location.search : ''
// allow the user to provide a `#` in the middle: `/base/#/app`
// base会再追加一个#符号
if (!base.includes('#')) base += '#'
// 看这里-----
return createWebHistory(base) }
- 使用上:创建的方式不同,在setup中没有this,因此需要引入router;router4中没有组件的beforeRouteEnter钩子了;
路由导航守卫和Vue实例生命周期钩子函数的执行顺序?
先路由导航守卫再生命周期钩子