前言
上一篇文章中大致介绍了vue-router是如何保存和操作route的. 这次接着介绍vue-router是如何实现页面的跳转的.
window.history和window.location
vue-router的路由导航的基础仍然是浏览器的window.history对象和window.location对象, 因此我们先了解一下history和location.
history
window对象通过history提供了访问浏览器对话历史的能力. history对象暴露了用于操作用户浏览历史栈和进行页面导航的方法和属性.
属性
length(readonly)
返回会话历史的长度, 包括当前页面.state(readonly) 返回一个any类型的值, 该值保存当前历史栈顶部的信息scrollRestoration
允许web应用显式的设置在历史导航中默认的滚动恢复行为.
方法
back()
此异步方法转到浏览器会话历史的上一页,与用户单击浏览器的Back按钮的行为相同。等价于history.go(-1)。
调用此方法回到会话历史的第一页之前没有效果并且不会引发异常。forward()
此异步方法转到浏览器会话历史的下一页,与用户单击浏览器的Forward按钮的行为相同。等价于history.go(1)。
调用此方法超越浏览器历史记录中最新的页面没有效果并且不会引发异常。go()
通过当前页面的相对位置从浏览器历史记录(会话记录)异步加载页面。pushState()
按指定的名称和 URL(如果提供该参数)将数据 push 进会话历史栈,数据被 DOM 进行不透明处理;你可以指定任何可以被序列化的 javascript 对象。replaceState()
按指定的数据、名称和 URL(如果提供该参数),更新 history 栈上最新的条目。这个数据被 DOM 进行了不透明处理。你可以指定任何可以被序列化的 javascript 对象。
location
location对象中包含了文档的当前位置信息.
属性
Location.ancestorOriginsLocation.href:URLLocation.protocol:协议Location.host:主机地址, 可能包含端口号Location.hostname:域名Location.port:端口号Location.pathname:路径Location.search:参数, ?Location.hash:#Location.origin
方法
Location.assign():加载指定urlLocation.reload():重新加载当前页面Location.replace():用指定URL的资源覆盖当前页面, 使用replace后当前页面不会保存在会话历史中,assign()会.Location.toString(): 和Location.href相等.
options.history
在使用createRouter(options)时, 需要在options中设定history值用于指定vue-router的历史模式.
目前vue-router有三种历史记录模式, 分别为Hash模式, HTML5 模式和Memory 模式. 主要使用的是前两种方式, Memory模式更多用于服务器端渲染(SSR).
而Hash模式其实是依赖于HTML5 模式的, 因此本文将集中介绍HTML 5模式的实现.
- 可以看到,
createWebHashHistory实际上在内部调用了createWebHistory.
export function createWebHashHistory(base?: string): RouterHistory {
// 使用文件协议打开的页面 location.host 是空字符串, 此时 base 为空
// 即使用文件协议打开页面时设置的 base 会被忽略
base = location.host ? base || location.pathname + location.search : ''
// 允许中间的 '#'
if (!base.includes('#')) base += '#'
if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
warn(
`A hash base must end with a "#":\n"${base}" should be "${base.replace(
/#.*$/,
'#'
)}".`
)
}
return createWebHistory(base)
}
使用createWebHistory
通常, 我们会在创建路由器时调用createWebHistory
// 创建路由
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes as RouteRecordRaw[],
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 }),
});
翻阅createWebHashHistory的源码, 可以看到其返回一个类型为 RouterHistory 的对象, 该类型的结构如下:
export interface RouterHistory {
readonly base: string;
readonly location: HistoryLocation;
readonly state: HistoryState;
push(to: HistoryLocation, data?: HistoryState): void;
replace(to: HistoryLocation, data?: HistoryState): void;
go(delta: number, triggerListeners?: boolean): void;
listen(callback: NavigationCallback): () => void;
createHref(location: HistoryLocation): string;
destroy(): void;
}
createWebHashHistory的源码如下
export function createWebHistory(base?: string): RouterHistory {
// normalizeBase() 当没有传入base时根据浏览器的地址进行创建, 确保了base一定不为空
// 当该函数用于创建WebHashHistory模式时, base会以 '#' 结尾
base = normalizeBase(base)
const historyNavigation = useHistoryStateNavigation(base)
const historyListeners = useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
)
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners()
history.go(delta)
}
const routerHistory: RouterHistory = assign(
{
location: '',
base,
go,
createHref: createHref.bind(null, base),
},
historyNavigation,
historyListeners
)
// 拦截 routerHistory.location, 使 routerHistory.location 返回当前路由地址
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
})
// 拦截 routerHistory.state, 使 routerHistory.state 返回当前的的 history.state
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
})
return routerHistory
}
通过阅读发现, createWebHashHistory进行了以下操作
- 创建了一个
base属性 - 调用
useHistoryStateNavigation()创建了historyListeners对象. - 调用
useHistoryListeners()创建了historyListeners对象. - 实现了一个
go()方法 - 将
historyListeners对象和historyListeners对象合并到routerHistory中 - 劫持
routerHistory的location和state属性
我们先从简单的看起
base
export function normalizeBase(base?: string): string {
// 如果传入的base为空字符串, 则根据浏览器的地址来创建一个base
if (!base) {
if (isBrowser) {
// 浏览 <base> 标签获取 href 属性
const baseEl = document.querySelector('base')
// 如果base 标签不存在或则其没有href属性, 将base设为 '/'
base = (baseEl && baseEl.getAttribute('href')) || '/'
// 在URL中删除前导的 xxx://xxxxx/
base = base.replace(/^\w+:\/\/[^\/]+/, '')
} else {
base = '/'
}
}
// 确保 base 的前导 '/'
if (base[0] !== '/' && base[0] !== '#') base = '/' + base
// 移除 base 的尾部 '/'
return removeTrailingSlash(base)
}
go() 的实现
可以看到, go()方法的实现是调用浏览器history对象的原生方法, 但是其添加了一个控制参数triggerListeners用来控制是否触发导航事件监听器. 其调用了historyListeners的方法.
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners()
history.go(delta)
}
useHistoryStateNavigation(), 页面跳转的核心
首先查看useHistoryStateNavigation()的返回值和函数签名
function useHistoryStateNavigation(base: string) {
......
return {
location: currentLocation,
state: historyState,
push,
replace,
}
}
useHistoryStateNavigation()接收一个base地址, 返回了两个页面跳转方法和两个用于之后数据劫持的页面历史相关属性. 接着查看其实现代码.
- 先获取浏览器window对象里的history对象和location对象
// 获取 window 的 history 和 location
const { history, location } = window
- 接着获取
location相对于base的地址赋值给currentLocation
const currentLocation: ValueContainer<HistoryLocation> = {
value: createCurrentLocation(base, location),
}
// 获取location相对于base的路径
function createCurrentLocation(
base: string,
location: Location
): HistoryLocation {
const { pathname, search, hash } = location
const hashPos = base.indexOf('#')
// hash 模式下只需返回#后内容
if (hashPos > -1) {
let slicePos = hash.includes(base.slice(hashPos))
? base.slice(hashPos).length
: 1
let pathFromHash = hash.slice(slicePos)
// 在前面加上 '/' 使得 path 为 '/#' 格式
if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash
return stripBase(pathFromHash, '')
}
// 如果不为 hash 模式, 需要将 pathname 前的 base 部分去掉
const path = stripBase(pathname, base)
return path + search + hash
}
- 获取
history.state, 将其赋值给historyState, 如果history.state的值为null, 那么使用changeLocation()构建一个默认记录.
// 获取history.state赋值给historyState
const historyState: ValueContainer<StateEntry> = { value: history.state };
// 如果 history.state 是空的, 构建一条新的历史记录
if (!historyState.value) {
changeLocation(
currentLocation.value,
{
back: null,
current: currentLocation.value,
forward: null,
position: history.length - 1,
replaced: true,
scroll: null,
},
true
);
}
replace()的实现
先利用buildState()创建一个state, 然后使用changeLocation()定位到to指代的页面.
function replace(to: HistoryLocation, data?: HistoryState) {
const state: StateEntry = assign(
{},
history.state,
buildState(historyState.value.back, to, historyState.value.forward, true),
data,
// replace 操作, position 不变
{ position: historyState.value.position }
);
changeLocation(to, state, true);
// 将当前历史改为 to
currentLocation.value = to;
}
/**
* 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,
}
}
push()的实现
逻辑和replace()类似, 不过需要调用两次changeLocation(), 第一次是刷新当前页面, 将当前页面的滚动位置记录. 第二次是跳转到目的页面.
function push(to: HistoryLocation, data?: HistoryState) {
const currentState = assign(
{},
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.`
);
}
// 第一次使用 replace 刷新当前历史, 记录当前页面的滚动位置
// 当之后重新前进或后退进入此页面时会进入同样的位置
changeLocation(currentState.current, currentState, true);
const state: StateEntry = assign(
{},
buildState(currentLocation.value, to, null),
// push 操作时需要将历史记录的位置加1
{ position: currentState.position + 1 },
data
);
// 第二次跳转, 跳转到目的位置, 不使用 replace
changeLocation(to, state, false);
currentLocation.value = to;
}
可以看到, push()和replace()都调用了changeLocation(). 而在changeLocation()其实是调用了history#replaceState和history#pushState来实现页面的跳转. 关于这两个方法的具体功能可以参考文章开始对window.history的介绍
function changeLocation(
to: HistoryLocation,
state: StateEntry,
replace: boolean
): void {
// 判断其是否为hash模式, 如果是将 url 中的协议和主机域名移除
const hashIndex = base.indexOf("#");
const url =
hashIndex > -1
? (location.host && document.querySelector("base")
? base
: base.slice(hashIndex)) + to
: createBaseLocation() + base + to;
try {
// 调用history的API来更改页面路径
history[replace ? "replaceState" : "pushState"](state, "", url);
// 更新 historyState
historyState.value = state;
} catch (err) {
// 如果修改历史记录的过程中报错, 则使用 assign 导航到对应 url
if (__DEV__) {
warn("Error with push/replace State", err);
} else {
console.error(err);
}
location[replace ? "replace" : "assign"](url);
}
}
通过阅读useHistoryStateNavigation()部分的代码, 我们发现其提供的push()和replace()等方法和state, locatino属性实际上是对window.history进行的二次封装. 接着我们继续阅读useHistoryListeners()部分的代码.
useHistoryListeners(), 监听会话历史的变化
function useHistoryListeners(
base: string,
historyState: ValueContainer<StateEntry>,
currentLocation: ValueContainer<HistoryLocation>,
replace: RouterHistory['replace']
) {
......
return {
pauseListeners,
listen,
destroy,
}
}
通过函数签名可以看到, 该函数接收通过useHistoryStateNavigation()创建的一个实例对象, 并返回了三个操作监听器的方法法. 照例, 接着从头开始阅读其源码.
let listeners: NavigationCallback[] = []; // 监听器函数列表
let teardowns: Array<() => void> = []; // 保存用于销毁监听回调的函数
let pauseState: HistoryLocation | null = null; // 暂停状态
先创建了两个数组listeners和teardowns. listeners用于保存监听器函数, teardowns用于保存销毁监听器的函数. pauseState记录了暂停状态. 继续查看返回的方法的具体实现, 首先是 listen().
listen(), 添加一个监听器
listen()的代码实现逻辑十分简单.- 接收一个回调函数
callback, 将其添加到listeners中. - 创建一个
teardown函数, 用于从listeners中移除添加的callback. - 将
teardown添加到teardowns中. - 返回
teardown.
- 接收一个回调函数
function listen(callback: NavigationCallback) {
listeners.push(callback);
const teardown = () => {
const index = listeners.indexOf(callback);
if (index > -1) listeners.splice(index, 1);
};
teardowns.push(teardown);
return teardown;
}
destory(), 销毁全部监听器- 遍历
teardowns数组中的函数并执行来销毁所有已添加的监听器. - 将
teardowns置空. - 将通过
window#addEventListener添加的事件监听移除.
- 遍历
// 销毁全部监听器, 取消监听浏览器的事件
function destroy() {
for (const teardown of teardowns) teardown();
teardowns = [];
window.removeEventListener("popstate", popStateHandler);
window.removeEventListener("beforeunload", beforeUnloadListener);
}
pauseListeners(), 暂停监听
用于当triggerListeners设置为false时不监听go()方法的触发. 可以看到该方法只是将pauseState标识为当前的路径位置. 具体如何实现暂停监听的效果可见后面popStateHandler().
function pauseListeners() {
pauseState = currentLocation.value
}
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners();
history.go(delta);
}
实现了listen(), destory()和pauseListeners()后, useHistoryListeners()继续通过
addEventListener()为window添加事件监听器.
// 添加监听器监听浏览器的 popstate 和 beforeunload 事件
window.addEventListener("popstate", popStateHandler);
window.addEventListener("beforeunload", beforeUnloadListener);
我们先来看popStateHandler(). 在了解popStateHandler()的代码实现之前, 我们先来看一下popstate事件.
popstate
每当激活同一文档中不同的历史记录条目时,popstate事件就会在对应的window对象上触发。如果当前处于激活状态的历史记录条目是由history.pushState()方法创建的或者是由history.replaceState()方法修改的,则popstate事件的state属性包含了这个历史记录条目的state对象的一个拷贝。
接着查看popStateHandler()的代码
const popStateHandler: PopStateListener = ({
state,
}: {
state: StateEntry | null;
}) => {
const to = createCurrentLocation(base, location);
const from: HistoryLocation = currentLocation.value;
const fromState: StateEntry = historyState.value;
let delta = 0;
if (state) {
// 设定浏览器信息
currentLocation.value = to;
historyState.value = state;
// 当 pausestate 和 from 相同时, 忽略此次事件的触发, 并重置 pausestate
if (pauseState && pauseState === from) {
pauseState = null;
return;
}
// 计算移动步数
delta = fromState ? state.position - fromState.position : 0;
} else {
replace(to);
}
// 执行所有添加的监听器回调
listeners.forEach((listener) => {
listener(currentLocation.value, from, {
delta,
type: NavigationType.pop,
direction: delta
? delta > 0
? NavigationDirection.forward
: NavigationDirection.back
: NavigationDirection.unknown,
});
});
};
function createCurrentLocation(
base: string,
location: Location
): HistoryLocation {
const { pathname, search, hash } = location
const hashPos = base.indexOf('#')
// hash 模式下只需返回#后内容
if (hashPos > -1) {
// 如果 hash 中包含 base 中的哈希串, 那么将其从 hash 中截出
let slicePos = hash.includes(base.slice(hashPos))
? base.slice(hashPos).length
: 1
let pathFromHash = hash.slice(slicePos)
// 在前面加上 '/' 使得 path 为 '/#' 格式
if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash
return stripBase(pathFromHash, '')
}
// 如果不为 hash 模式, 需要将 pathname 前的 base 部分去掉
const path = stripBase(pathname, base)
return path + search + hash
}
可以看到, 当触发popState事件时, 使用createCurrentLocation()创建目标路径, 设置currentLocation为起始路径, historyState为当前会话记录状态. 然后更新currentLocation为to, historyState为触发事件时的state. 这样就实现了会话记录的更新. 然后再依次执行所有listeners中的监听器函数.
接着是对beforeunload事件的监听, 对beforeunload事件的处理比较简单, 使用了replaceState方法更新当前页面状态, 记录文档的滚动位置.
function beforeUnloadListener() {
const { history } = window;
if (!history.state) return;
// 当页面关闭时记录页面滚动记录
history.replaceState(
assign({}, history.state, { scroll: computeScrollPosition() }),
""
);
}
至此整个useHistoryListeners()部分的代码已经阅读完毕, 同时补全了createWebHistory()的代码拼图. 在阅读了createWebHistory()后, 我们将返回createRouter(), 了解如何使用createWebHistory()返回的routerHistory.
createWebHistory() 在 createRouter() 中的使用
我们在vue-router源码阅读-1.从createRouter开始 - 掘金 (juejin.cn)中介绍了createRouter()返回的router对象中用5个和页面历史记录相关的方法. 其实这些方法就是调用了routerHistory提供的方法来实现其功能. 我们来进行这个检查.
// history相关API
push,
replace,
go,
back: () => go(-1),
forward: () => go(1),
在阅读以上方法的代码实现前, 我们先看一下createWebHistory()是如何在createRouter()中进行使用的.
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes as RouteRecordRaw[],
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 }),
});
export function createRouter(options: RouterOptions): Router {
......
const routerHistory = options.history
......
}
可以看到, 在使用时我们先使用createWebHashHistory()创建一个history对象添加到options配置对象里作为createRouter()的参数,
然后在createRouter()将其赋值给routerHistory. 接着我们依次查看方法的代码实现.
go(),forward()和back()
const go = (delta: number) => routerHistory.go(delta)
.....
back: () => go(-1),
forward: () => go(1),
这三个方法的代码逻辑都比较简单.
可以看到, go()是简单的调用了routerHistory#go, 而back()和forward()是go()的特殊调用情况.
push()和replace()
// 导航到 to 并且将新的条目添加到浏览历史栈中
function push(to: RouteLocationRaw) {
return pushWithRedirect(to)
}
// 导航到 to 并用新条目替换当前浏览条目
function replace(to: RouteLocationRaw) {
return push(assign(locationAsObject(to), { replace: true }))
}
可以看到, replace()是设置了参数后调用push(). push()则是调用了另一个方法pushWithRedirect()
pushWithRedirect()
pushWithRedirect()的代码逻辑可分解为:- 判断目标路由是否需要重定向, 如果需要的话跳转到重定向的位置.
- 调用
navigate()执行路由跳转守卫. - 调用
finalizeNavigation()执行url修改等路由跳转的收尾工作. - 使用
triggerAfterEach()触发全局After路由守卫
// 处理可能包含重定向的路由跳转
function pushWithRedirect(
to: RouteLocationRaw | RouteLocation,
redirectedFrom?: RouteLocation
): Promise<NavigationFailure | void | undefined> {
// 将目标location和等待location都设为 to 的值
const targetLocation: RouteLocation = (pendingLocation = resolve(to));
const from = currentRoute.value;
const data: HistoryState | undefined = (to as RouteLocationOptions).state;
const force: boolean | undefined = (to as RouteLocationOptions).force;
// 当replace是一个函数时, to可以是一个字符串
// 判断是否是 replace 模式
const replace = (to as RouteLocationOptions).replace === true;
// 判断是否是重定向, 如果是, 将重定向到的地址作为参数调用本函数
const shouldRedirect = handleRedirectRecord(targetLocation);
if (shouldRedirect)
return pushWithRedirect(
assign(locationAsObject(shouldRedirect), {
state: data,
force,
replace,
}),
// 保留重定向的源地址
redirectedFrom || targetLocation
);
// 此时为非重定向路由
const toLocation = targetLocation as RouteLocationNormalized;
toLocation.redirectedFrom = redirectedFrom;
let failure: NavigationFailure | void | undefined;
// 如果目标路由和当前路由同地址, 报错
if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) {
/** 错误处理代码 */
}
// 如果有错误
// 将错误对象用Promise.reslove()进行封装后返回
// 否则调用navigate()执行跳转
return (
(failure ? Promise.resolve(failure) : navigate(toLocation, from))
// 错误捕获
.catch((error: NavigationFailure | NavigationRedirectError) =>
// 判断是否是导航错误
isNavigationFailure(error)
? // navigation redirects still mark the router as ready
isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
? error
: markAsReady(error) // also returns the error
: // 对于非导航错误使用triggerError触发错误传给 .then() 进行处理
triggerError(error, toLocation, from)
)
.then((failure: NavigationFailure | NavigationRedirectError | void) => {
if (failure) {
/** 错误处理代码 */
}
// 将页面重定向至错误导航发生时的默认页面
return pushWithRedirect(
assign(
{ replace },
locationAsObject(failure.to),
{
state: data,
force,
}
),
redirectedFrom || toLocation
);
} else {
// if we fail we don't finalize the navigation
failure = finalizeNavigation(
toLocation as RouteLocationNormalizedLoaded,
from,
true,
replace,
data
);
}
// 调用After路由守卫
triggerAfterEach(
toLocation as RouteLocationNormalizedLoaded,
from,
failure
);
return failure;
})
);
}
由于navigate()代码繁多但逻辑简单, 为避免文章篇幅过长, 简单介绍下其代码逻辑
- 获取关于本次路由跳转涉及的离开路由, 进入路由和更新路由.
- 分类从这些涉及的路由中抽离路由守卫.
- 依次执行抽离的路由守卫.
triggerAfterEach()遍历了路由守卫数组并依次执行.
function triggerAfterEach(
to: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
failure?: NavigationFailure | void
): void {
// 导航跳转已结束, 调用after守卫
for (const guard of afterGuards.list()) guard(to, from, failure);
}
你会发现到现在我们还是没有发现路由跳转中有哪里需要使用routerHIstory, 但接着是整个路由跳转中的主角finalizeNavigation, 在其中我们可以看到routerHistory#replace和routerHistory#push的登场. 其代码逻辑如下:
- 先判断执行路由守卫时是否抛出错误, 如果是, 那么本次路由跳转取消, 返回错误对象.
- 判断是否为第一次初始化路由跳转, 仅当为非初始化路由跳转时才改变url.
- 根据参数判断是
replace跳转还是push跳转, 接着调用routerHistory#replace或routerHistory#push. - 将当前路由位置设为目的位置, 执行页面滚动, 标记路由跳转完成.
function finalizeNavigation(
toLocation: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
isPush: boolean,
replace?: boolean,
data?: HistoryState
): NavigationFailure | void {
// a more recent navigation took place
const error = checkCanceledNavigation(toLocation, from);
if (error) return error;
const isFirstNavigation = from === START_LOCATION_NORMALIZED;
const state = !isBrowser ? {} : history.state;
// 仅当用户执行了一个非初始化的push/replace导航时才修改URL
if (isPush) {
if (replace || isFirstNavigation)
routerHistory.replace(
toLocation.fullPath,
assign(
{
scroll: isFirstNavigation && state && state.scroll,
},
data
)
);
else routerHistory.push(toLocation.fullPath, data);
}
// 将当前路由位置设为目的位置, 执行页面滚动
currentRoute.value = toLocation;
handleScroll(toLocation, from, isPush, isFirstNavigation);
markAsReady();
}
至此我们已经知道了router是如何实现路由跳转的了.