vue Router 4源码解析2:url 如何跳转
在上一篇文章我介绍了如何创建路由,下面我们来分析当输入一个 url 时,如何通过路由跳转到对应页面
基础概念介绍
先介绍一下浏览器的 window.location 对象,通过 location 对象能够访问到 url 每个部分的参数
其次要介绍一下 window.history 对象,history 对象能够操作浏览器会话历史,从而实现页面跳转的效果,history 对象有四个核心对象和属性
-
history.pushState(data, title, [, url])
方法:向历史记录栈顶添加一条记录- data:
onpopstate
事件触发时作为参数传递 - title:页面标题,除了 safari 之外的浏览器都会忽略 title 参数
- url:页面地址
- data:
-
history.replaceState(data, title, [, url])
方法:更改当前历史记录 -
history.state
属性:存储上述方法的 data 数据 -
history.scrollRestoration
属性:自动的(auto)或手动的(manual)恢复浏览器的页面滚动位置但是通过 history 对象实现路由跳转,刷新页面会重新发起请求,如果服务端没有匹配到请求 url 就会产生 404,所以也需要服务端配合改造,设置一个没有匹配默认返回的地址
结合 location 对象和 history 对象,我们具备了获取 url 和跳转 url 的能力,通过这两个能力,下面我们来分析路由跳转的实现原理
路由跳转实现原理
我们在创建路由时,会定义 history 属性,vue-router 有三种方式:createWebHistory
、 createWebHashHistory
和 createMemoryHistory
,因为 createWebHistory
是基于基础 history 对象实现,所以我们分析 createWebHistory
的实现原理
createWebHistory
createWebHistory
方法实现主要分为 5 步,我们先看整体方法示意图,再具体介绍每一步实现
export function createWebHistory(base?: string): RouterHistory {
// 第一步:标准化 base 参数
base = normalizeBase(base)
// 第二步:创建 history
const historyNavigation = useHistoryStateNavigation(base)
// 第三步:创建路由监听器
const historyListeners = useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
)
// 第四步:定义 go 方法,创建完成的路由导航对象
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners()
history.go(delta)
}
const routerHistory: RouterHistory = assign(
{
// it's overridden right after
location: '',
base,
go,
createHref: createHref.bind(null, base),
},
historyNavigation,
historyListeners
)
// 第五步:添加 location 和 state 访问劫持
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
})
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
})
return routerHistory
}
第一步:标准化 base 参数
通过 normalizeBase
方法处理 base 参数,主要是为了规范化 base 参数,避免错误如果没有传递 base 参数的话,在浏览器环境取 <base>
标签的链接,否则默认为 /
,保证 base 前又一个前导斜杠,并移除末尾斜杠
export function normalizeBase(base?: string): string {
// 如果没有传入 base 参数,
if (!base) {
// 在浏览器中 base 取 <base> 标签的链接,否则默认为 /
if (isBrowser) {
// respect <base> tag
const baseEl = document.querySelector('base')
base = (baseEl && baseEl.getAttribute('href')) || '/'
// strip full URL origin
base = base.replace(/^\w+:\/\/[^\/]+/, '')
} else {
base = '/'
}
}
// 确保 base 前有一个前导斜杠,避免问题
if (base[0] !== '/' && base[0] !== '#') base = '/' + base
// 移除 base 末尾斜杠
return removeTrailingSlash(base)
}
第二步:创建 history 对象
通过 useHistoryStateNavigation
方法创建 vue-router 的 history 对象,本质上是对浏览器的 history 对象属性和方法做了一个映射
function useHistoryStateNavigation(base: string) {
const { history, location } = window
// private variables
const currentLocation: ValueContainer<HistoryLocation> = {
value: createCurrentLocation(base, location),
}
const historyState: ValueContainer<StateEntry> = { value: history.state }
// 刷新后通过 changeLocation 方法创建 historyState
if (!historyState.value) {
changeLocation()
}
function changeLocation(): void {}
function replace(to: HistoryLocation, data?: HistoryState) {}
function push(to: HistoryLocation, data?: HistoryState) {}
return {
location: currentLocation,
state: historyState,
push,
replace,
}
}
changeLocation
方法用来更新浏览器历史记录并触发页面导航,首先根据 base 获取跳转的完整 url,在通过 replace 判断通过浏览器的 replaceState
还是 pushState
进行页面跳转
function changeLocation(
to: HistoryLocation,
state: StateEntry,
replace: boolean
): void {
const hashIndex = base.indexOf('#')
// 获取跳转的完整 url
const url =
hashIndex > -1
? (location.host && document.querySelector('base')
? base
: base.slice(hashIndex)) + to
: createBaseLocation() + base + to
try {
// 通过 replace 判断通过 replaceState 还是 pushState 进行页面跳转
history[replace ? 'replaceState' : 'pushState'](state, '', url)
historyState.value = state
} catch (err) {
location[replace ? 'replace' : 'assign'](url)
}
}
replace
方法和 push
方法都使用到 buildState
创建 state,主要目的是为了在 state 中添加页面滚动位置,在返回到时候能够再回到原来的位置
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,
}
}
replace
方法先通过 buildState
方法创建一个新的 state,再通过 changeLocation
方法进行页面跳转,最后再更新 currentLocation 的值
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
方法和 replace
方法类似,但要注意的是,push
方法会通过 changeLocation
进行两次页面跳转,第一次通过 replaceState
进行页面跳转,目的是为了在 state 中记录页面滚动的位置,第二次通过 pushState
才是真正的跳转
function push(to: HistoryLocation, data?: HistoryState) {
const currentState = assign(
{},
historyState.value,
history.state as Partial<StateEntry> | null,
{
forward: to,
scroll: computeScrollPosition(),
}
)
// 第一次通过 replaceState 跳转,在 state 记录页面滚动位置
changeLocation(currentState.current, currentState, true)
const state: StateEntry = assign(
{},
buildState(currentLocation.value, to, null),
{ position: currentState.position + 1 },
data
)
// 第二次通过 pushState 实现 push 跳转
changeLocation(to, state, false)
currentLocation.value = to
}
第四步:创建路由监听器
通过 useHistoryListeners
方法创建路由监听器,当路由变化时做响应修改,方法主要定义了对于 history 操作事件 popstate 的处理方法 popStateHandler
,最后再返回操作监听事件三个方法
function useHistoryListeners(
base: string,
historyState: ValueContainer<StateEntry>,
currentLocation: ValueContainer<HistoryLocation>,
replace: RouterHistory['replace']
) {
// 监听回调函数集合
let listeners: NavigationCallback[] = []
let teardowns: Array<() => void> = []
// 暂停状态
let pauseState: HistoryLocation | null = null
// 处理浏览器历史状态更改
const popStateHandler: PopStateListener = () => {}
// 停止监听操作
function pauseListeners() {
pauseState = currentLocation.value
}
// 添加监听回调函数
function listen(callback: NavigationCallback) {}
// 当用户从当前页面导航离开时记录当前页面滚动位置
function beforeUnloadListener() {}
// 清空 teardowns 数组,移除监听事件
function destroy() {}
// 监听 history 操作事件 popstate
window.addEventListener('popstate', popStateHandler)
// 监听页面离开的时间 beforeunload
window.addEventListener('beforeunload', beforeUnloadListener, {
passive: true,
})
return {
pauseListeners,
listen,
destroy,
}
}
popStateHandler
方法主要做了两件事
- 处理跳转地址,更新 state 缓存信息,如果是暂停监听状态,停止跳转并重置 pauseState
- 遍历回调函数并执行,相当于发布订阅模式通知所有注册的订阅者
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) {
currentLocation.value = to
historyState.value = state
// ignore the popstate and reset the pauseState
if (pauseState && pauseState === from) {
pauseState = null
return
}
delta = fromState ? state.position - fromState.position : 0
} else {
// 如果没有 state,则执行 replace 回调
replace(to)
}
// 遍历回调事件并执行
listeners.forEach((listener) => {
listener(currentLocation.value, from, {
delta,
type: NavigationType.pop,
direction: delta
? delta > 0
? NavigationDirection.forward
: NavigationDirection.back
: NavigationDirection.unknown,
})
})
}
listen
监听方法向 listeners 数组中存储回调函数,并且在内部定义了 teardown
方法用来清除回调函数
function listen(callback: NavigationCallback) {
listeners.push(callback)
// 如果 listeners 数组包含 callback,则清空 callback
const teardown = () => {
const index = listeners.indexOf(callback)
if (index > -1) listeners.splice(index, 1)
}
teardowns.push(teardown)
return teardown
}
beforeUnloadListener
方法用于在离开页面是,判断 history 中是否有历史页面状态数据,如果有的话,就记录当前页面的位置
function beforeUnloadListener() {
const { history } = window
if (!history.state) return
// 如果 history 中有状态,则通过 scroll 记录当前页面位置
history.replaceState(
assign({}, history.state, { scroll: computeScrollPosition() }),
''
)
}
destory
方法清空 teardowns 数组,移除监听事件
function destroy() {
// 清空 teardowns 数组,移除监听事件
for (const teardown of teardowns) teardown()
teardowns = []
window.removeEventListener('popstate', popStateHandler)
window.removeEventListener('beforeunload', beforeUnloadListener)
}
第四步:创建完整路由导航对象
定义 go 方法,可以直接使用计步器跳转到对应历史路由,再将 location、base 等状态合并创建完成的路由导航对象
// 定义 go 方法,如果第二个参数 triggerListeners 为 false 则暂停监听
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners()
history.go(delta)
}
// 创建完整的路由导航对象
const routerHistory: RouterHistory = assign(
{
// it's overridden right after
location: '',
base,
go,
createHref: createHref.bind(null, base),
},
historyNavigation,
historyListeners
)
}
第五步:添加 lacation 和 state 访问劫持
添加 lacation 和 state 访问劫持,保证访问 location 和 value 属性时获取的是具体值而不是代理对象
// 第五步:添加 lacation 和 state 访问劫持
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
})
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
})
createWebHashHistory
createWebHashHistory
本质也是基于 createWebHistory
的方式来实现,在 base 中会默认拼接一个 #
,在回顾下第一幅图,如果 url 链接中带有 #
后面的部分会作为锚点,就不会再刷新时请求服务器。最后再将处理好的 base 参数传入 createWebHistory
方法,同样借助 history 实现路由跳转
export function createWebHashHistory(base?: string): RouterHistory {
base = location.host ? base || location.pathname + location.search : ''
// 确保链接会拼接一个 #
if (!base.includes('#')) base += '#'
return createWebHistory(base)
}
createMemoryHistory
createMemoryHistory
方法用于服务端渲染,因为服务端没有浏览器的 history 对象,所以实现方式是基于内存,下面我们简单分析一下具体实现
在 createMemoryHistory
方法中,定义 queue 作为历史记录的存储队列,定义 position 作为计步器,设置 location、push、replace 的方法都是基于 queue 的出队入队操作实现,最后再将相关路由操作方法放在 routerHistory 对象中返回
export function createMemoryHistory(base: string = ''): RouterHistory {
let listeners: NavigationCallback[] = []
let queue: HistoryLocation[] = [START]
let position: number = 0
// 第一步:
base = normalizeBase(base)
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)
}
}
const routerHistory: RouterHistory = {
// rewritten by Object.defineProperty
location: START,
// TODO: should be kept in queue
state: {},
base,
createHref: createHref.bind(null, base),
replace(to) {
// remove current entry and decrement position
queue.splice(position--, 1)
setLocation(to)
},
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 =
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],
})
return routerHistory
}