本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐
感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:《vue router 4 源码篇:路由诞生——createRouter原理探索》 《vue router 4 源码篇:路由matcher的前世今生》 《vue router 4 源码篇:router history的原生结合》 《vue router 4 源码篇:导航守卫该如何设计(一)》 《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
的实现原理。事不宜迟,开讲。。。
。。
。
在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滚动行为中就用到这块的能力;pushState
传的第一个参数,理论上这个参数可以是任意对象,这也是单页应用在路由跳转时可以随心所欲传值的关键。另外如果不是pushState()
和replaceState()
调用,state 的值将会是 null。用pushState()
和 replaceState()
改变URL确实也有个通病,就是刷新页面报404,因为刷新行为属于浏览器与后台服务通信的默认行为,服务器没法解析前端自定义path而导致404错误。
要解决这个问题,你需要在服务器上添加一个简单的回退路由,如果 URL 不匹配任何静态资源,直接回退到 index.html。
说了那么多,总结下Web History API能给我们带来:
基于此,各类的路由库应用应运而生,当然vue-router
也是其中之一。
创建一个适配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处理和自定义的跳转逻辑回调;routerHistory.location
变动时返回标准化的路径;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
使用。
接下来,我们跟着源码,拆分上面四个流程,看具体是怎么实现的。
第一步,创建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
,源码如下:
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
进行跳转。
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方法实现也比较简单:先把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
}
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
「卸载监听器」。
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
在路由跳转时,做了这些事情:
function beforeUnloadListener() {
const { history } = window
if (!history.state) return
history.replaceState(
assign({}, history.state, { scroll: computeScrollPosition() }),
''
)
}
关闭页面前会执行这个方法,主要作用是记录下当前页面滚动。
// 暂停监听
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)
}
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
的整个过程了。
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
}
好了好了,这节先到这里,最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。