|
本文为原创文章,引用请注明出处,欢迎大家收藏和分享 源码专栏
感谢大家继续阅读《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变型。
撰写目的
在这篇文章里,你能获得以下增益: 1. 了解vue-router中对Web History API能力的应用。 2. 了解createWebHistory和createWebHashHistory的实现原理。
事不宜迟,开讲。。。
。。
。
Web History API
在H5 History API完成页面url变化有2个重要函数:pushState() 和 replaceState(),它们的差异无非就是
举个沉浸式例子
我们随便打开一个页面,在控制台查看下原始History是这样的,其中length是一个只读属性,代表当前 session记录的页面历史数量(包括当前页)。

image.png
然后再执行这段代码,有得到如下效果:浏览器url发生了变化,但页面内容没有任何改动:
history.pushState(
{ myName: 'test', state: { page: 1, index: 2 } },
'div title',
'/divPath'
)
我们再看看History内容,如下图:

image.png
会发现和之前的变化有: - 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错误。

image.png
要解决这个问题,你需要在服务器上添加一个简单的回退路由,如果 URL 不匹配任何静态资源,直接回退到 index.html。
结论
说了那么多,总结下Web History API能给我们带来: 1. 在不与服务端交互情况下改变页面url,给单页路由应用带来可玩(有)性(戏) 2. 能传值,并且能在history栈顶的state读到这些值,解决单页之间的跳转数据传输问题 3. 兼容性好,主流和不是那么主流的客户端都兼容
基于此,各类的路由库应用应运而生,当然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&#39;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,把两者连接起来。
实现原理流程图

image.png
createWebHistory总流程非常简单,分4步走: 1. 创建vue router 的history对象,包含4个属性:location(当前location)、state(路由页面的history state)、和push、replace2个方法; 2. 创建vue router 监听器:主要支持路由跳转时的state处理和自定义的跳转逻辑回调; 3. 添加location劫持,当routerHistory.location变动时返回标准化的路径; 4. 添加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&#39;s overridden right after
location: &#39;&#39;,
base,
go,
createHref: createHref.bind(null, base),
},
historyNavigation,
historyListeners
)
// 步骤3:添加location劫持
Object.defineProperty(routerHistory, &#39;location&#39;, {
enumerable: true,
get: () => historyNavigation.location.value,
})
// 步骤4:添加state劫持
Object.defineProperty(routerHistory, &#39;state&#39;, {
enumerable: true,
get: () => historyNavigation.state.value,
})
// 返回整个router History对象
return routerHistory
}最后,createWebHistory方法返回处理好后的routerHistory对象,供createRouter使用。
接下来,我们跟着源码,拆分上面四个流程,看具体是怎么实现的。
创建History
第一步,创建vue router 的history对象,在上面源码用useHistoryStateNavigation方法来创建这个对象,方便大家理解,笔者简化一个流程图:
流程图

image.png
从左到右,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&#39;t a
* base tag we can just use everything after the `#`.
*/
const hashIndex = base.indexOf(&#39;#&#39;)
const url =
hashIndex > -1
? (location.host && document.querySelector(&#39;base&#39;)
? 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 ? &#39;replaceState&#39; : &#39;pushState&#39;](state, &#39;&#39;, url)
historyState.value = state
} catch (err) {
if (__DEV__) {
warn(&#39;Error with push/replace State&#39;, err)
} else {
console.error(err)
}
// Force the navigation, this also resets the call count
location[replace ? &#39;replace&#39; : &#39;assign&#39;](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, &#39;&#39;, 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的改变是不够的,下面我们再看看监听器的实现过程。
创建路由监听器
流程图

image.png
众所周知,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在路由跳转时,做了这些事情: 1. 更新history的location和state等信息,使得缓存信息同步; 2. 暂停监控时,中断跳转并重置pauseState; 3. 将必要信息告知所有注册的订阅者,并执行注册回调;
beforeUnloadListener
function beforeUnloadListener() {
const { history } = window
if (!history.state) return
history.replaceState(
assign({}, history.state, { scroll: computeScrollPosition() }),
&#39;&#39;
)
}关闭页面前会执行这个方法,主要作用是记录下当前页面滚动。
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(&#39;popstate&#39;, popStateHandler)
window.removeEventListener(&#39;beforeunload&#39;, beforeUnloadListener)
}添加location和state劫持
Object.defineProperty(routerHistory, &#39;location&#39;, {
enumerable: true,
get: () => historyNavigation.location.value,
})
Object.defineProperty(routerHistory, &#39;state&#39;, {
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&#39;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 &#39;/&#39;
* @returns a history object that can be passed to the router constructor
*/
export function createMemoryHistory(base: string = &#39;&#39;): 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, &#39;direction&#39; | &#39;delta&#39;>
): 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&#39;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, &#39;location&#39;, {
enumerable: true,
get: () => queue[position],
})
// 针对单测时处理
if (__TEST__) {
// ...
}
return routerHistory
}落幕
好了好了,这节先到这里,最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来! |
|