本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐
源码专栏
感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:
开场
哈喽大咖好,我是跑手,本次给大家继续探讨vue-router@4.x
源码中有关Web History API能力的部分,也就是官方文档中历史模式。
大家多少有点了解,包括react router
、vue-router
在内大多数单页路由库,是基于 H5 History API能力来实现的。History API其实做的事情也很简单,就是改变当前web URL而不与服务器交互,完成纯前端页面的URL变型。
撰写目的
在这篇文章里,你能获得以下增益:
- 了解
vue-router
中对Web History API能力的应用。 - 了解
createWebHistory
和createWebHashHistory
的实现原理。
事不宜迟,开讲。。。
。。
。
Web History API
在H5 History API完成页面url变化有2个重要函数:pushState()
和 replaceState()
,它们的差异无非就是
举个沉浸式例子
我们随便打开一个页面,在控制台查看下原始History
是这样的,其中length
是一个只读属性,代表当前 session记录的页面历史数量(包括当前页)。
然后再执行这段代码,有得到如下效果:浏览器url发生了变化,但页面内容没有任何改动:
history.pushState( { myName: 'test', state: { page: 1, index: 2 } }, 'div title', '/divPath' ) 复制代码
我们再看看History
内容,如下图:
会发现和之前的变化有:
length
由 2 变 3。虽然页面不跳转,但我们执行pushState
时往history堆栈中插入了一条新数据,所以依旧被History
对象收录,因此length
加1;scrollRestoration
是描述页面滚动属性,auto
|manual
: 分别表示自动 | 手动恢复页面滚动位置,在vue-router滚动行为中就用到这块的能力;- History.state值变成了我们在
pushState
传的第一个参数,理论上这个参数可以是任意对象,这也是单页应用在路由跳转时可以随心所欲传值的关键。另外如果不是pushState()
和replaceState()
调用,state 的值将会是 null。
服务器适配
用pushState()
和 replaceState()
改变URL确实也有个通病,就是刷新页面报404,因为刷新行为属于浏览器与后台服务通信的默认行为,服务器没法解析前端自定义path而导致404错误。
要解决这个问题,你需要在服务器上添加一个简单的回退路由,如果 URL 不匹配任何静态资源,直接回退到 index.html。
结论
说了那么多,总结下Web History API能给我们带来:
- 在不与服务端交互情况下改变页面url,给单页路由应用带来可玩(有)性(戏)
- 能传值,并且能在history栈顶的state读到这些值,解决单页之间的跳转数据传输问题
- 兼容性好,主流和不是那么主流的客户端都兼容
基于此,各类的路由库应用应运而生,当然vue-router
也是其中之一。
createWebHistory
创建一个适配Vue的 H5 History记录,需要用到createWebHistory
方法,入参是一个路径字符串,表示history的根路径,返回是一个vue的history对象,返回类型定义如下:
Typescript类型:
export declare function createWebHistory(base?: string): RouterHistory /** * Interface implemented by History implementations that can be passed to the * router as {@link Router.history} * * @alpha */ export interface RouterHistory { /** * Base path that is prepended to every url. This allows hosting an SPA at a * sub-folder of a domain like `example.com/sub-folder` by having a `base` of * `/sub-folder` */ readonly base: string /** * Current History location */ readonly location: HistoryLocation /** * Current History state */ readonly state: HistoryState // readonly location: ValueContainer<HistoryLocationNormalized> /** * Navigates to a location. In the case of an HTML5 History implementation, * this will call `history.pushState` to effectively change the URL. * * @param to - location to push * @param data - optional {@link HistoryState} to be associated with the * navigation entry */ push(to: HistoryLocation, data?: HistoryState): void /** * Same as {@link RouterHistory.push} but performs a `history.replaceState` * instead of `history.pushState` * * @param to - location to set * @param data - optional {@link HistoryState} to be associated with the * navigation entry */ replace(to: HistoryLocation, data?: HistoryState): void /** * Traverses history in a given direction. * * @example * ```js * myHistory.go(-1) // equivalent to window.history.back() * myHistory.go(1) // equivalent to window.history.forward() * ``` * * @param delta - distance to travel. If delta is < 0, it will go back, * if it's > 0, it will go forward by that amount of entries. * @param triggerListeners - whether this should trigger listeners attached to * the history */ go(delta: number, triggerListeners?: boolean): void /** * Attach a listener to the History implementation that is triggered when the * navigation is triggered from outside (like the Browser back and forward * buttons) or when passing `true` to {@link RouterHistory.back} and * {@link RouterHistory.forward} * * @param callback - listener to attach * @returns a callback to remove the listener */ listen(callback: NavigationCallback): () => void /** * Generates the corresponding href to be used in an anchor tag. * * @param location - history location that should create an href */ createHref(location: HistoryLocation): string /** * Clears any event listener attached by the history implementation. */ destroy(): void } 复制代码
在《vue router 4 源码篇:路由诞生——createRouter原理探索》中讲到,createRouter
创建vue-router实例时,会添加单页跳转时的监听回调,其能力源于本方法createWebHistory
创建的history对象。该对象中导出的方法(如:listen、destroy、push等等...),都是依托了原生Web History API能力,并且结合了Vue技术而封装的中间层SDK,把两者连接起来。
实现原理流程图
createWebHistory
总流程非常简单,分4步走:
- 创建
vue router
的history对象,包含4个属性:location
(当前location)、state
(路由页面的history state)、和push
、replace
2个方法; - 创建
vue router
监听器:主要支持路由跳转时的state处理和自定义的跳转逻辑回调; - 添加location劫持,当
routerHistory.location
变动时返回标准化的路径; - 添加state劫持,当
routerHistory.state
变动时返回里面的state;
步骤对应的源码如下「附注释」:
/** * Creates an HTML5 history. Most common history for single page applications. * * @param base - */ export function createWebHistory(base?: string): RouterHistory { base = normalizeBase(base) // 步骤1:创建`vue router` 的history对象 const historyNavigation = useHistoryStateNavigation(base) // 步骤2:创建`vue router` 监听器 const historyListeners = useHistoryListeners( base, historyNavigation.state, historyNavigation.location, historyNavigation.replace ) function go(delta: number, triggerListeners = true) { if (!triggerListeners) historyListeners.pauseListeners() history.go(delta) } // 组装routerHistory对象 const routerHistory: RouterHistory = assign( { // it's overridden right after location: '', base, go, createHref: createHref.bind(null, base), }, historyNavigation, historyListeners ) // 步骤3:添加location劫持 Object.defineProperty(routerHistory, 'location', { enumerable: true, get: () => historyNavigation.location.value, }) // 步骤4:添加state劫持 Object.defineProperty(routerHistory, 'state', { enumerable: true, get: () => historyNavigation.state.value, }) // 返回整个router History对象 return routerHistory } 复制代码
最后,createWebHistory
方法返回处理好后的routerHistory
对象,供createRouter
使用。
接下来,我们跟着源码,拆分上面四个流程,看具体是怎么实现的。
创建History
第一步,创建vue router
的history对象,在上面源码用useHistoryStateNavigation
方法来创建这个对象,方便大家理解,笔者简化一个流程图:
流程图
从左到右,vue router history
使用了H5 History能力。其中history.pushState
和history.replaceState
方法被封装到一个名为locationChange
的路径变化处理函数中,而locationChange
作为一个公共函数,则被push 和 replace 函数调用,这2个函数,也就是我们熟知的Router push 和 Router replace 方法。
另外,vue router history
的state对象底层也是用到了history.state
,只不过再封装成符合vue router的state罢了。
最后,useHistoryStateNavigation
方法把push、replace、state、location集成到一个对象中返回,完成了history的初始化。
源码解析
changeLocation
先看changeLocation
,源码如下:
function changeLocation( to: HistoryLocation, state: StateEntry, replace: boolean ): void { /** * if a base tag is provided, and we are on a normal domain, we have to * respect the provided `base` attribute because pushState() will use it and * potentially erase anything before the `#` like at * https://github.com/vuejs/router/issues/685 where a base of * `/folder/#` but a base of `/` would erase the `/folder/` section. If * there is no host, the `<base>` tag makes no sense and if there isn't a * base tag we can just use everything after the `#`. */ const hashIndex = base.indexOf('#') const url = hashIndex > -1 ? (location.host && document.querySelector('base') ? base : base.slice(hashIndex)) + to : createBaseLocation() + base + to try { // BROWSER QUIRK // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds history[replace ? 'replaceState' : 'pushState'](state, '', url) historyState.value = state } catch (err) { if (__DEV__) { warn('Error with push/replace State', err) } else { console.error(err) } // Force the navigation, this also resets the call count location[replace ? 'replace' : 'assign'](url) } } 复制代码
首先是结合base根路径计算最终的跳转url,然后根据replace标记决定使用history.pushState
或 history.replaceState
进行跳转。
buildState
replace和push里都使用到一个公共函数buildState
,这函数作用是在原来state中添加页面滚动位置记录,方便页面回退时滚动到原来位置。
/** * Creates a state object */ function buildState( back: HistoryLocation | null, current: HistoryLocation, forward: HistoryLocation | null, replaced: boolean = false, computeScroll: boolean = false ): StateEntry { return { back, current, forward, replaced, position: window.history.length, scroll: computeScroll ? computeScrollPosition() : null, } } // computeScrollPosition方法定义 export const computeScrollPosition = () => ({ left: window.pageXOffset, top: window.pageYOffset, } as _ScrollPositionNormalized) 复制代码
replace
replace方法实现也比较简单:先把state和传进来的data整合得到一个最终state,再调用changeLocation
进行跳转,最后更新下当前Location变量。
function replace(to: HistoryLocation, data?: HistoryState) { const state: StateEntry = assign( {}, history.state, buildState( historyState.value.back, // keep back and forward entries but override current position to, historyState.value.forward, true ), data, { position: historyState.value.position } ) changeLocation(to, state, true) currentLocation.value = to } 复制代码
push
function push(to: HistoryLocation, data?: HistoryState) { // Add to current entry the information of where we are going // as well as saving the current position const currentState = assign( {}, // use current history state to gracefully handle a wrong call to // history.replaceState // https://github.com/vuejs/router/issues/366 historyState.value, history.state as Partial<StateEntry> | null, { forward: to, scroll: computeScrollPosition(), } ) if (__DEV__ && !history.state) { warn( `history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` + `history.replaceState(history.state, '', url)\n\n` + `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.` ) } changeLocation(currentState.current, currentState, true) const state: StateEntry = assign( {}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data ) changeLocation(to, state, false) currentLocation.value = to } 复制代码
和replace差不多,都是调用changeLocation
完成跳转,但是push方法会跳转2次:第一次是给router history添加forward和scroll的中间跳转,其作用是保存当前页面的滚动位置。
为什么要2次跳转才能保存页面位置? 大家试想下,当你浏览一个页面,滚动到某个位置,你利用history.pushState
跳转到另一个页面时,history堆栈会压入一条记录,但同时vue router会帮助你记录跳转前页面位置,以便在回退时恢复滚动位置。要实现这个效果,就必须在push方法中,在调用changeLocation
前把当前页面位置记录到router state中。
要实现这个功能方法有多种,最简单方法就是在跳转前把位置信息记录好放进state里面,然后通过changeLocation(to, state, false)
实现跳转。
但官方用了另一种优雅方法解决这个问题,就是在最终跳转前先来一次replace模式的中间跳转,这样在不破坏原页面信息基础上更新了router state,省去更多与页面位置相关的连带处理。这就有了push
方法中2次调用changeLocation
。
至此,vue router history
的创建流程全部执行完成,但仅仅依靠history的改变是不够的,下面我们再看看监听器的实现过程。
创建路由监听器
流程图
众所周知,history.go
、history.forward
、history.back
都会触发popstate
事件,然后再将popStateHandler
方法绑定到popstate
事件即可实现路由跳转监听。
而页面关闭或离开时会触发beforeunload
事件,同理将beforeUnloadListener
方法绑定到该事件上实现对此类场景的监控。
最后为了能自定义监控逻辑,监听器抛出了3个钩子函数:pauseListeners
「停止监听」、listen
「注册监听回调,符合订阅发布模式」、destroy
「卸载监听器」。
源码解析
popStateHandler
const popStateHandler: PopStateListener = ({ state, }: { state: StateEntry | null }) => { // 新跳转地址 const to = createCurrentLocation(base, location) // 当前路由地址 const from: HistoryLocation = currentLocation.value // 当前state const fromState: StateEntry = historyState.value // 计步器 let delta = 0 if (state) { // 目标路由state不为空时,更新currentLocation和historyState缓存 currentLocation.value = to historyState.value = state // 暂停监控时,中断跳转并重置pauseState if (pauseState && pauseState === from) { pauseState = null return } // 计算距离 delta = fromState ? state.position - fromState.position : 0 } else { // 否则执行replace回调 replace(to) } // console.log({ deltaFromCurrent }) // Here we could also revert the navigation by calling history.go(-delta) // this listener will have to be adapted to not trigger again and to wait for the url // to be updated before triggering the listeners. Some kind of validation function would also // need to be passed to the listeners so the navigation can be accepted // call all listeners // 发布跳转事件,将Location、跳转类型、跳转距离等信息返回给所有注册的订阅者,并执行注册回调 listeners.forEach(listener => { listener(currentLocation.value, from, { delta, type: NavigationType.pop, direction: delta ? delta > 0 ? NavigationDirection.forward : NavigationDirection.back : NavigationDirection.unknown, }) }) } 复制代码
纵观而视,popStateHandler
在路由跳转时,做了这些事情:
- 更新history的location和state等信息,使得缓存信息同步;
- 暂停监控时,中断跳转并重置pauseState;
- 将必要信息告知所有注册的订阅者,并执行注册回调;
beforeUnloadListener
function beforeUnloadListener() { const { history } = window if (!history.state) return history.replaceState( assign({}, history.state, { scroll: computeScrollPosition() }), '' ) } 复制代码
关闭页面前会执行这个方法,主要作用是记录下当前页面滚动。
3个listener hooks
// 暂停监听 function pauseListeners() { pauseState = currentLocation.value } // 注册监听逻辑 function listen(callback: NavigationCallback) { // set up the listener and prepare teardown callbacks listeners.push(callback) const teardown = () => { const index = listeners.indexOf(callback) if (index > -1) listeners.splice(index, 1) } teardowns.push(teardown) return teardown } // 监听器销毁 function destroy() { for (const teardown of teardowns) teardown() teardowns = [] window.removeEventListener('popstate', popStateHandler) window.removeEventListener('beforeunload', beforeUnloadListener) } 复制代码
添加location和state劫持
Object.defineProperty(routerHistory, 'location', { enumerable: true, get: () => historyNavigation.location.value, }) Object.defineProperty(routerHistory, 'state', { enumerable: true, get: () => historyNavigation.state.value, }) 复制代码
这里没啥好说的,就是读取routerHistory.location
或routerHistory.state
时能获取到historyNavigation
方法中的内容。
到这里就是createWebHistory
如何结合vue创建出一个router history
的整个过程了。
createWebHashHistory
createMemoryHistory
主要创建一个基于内存的历史记录,这个历史记录的主要目的是处理 SSR。
其逻辑和createWebHistory
大同小异,都是通过history和监听器实现,只不过在服务器场景中,没有window对象,也没法用到H5 History API能力,所以history用了一个queue
(队列)代替,而监听器也是消费队列完成路由切换。以下是关键源码:
/** * Creates an in-memory based history. The main purpose of this history is to handle SSR. It starts in a special location that is nowhere. * It's up to the user to replace that location with the starter location by either calling `router.push` or `router.replace`. * * @param base - Base applied to all urls, defaults to '/' * @returns a history object that can be passed to the router constructor */ export function createMemoryHistory(base: string = ''): RouterHistory { let listeners: NavigationCallback[] = [] let queue: HistoryLocation[] = [START] let position: number = 0 base = normalizeBase(base) // 通过position(计步器)改变queue达到路由跳转效果 function setLocation(location: HistoryLocation) { position++ if (position === queue.length) { // we are at the end, we can simply append a new entry queue.push(location) } else { // we are in the middle, we remove everything from here in the queue queue.splice(position) queue.push(location) } } // 监听器触发 function triggerListeners( to: HistoryLocation, from: HistoryLocation, { direction, delta }: Pick<NavigationInformation, 'direction' | 'delta'> ): void { const info: NavigationInformation = { direction, delta, type: NavigationType.pop, } for (const callback of listeners) { callback(to, from, info) } } // 构建router history const routerHistory: RouterHistory = { // rewritten by Object.defineProperty location: START, // TODO: should be kept in queue state: {}, base, createHref: createHref.bind(null, base), // replace方法 replace(to) { // remove current entry and decrement position queue.splice(position--, 1) setLocation(to) }, // push方法 // 这2种方法都是调用setLocation来改变queue push(to, data?: HistoryState) { setLocation(to) }, // 添加监听回调 listen(callback) { listeners.push(callback) return () => { const index = listeners.indexOf(callback) if (index > -1) listeners.splice(index, 1) } }, destroy() { listeners = [] queue = [START] position = 0 }, go(delta, shouldTrigger = true) { const from = this.location const direction: NavigationDirection = // we are considering delta === 0 going forward, but in abstract mode // using 0 for the delta doesn't make sense like it does in html5 where // it reloads the page delta < 0 ? NavigationDirection.back : NavigationDirection.forward position = Math.max(0, Math.min(position + delta, queue.length - 1)) if (shouldTrigger) { triggerListeners(this.location, from, { direction, delta, }) } }, } // 增加获取数据劫持 Object.defineProperty(routerHistory, 'location', { enumerable: true, get: () => queue[position], }) // 针对单测时处理 if (__TEST__) { // ... } return routerHistory } 复制代码
落幕
好了好了,这节先到这里,最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹