3/22 从源码角度浅谈vue-router的两种模式

前面的话

前端日问,巩固基础,不打烊!!!

解答

回答思路: 路由的两种模式。

为什么有前端路由,最大的好处是:更新视图但不重新请求页面。

浏览器中实现主要有两种方式:

  • hash模式:利用URL中的hash,也就是锚点(“#”)
  • history模式:HTML5中新增方法

如果不在浏览器环境则使用abstract模式(node环境下)

为什么这两种模式实现了更新视图但不请求页面?

  • hash模式

    一般,我们将url中#和它之后的字符一起称为hash,通过window.location.hash来读取。

    特点:

    • hash虽然出现在url中,但是不会被包括在HTTP请求中。它是用来指导浏览器动作的,对服务端没有影响。所以,改变hash不会重新加载页面。
    • hash值的变化可以通过监听 hashchange事件来得到window.addEventListener("hashchange", funcRef, false);每一次改变hash值,都会在浏览器的访问历史中增加一个记录。
  • history模式

    history interface 是浏览器历史记录栈 提供的接口,通过back()、forward()、go()等方法,我们可以读取到历史记录栈的信息,进行各种跳转。

这样了解这两种模式的特点,就理解了它们是如何实现了更新视图,但不请求页面的功能。

如何设置

在vue-router中通过mode参数来设置:

const  router = new VueRouter({
	mode: "XXX",
	routes: [...]
})

当你设置了mode参数值之后,程序会根据你的选择创建不同的history对象(HashHistory / HTML5History / AbstractHistory),看看源码就晓得啦:

export default class VueRouter {
  
  mode: string; // 传入的字符串参数,指示history类别
  history: HashHistory | HTML5History | AbstractHistory; // 实际起作用的对象属性,必须是以上三个类的枚举
  fallback: boolean; // 如浏览器不支持,'history'模式需回滚为'hash'模式
  
  constructor (options: RouterOptions = {}) {    
    let mode = options.mode || 'hash' // 默认为'hash'模式
    this.fallback = mode === 'history' && !supportsPushState // 通过supportsPushState判断浏览器是否支持'history'模式
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract' // 不在浏览器环境下运行需强制为'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}`)
        }
    }
  }
  // 初始化操作, app表示组件实例 
  init (app: any /* Vue component instance */) {
    // ...
    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }

    this.app = app
    const history = this.history

    // 根据history的类别执行相应的初始化操作和监听
    if (history instanceof HTML5History) {
   	  // html5模式
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      // hash模式 
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }
	
	// 切换路由
    history.listen(route => {
      this.apps.forEach((app) => {
        app._route = route
      })
    })
  }
  // 一些钩子函数 beforeEach、 afterEach等
  // ...
  // ...
  

  // VueRouter类暴露的以下方法实际是调用具体history对象的方法
  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)
  }
}
 // 相对于当前页面向前或向后跳转多少个页面,类似 window.history.go(n)。n可为正数可为负数。正数返回上一个页面
  go (n: number) {
    this.history.go(n)
  }
  // 后退到上一个页面
  back () {
    this.go(-1)
  }
  // 前进到下一个页面
  forward () {
    this.go(1)
  }

 // ...
 // ...

总结:

  • 浏览器默认是hash模式,当浏览器不支持html5的history模式时,也会强制为hash模式;当环境不是浏览器时,强制为abstract模式。
  • 当创建VueRouter实例后,VueRouter构造函数会通过传入对象options的mode参数,来调用对应的(HashHistory / HTML5History / AbstractHistory)构造函数,进而创建对应的history实例对象。
  • 创建相应的history实例后,我们可以看到init函数里面会有对应的初始化操作。
  • 我们都知道$router实例有两个常见的跳转方法:push与replace方法,源码的最下面,就暴露出来这两个方法。很显然这两种方法都是对不同模式下的方法的封装,本质还是执行的对应模式上的方法。

我们接着看,$router.push$router.replace是如何封装的。

HashHistory

HashHistory中的push()方法:
	push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
	  // 执行transitionTo函数
	  this.transitionTo(location, route => {
	  	// 改变hash值
	    pushHash(route.fullPath)
	    onComplete && onComplete(route)
	  }, onAbort)
	}
	
	function pushHash (path) {
	  window.location.hash = path
	}

pushHash直接对window.location.hash赋值,hash值变化之后,浏览器访问历史中就会增加一个记录。

记录增加了,如何更新视图呢?接着看父类History中transitionTo函数是如何实现的。

transtitionTo函数的实现:

	transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
		//找到匹配路由(match函数)
	    const route = this.router.match(location, this.current) 
	    this.confirmTransition(route, () => { //确认是否转化
	      this.updateRoute(route) //更新route
	      // ...
	    })
	  }
	  
	//更新路由
	updateRoute (route: Route) {
	    const prev = this.current // 跳转前路由
	    this.current = route // 装备跳转路由
	    this.cb && this.cb(route) // 回调函数,这一步很重要,这个回调函数在index文件中注册,会更新被劫持的数据 _router
	    this.router.afterHooks.forEach(hook => {
	      hook && hook(route, prev)
	    })
	  }
	}
	
    // this.cb函数的定义 
    listen (cb: Function) {
  	this.cb = cb	
	}
	

transitionTo函数中,执行了updateRoute更新路由函数,这个函数中执行了cb这个函数,cb这个函数是在VueRouter实例中的init函数中通过history.listen传入的。

init (app: any /* Vue component instance */) {  
	   // ...  
	  this.apps.push(app)
	  // ...
	  history.listen(route => {
	    this.apps.forEach((app) => {
	      app._route = route
	    })
	  })
	}

现在我们从$router.push 分析到了cb()函数, 再接着看cb函数,这个函数中有一个_route属性,并且将匹配的路由route赋给了app._route。(这里app是组件实例,apps是所有组件实例一个数组) 。

那么_route属性是什么,我们本身的组件中是没有定义这个属性的,那么这个属性从哪里来的呢?源码中在install()方法中使用Vue.mixin()方法添加一个全局的混合对象:

install.js代码里面做了四件事

1、混入beforeCreate函数,在里面定义_route这个响应式属性。
2、混入destroyed函数。
3、在Vue.prototype上定义$router$route这两个对象,以便每个组件都可以获得
4、在Vue上注册router-linkrouter-view这两个组件

	// install.js代码:
	export function install (Vue) {
	  if (install.installed && _Vue === Vue) return
	  install.installed = true
	
	  _Vue = Vue
	
	  const isDef = v => v !== undefined
	
	  const registerInstance = (vm, callVal) => {
	    let i = vm.$options._parentVnode
	    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
	      i(vm, callVal)
	    }
	  }
	  
	 // 使用mixin会在每个.vue文件中进行beforeCreate和destroyed 
	  Vue.mixin({
	    //对每个Vue实例混入beforeCreate钩子操作
	    //验证Vue实例是否有router对象了,如果有,就不再初始化了
	    beforeCreate () {
	    // 如果没有router对象(this.$option.router来自于VueRouter实例router对象)
	      if (isDef(this.$options.router)) { 
	       // 将_routerRoot指向当前组件
	        this._routerRoot = this
	       // 将router对象挂载到根组件的_router上
	        this._router = this.$options.router
	        //  调用VueRouter实例的init方法
	        this._router.init(this)
	        // 劫持数据_route,一旦_route数据变化之后,通知router-view执行render方法
	        Vue.util.defineReactive(this, '_route', this._router.history.current)
	      } else {
			// 如果有router对象,则将每一个组件的_routerRoot指向根Vue实例
	        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
	      }
	      // 注册vuecomponent实例
	      registerInstance(this, this)
	    },
	    destroyed () {
	     // 销毁vuecomponent实例
	      registerInstance(this)
	    }
	  })
	  //通过Vue.prototype定义$router$route 属性(方便所有组件可以获取这两个属性)
	  Object.defineProperty(Vue.prototype, '$router', {
	    get () { return this._routerRoot._router }
	  })
	
	  Object.defineProperty(Vue.prototype, '$route', {
	    get () { return this._routerRoot._route }
	  })
	  //Vue上注册router-link和router-view两个组件
	  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
	}

在beforeCreate钩子中,通过Vue.util.defineReactive()创建一个响应式_route属性。router-view组件挂载时,会生成一个watcher触发_route属性的getter,进行依赖收集watcher,当hash值变化时,匹配到新的路由route后,_route属性跟着改变,最后触发_route的setter,执行watcher的更新函数,进而触发的render函数,更新视图。`

总结:

写了这么多,总结一下大致流程:
$router.push-> history.push -> transitionTo->updateRoute->cb(即 app._route = route) -> rander

  • 触发$router.pash()
  • 触发 HashHistory.push()
  • 触发transitionTo() : 根据传入的location找到匹配的route,触发updateRoute().
  • 触发updateRoute(): 将匹配的route赋给实例app的_route响应式属性, 当_route属性变化时,就会触发实例的rander函数,更新视图。
HashHistory的replace()方法

理解前面的push(),replace()就很好理解了.

replace()与push()方法不同的是:它是直接替换掉当前的路由,而不是像浏览器记录中添加一个记录。

replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  this.transitionTo(location, route => {
    replaceHash(route.fullPath)
    onComplete && onComplete(route)
  }, onAbort)
}
  
function replaceHash (path) {
  const i = window.location.href.indexOf('#')
  window.location.replace(
    window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
  )
}

它不是直接对window.location.hash进行赋值,而是调用window.location。replace方法进行路由替换。

监听地址栏

除了使用上面的方法进行路由更新,用户还可以直接在url上输入来改变路由,所有VueRouter中还有一个方法是监听地址栏的。通过setupListeners实现:

setupListeners () {
    window.addEventListener('hashchange', () => {
        if (!ensureSlash()) {
            return
        }
        this.transitionTo(getHash(), route => {
            replaceHash(route.fullPath)
        })
    })
}

HTML5History

HTML5History就比较强了,不仅可以通过back()、forward()、go()方法来读取浏览器历史记录栈的信息,进行各种跳转,还新增两个方法:pushState()replaceState()使得我们可以对浏览器历史记录栈进行修改:

window.history.pushState(stateObject, title, URL)
window.history.replaceState(stateObject, title, URL)
  • stateObject: 当浏览器跳转到新的状态时,将触发popState事件,该事件将携带这个stateObject参数的副本

  • title: 所添加记录的标题

  • URL: 所添加记录的URL

这两个方法有个共同的特点:当调用他们修改浏览器历史记录栈后,虽然当前URL改变了,但浏览器不会立即发送请求该URL。

HTML5History的push方法
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)
}

跟前面hash模式很像,只不过换成了pushState(cleanPath(this.base + route.fullPath)):

export function pushState (url?: string, replace?: boolean) {
  saveScrollPosition()
  // 加了 try...catch 是因为 Safari 有调用 pushState 100 次限制
  // 一旦达到就会抛出 DOM Exception 18 错误
  const history = window.history
  try {
    if (replace) {
    // replace 的话 key 还是当前的 key 没必要生成新的
      history.replaceState({ key: _key }, '', url)
    } else {
    // 重新生成 key
      _key = genKey()
       // 带入新的 key 值
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
  // 达到限制了 则重新指定新的地址
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

HTML5History的replaceState方法
// 直接调用 pushState 传入 replace 为 true
export function replaceState (url?: string) {
  pushState(url, true)
}

监听地址栏

直接通过监听popstate事件:

constructor (router: Router, base: ?string) {
  
  window.addEventListener('popstate', e => {
    const current = this.current
    this.transitionTo(getLocation(this.base), route => {
      if (expectScroll) {
        handleScroll(router, route, current, true)
      }
    })
  })
}

两种模式的比较

  • hash模式

    缺点:样式"丑",hash模式只能改变hash部分的内容,只能设置与当前同文档的URL

    优点:hash部分是不会包含在http请求中的。

  • HTML5History模式:

    优点:

    1、调用history.pushState()可以直接设置新的URL,可以是与当前URL同源的任意URL。
    2、pushState通过stateObject可以添加任意类型的数据到记录中;而hash只可添加短字符串
    3、pushState可额外设置title属性供后续使用

    缺点:
    在history模式下:用户直接在地址栏中输入并回车,浏览器重启重新加载应用。这时history模式则会将URL修改得就和正常请求后端的URL一样,如果没有请求到资源,则会返回404。所有我们一般会自己添加一个404页面。

参考文章:

  • 从vue-router看前端路由的两种实现
  • 添加链接描述

你可能感兴趣的:(#,Vue)