dwqs/blog

vue-router 源码分析-History

dwqs opened this issue · 0 comments

dwqs commented

在前两篇文章中, 分别介绍了 vue-router整体流程组件, 对 history 的细节没有具体分析, 这一篇就具体来分析下 history 的实现.

本文分析的 vue-router 的版本为 2.6.0

History 实例化

整体流程一文中有提到, VueRouter 提供了 HTML5HistoryHashHistory 以及 AbstractHistory 三种方式. 在 VueRouter 实例化的同时, 会对 History 实例化, 源码在 src/index.js:

//...

import {HashHistory} from './history/hash'
import {HTML5History} from './history/html5'
import {AbstractHistory} from './history/abstract'

//...

export default class VueRouter{
	// ...
	
	constructor(options: RouterOptions = {}){
		// ...
		
	        // 对 mode 作检测
                // options.fallback 是2.6.0 新增, 表示是否对不支持 HTML5 history 的浏览器采用降级处理
	        // https://github.com/vuejs/vue-router/releases/tag/v2.6.0
	        let mode = options.mode || 'hash'
	        this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
	    
	         if (this.fallback) {
	              // 兼容不支持 history 的浏览器
	              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':
            	               // 传入 fallback
                               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}`)
                                 }
                   }
	}
	
	// ...
}

// ...

从上述代码可以看出, vue-router 提供了三种模式: mode(默认)、historyabstract, 三者的区别见mode.

整体流程组件一文中有提到, 所有的 History 类都是在 src/history/ 目录下, 并且都继承自 src/history/base.js. 下面会分别作具体分析.

HTML5History

HTML5History 是利用 HTML5 History 的 API pushState/repaceState 来完成 URL 跳转而无须重新加载页面; 源码在 src/history/html5.js 中:

// ...
import {History} from './base'
import {cleanPath} from '../util/path'
import {setupScroll, handleScroll} from '../util/scroll'
import {pushState, replaceState} from '../util/push-state'

export class HTML5History extends History {
    constructor(router: Router, base: ?string) {
    	 // 调用基类构造函数
        super(router, base)
		 
        // 获取路由的滚动行为	
        const expectScroll = router.options.scrollBehavior

        // 处理滚动
        if (expectScroll) {
            setupScroll()
        }

        // 监听 popstate 事件
        // 点击浏览器前进后退 或者调用 history api 时触发
        // pushState/replaceState 不会触发该事件
        // http://javascript.ruanyifeng.com/bom/history.html#toc4
        window.addEventListener('popstate', e => {
            // 当前 route
            const current = this.current
            
            // 导航过渡
            this.transitionTo(getLocation(this.base), route => {
                if (expectScroll) {
                    // 处理滚动
                    handleScroll(router, route, current, true)
                }
            })
        })
    }
	
   // html5 history api
    go(n: number) {
        window.history.go(n)
    }

    push(location: RawLocation, onComplete?: Function, onAbort?: Function) {
        
    }

    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)
    }
	
    // ...
}

// 获取 location
export function getLocation(base: string): string {
    let path = window.location.pathname
    if (base && path.indexOf(base) === 0) {
        path = path.slice(base.length)
    }
    return (path || '/') + window.location.search + window.location.hash
}

从上述代码可以看到, history 模式是比较简单的:

  • 调用基类的构造函数进行初始化
  • 监听 popstate
  • history api 调用

HashHistory

hash 模式是一种降级方案, 也是默认模式. history 模式存在兼容性问题, 但 hash 模式是被所有浏览器支持的. 在 vue-router@2.6.0 中, 提供了 fallback 属性用于 history 模式下的降级处理, 详情见tag#v2.6.0 源码在 src/history/hash.js 中:

// ...
import {History} from './base'
import {cleanPath} from '../util/path'
import {getLocation} from './html5'

export class HashHistory extends History {
    constructor(router: Router, base: ?string, fallback: boolean) {
    	  // 调用基类构造函数
        super(router, base)
       
        // 降级检查
        if (fallback && checkFallback(this.base)) {
            return
        }
        // 保证 hash 是以 / 开头
        ensureSlash()
    }

    // 等到 app mount 之后才设置 hashchange 的监听  
    // https://github.com/vuejs/vue-router/issues/725
    setupListeners() {
        window.addEventListener('hashchange', () => {
            if (!ensureSlash()) {
                return
            }
            this.transitionTo(getHash(), route => {
                // hash 替换 route 中的 path
                replaceHash(route.fullPath)
            })
        })
    }

    push(location: RawLocation, onComplete?: Function, onAbort?: Function) {
    	  // 在回调中调用 pushHash
    	  this.transitionTo(...)
    }

    replace(location: RawLocation, onComplete?: Function, onAbort?: Function) {
        // 在回调中调用 replaceHash
    	  this.transitionTo(...)
    }

    go(n: number) {
        window.history.go(n)
    }

   // ...
}

function checkFallback(base) {
    // 得到不含 base 的 location 值
    // hash 模式下的导航以 /# 开始的
    const location = getLocation(base)
    if (!/^\/#/.test(location)) {
        // 如果说此时的地址不是以 /# 开头的
        // 需要做一次 url 替换处理
        window.location.replace(
            cleanPath(base + '/#' + location)
        )
        return true
    }
}


function ensureSlash(): boolean {
    // 获取当前 url 的 hash 值
    const path = getHash()
    // 以 / 开头 直接返回
    if (path.charAt(0) === '/') {
        return true
    }
    // 否则替换 hash 值
    replaceHash('/' + path)
    return false
}

export function getHash(): string {
    const href = window.location.href
    const index = href.indexOf('#')
    
    // 如果此时没有 # 则返回 ''
    // 否则 取得 # 后的所有内容
    return index === -1 ? '' : href.slice(index + 1)
}

// transitionTo 的回调里调用
function pushHash(path) {
    window.location.hash = path
}

// transitionTo 的回调里调用
function replaceHash(path) {
    const href = window.location.href
    const i = href.indexOf('#')
    const base = i >= 0 ? href.slice(0, i) : href
    window.location.replace(`${base}#${path}`)
}

从代码可知, 与 HTML5History 不同, 并没有在 constructor 中作 hashchange 的监听, setupListeners 是在 router.init 方法中调用的:

// ...

// Router 初始化
init(app: any /* Vue component instance */){
	if (history instanceof HTML5History) {
         history.transitionTo(history.getCurrentLocation())
	} else if (history instanceof HashHistory) {
	      
	      const setupHashListener = () => {
	      	   // 设置 hashchange 监听
	            history.setupListeners()
	       }
	       history.transitionTo(
	            history.getCurrentLocation(),
	            setupHashListener,
	            setupHashListener
	       )
	}
}

// ...

是在 route 切换完成后的回调中设置的, 这是为了修复 vuejs/vue-router#725, 避免 beforeEnter 是异步的情况下, beforeEnter 被调用两次.

此外, 我们都知道可以通过 window.location.hash 来获取 url 的 hash 部分, 但在 getHash() 方法却没有使用, 这样处理的原因是低版本的 Firefox 会对 hash 进行编码, 具体见 Firefox automatically decoding encoded parameter in url, does not happen in IE.

AbstractHistory

这种模式和浏览器无关, 一般用于 Node 端测试, 其实现也是最简单的:

// ...

export class AbstractHistory extends History {
	constructor(router: Router, base: ?string) {
       // 调用基类构造函数
        super(router, base)
        
        // 初始化记录栈
        this.stack = []
        // 记录的当前位置
        this.index = -1
    }
    
    // replace/go/push 的模拟...
}

该模式比较抽象, 仅用一个数组来模拟浏览器的历史记录, 通过位置变量来获取当前的记录.

三种模式的初始化就大致介绍完了, 现在看看浏览器的 history 改变会发生什么?

history 改变

有两种方式可以改变浏览器的 history:

  • 点击 router-link 组件
  • 点击浏览器的前进后退按钮

浏览器的 history 发生改变时, 会触发 window 的相关的事件: hashchangepopstate.

hash 模式下:

// ...
window.addEventListener('hashchange', () => {
    // ...
    this.transitionTo(getHash(), route => {
        // 回调处理
    })
})

// ...

history 模式下:

// ...
window.addEventListener('popstate', e => {
    const current = this.current
    this.transitionTo(getLocation(this.base), route => {
        if (expectScroll) {
            // 处理滚动
            handleScroll(router, route, current, true)
        }
    })
})

// ...

vue-router 源码分析-组件一文中, 已经介绍过 router-link 组件, 其事件绑定如下:

// ...
// router-link 的 event 绑定
function guardEvent(e) {
    // 忽略功能键的点击跳转
    if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return

    // 已经阻止
    if (e.defaultPrevented) return
    
    // 右击不跳转
    if (e.button !== undefined && e.button !== 0) return
    
    // 忽略 `target="_blank"
    if (e.currentTarget && e.currentTarget.getAttribute) {
        const target = e.currentTarget.getAttribute('target')
        if (/\b_blank\b/i.test(target)) return
    }
    
    // 阻止默认行为
    if (e.preventDefault) {
        e.preventDefault()
    }
    return true
}

//...

event 触发时, 会调用 routerpush/replace 来更新路由, 其实现在 src/index.js:

// ...

export default class VueRouter{
	// ...
	constructor(options: RouterOptions = {}) {
		// ...
	}
	
	// ...
	
	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)
	}
	
	// ...
}

这里可以看出, 会去调用各子类的对应实现.

// ...
push(location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.transitionTo(// ...)
}

replace(location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.transitionTo(// ...)
}

//...

整体流程一文中大致介绍了 transitionTo的处理流程, 但忽略了很多细节. 如果想了解更多细节, 请移步到 vue-router 源码分析-history, vue-router 的版本虽然不一样, 但整个过程大致是一样的.

小结

vue-router 虽然提供了三种模式, 但是执行的整体流程差异不大, 最大的差异是在 history 改变时的具体处理逻辑不同.