前言
最近开始阅读react-routerV6
的源码,一开始接触到一个名为history
的第三方库(下称history库
),后面慢慢发现react-router
无论是V5
版本还是V6
版本的设计中很大程度依赖于这个库,因此我先把注意力转移到history
库上进行学习。下面就写一下我对这个库的学习总结。
截止到文章收笔的今天,react-routerV6
的版本已经更新到6.0.0-beta.7
,但从react-router``v6alpha
到v6beta
一直都依赖history库V5
版本,而history库V5
目前只更新到5.0.1
,可见目前history库
的5.0.1
已经基本可以说是稳定版本了。因此,这篇文章分析的history库
版本为5.0.1
。
注意:不同版本的react-router
所依赖的history库
版本也不同。react-router
的v5和v4版本依赖history库
的v4版本,react-routerV6
正式版虽然还没出,但history库
官方已宣称react-routerV6
将依赖history库V5
。
作用
关于history库
的作用,可以引述官方的介绍来说明:
history库
可以令你更轻松地管理在JavaScript
环境下运行的会话历史(session history
)。一个history
实例抽象了各种环境中的差异,且提供了最简洁的API
去管理会话中的历史栈(history stack
)、导航(navigate
)以及持久化的状态(persist state
)。
通常我们在管理页面路由方面有两种模式:hash模式和history模式。如果要我们手动去实现的话,hash模式需要通过用window.onhashchange
监听location.hash
的变化实现。history模式需要通过window.history
和window.onpopstate
实现。而history库
提供了针对两种模式下的简洁的管理方式, 我们只需要根据项目的需求获取hash模式或history模式下的管理实例。调用这个管理实例下统一的API
(如push
、replace
等)就可以做到对url
的动态改变。
而且,history库
也弥补了原生的window.history一些不尽人意的效果,例如popstate
事件的触发条件:
调用
history.pushState()
或者history.replaceState()
不会触发popstate
事件.popstate
事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者调用history.back()
、history.forward()
、history.go()
方法)
由于很多人都没单独使用过history库
,因此接下来,我会采用一边介绍API
一边分析源码的方式来揭秘history库
的原理。
用法以及源码分析
实例化
用法
首先,我们需要获取一个会话历史管理实例(history instance
)来执行后续的操作。history库
提供了三个对应不同模式的方法来创建会话历史管理实例:
- createBrowserHistory:用于创建history模式下的会话历史管理实例
- createHashHistory:用于创建hash模式下的会话历史管理实例
- createMemoryHistory:用于无浏览器环境下,例如
React Native
和测试用例
接下来我们先以history模式进行分析,获取会话历史管理实例的代码如下:
// 有两种获取实例的方式:
// 1. 通过函数创建实例
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
// 如果要创建管理iframe历史的实例,可以把iframe.window作为形参传入该函数上
const iframeHistory = createBrowserHistory({
window: iframe.contentWindow
});
// 2. 通过导入获取对应的单例
import history from 'history/browser';
源码分析
export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
let { window = document.defaultView! } = options;
let globalHistory = window.history;
// ...
let history: BrowserHistory = {
get action() {
return action;
},
get location() {
return location;
},
createHref,
push,
replace,
go,
back() {
go(-1);
},
forward() {
go(1);
},
listen(listener) {
},
block(blocker) {
}
};
return history;
}
其实createBrowserHistory
内部就是生成push
、replace
、listen
等方法,然后挂到一个刚生成的普通对象的属性上,最后把这个对象返回出去,因此会话历史管理实例其实就是一个包含多个属性的纯对象而已。
对于第二种获取实例的方式,我们可以看一下history/browser
的代码:
import { createBrowserHistory } from 'history';
export default createBrowserHistory();
其实就是用createBrowserHistory
创建了实例,然后把该实例导出,这样子就成为了单例模式的导出。
获取location
用法
// 可通过history.location获取当前路由地址信息
let location = history.location;
location
是一个纯对象,代表当前路由地址,包含以下属性:
pathname
:等同于window.location.pathname
search
:等同于window.location.search
hash
:等同于window.location.hash
state
:当前路由地址的状态,类似但不等于window.history.state
key
:代表当前路由地址的唯一值
源码分析
我们看一下hisoory.location
的相关源码
const readOnly:
// ts类型定义
<T extends unknown>(obj: T) => T
/**
* __DEV__在开发环境下为true
* 在开发环境下使用Object.freeze让obj只读,
* 不可修改其中的属性和新增属性
*/
= __DEV__? obj => Object.freeze(obj): obj => obj;
export function createBrowserHistory(): BrowserHistory {
// 获取index和location的方法
// index代表该历史在历史栈中的第几个
function getIndexAndLocation(): [number, Location] {
let { pathname, search, hash } = window.location;
let state = globalHistory.state || {};
return [
state.idx,
readOnly<Location>({
pathname,
search,
hash,
state: state.usr || null,
key: state.key || 'default'
})
];
}
let [index, location] = getIndexAndLocation();
let history: BrowserHistory = {
get location() {
return location;
},
// ...
}
return history;
}
从getIndexAndLocation
中可以看出location
中的pathname
、search
、hash
取自window.location
。state
和key
分别取自window.history.state
中的key
、usr
。至于window.history.state
中的key
、usr
是怎么生成的,我们留在后面介绍push
方法时介绍。
listen
用法
history.listen
用于监听当前路由(history.location
)变化。作为形参传入的回调函数会在当前路由变化时执行。示例代码如下:
// history.listen执行后返回一个解除监听的函数,该函数执行后解除对路由变化的监听
let unlisten = history.listen(({ location, action }) => {
console.log(action, location.pathname, location.state);
});
unlisten();
history.listen
中传入的回调函数中,形参是一个对象,解构该对象可以获取两个参数:
location
:等同于history.location
action
:描述触发路由变化的行为,字符串类型,有三个值:"POP"
: 代表路由的变化是通过history.go
、history.back
、history.forward
以及浏览器导航栏上的前进和后退键触发。"PUSH"
:代表路由的变化是通过history.push
触发的。"REPLACE"
:代表路由的变化是通过history.replace
触发的。 此值可以通过history.action
获取
源码分析
// 基于观察者模式创建一个事件中心
function createEvents<F extends Function>(): Events<F> {
let handlers: F[] = [];
return {
get length() {
return handlers.length;
},
// 用于注册事件的方法,返回一个解除注册的函数
push(fn: F) {
handlers.push(fn);
return function() {
handlers = handlers.filter(handler => handler !== fn);
};
},
// 用于触发事件的方法
call(arg) {
handlers.forEach(fn => fn && fn(arg));
}
};
}
let listeners = createEvents<Listener>();
let history: BrowserHistory = {
// ...
listen(listener) {
return listeners.push(listener);
},
}
至于为啥回调函数的形参可以解构出action
和location
。可以看下面介绍push
的源码分析。
push
用法
用过Vue-Router
和React-Router
都知道,push
就是把新的历史条目添加到历史栈上。而history.push
也是同样的效果,实例代码如下:
// 改变当前路由
history.push('/home');
// 第二参数可以指定history.location.state
history.push('/home', { some: 'state' });
// 第一参数可以是字符串,也可以是普通对象,在对象中可以指定pathname、search、hash属性
history.push({
pathname: '/home',
search: '?the=query'
}, {
some: state
});
源码分析
// 上面说到history.push的第一参数除了字符串还可以是含部分特定属性的对象
// 此函数的作用在于把此对象转化为url字符串
export function createPath({
pathname = '/',
search = '',
hash = ''
}: PartialPath) {
return pathname + search + hash;
}
// 根据传入的to的数据类型创建链接字符串
function createHref(to: To) {
return typeof to === 'string' ? to : createPath(to);
}
// 此函数用于根据传入的location获取新的history.state和url
function getHistoryStateAndUrl(
nextLocation: Location,
index: number
): [HistoryState, string] {
return [
{
usr: nextLocation.state,
key: nextLocation.key,
idx: index
},
createHref(nextLocation)
];
}
function push(to: To, state?: State) {
// Action是一个TypeScript中枚举类型的数据,有三个值:Push、Pop、Replace,
// 分别对应字符串'PUSH'、'POP'、'PUSH'
// Action用于描述触发路由改变行为,与history.action一样
let nextAction = Action.Push;
// 根据传入的to生成下一个location
let nextLocation = getNextLocation(to, state);
// 用于重试
function retry() {
push(to, state);
}
// allowTx主要与history.block相关,该API在后面的章节会介绍,
// 我们这里先默认它为true然后往下阅读
if (allowTx(nextAction, nextLocation, retry)) {
// 根据新的location生成新的historyState和url
// 注意此处index + 1,会赋值于historyState.idx,
// 在globalHistory.pushState更新state后,再次调用getIndexAndLocation时,
// 由于getIndexAndLocation是根据globalHistory.state生成index和location的,
// 因此得出的index会比之前的多1
let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
// 之所以用try~catch包裹着是因为iOS中限制pushState的调用次数为100次
try {
globalHistory.pushState(historyState, '', url);
} catch (error) {
// 刷新页面,该写法等同于window.location.href = url
// 官方源码注释:此处操作会失去state,但没有明确的方法警告他们因为页面会被刷新
window.location.assign(url);
}
// 触发listeners执行注册其中的回调函数
applyTx(nextAction);
}
}
function applyTx(nextAction: Action) {
// 更新history.action
action = nextAction;
// 更新index和location,在history.pushState过后得出的index比之前的多1
[index, location] = getIndexAndLocation();
// 触发listeners中注册的回调函数的执行
// 此处传入的参数{ action, location }会作为形参传入到回调函数中
listeners.call({ action, location });
}
replace
用法
与Vue-Router
和React-Router
类似,replace
用于把当前历史栈中的历史条目替换成新的历史条目。传入的参数和history.push
一样。这里就不展示代码实例了。
源码分析
function replace(to: To, state?: State) {
// 定义行为为"REPLACE"
let nextAction = Action.Replace;
let nextLocation = getNextLocation(to, state);
function retry() {
replace(to, state);
}
if (allowTx(nextAction, nextLocation, retry)) {
// 注意此处getHistoryStateAndUrl的第二参数为index,而push的是index+1
// 是因为replace只是生成新的历史条目替代现在的历史条目
let [historyState, url] = getHistoryStateAndUrl(nextLocation, index);
globalHistory.replaceState(historyState, '', url);
applyTx(nextAction);
}
}
history.replace
和history.push
的源码基本一致,只是在index
的更新和调用window.history
的原生API
方面有所不同,我这里就重复了。
block
用法
history.block
是相比于原生的window.history
中新增的一个很好的特性。它能够在当前路由即将变化之前,执行回调函数从而阻塞甚至拒绝更改路由。看一下示例代码:
let unblock = history.block((tx:{nextAction, nextLocation, retry}) => {
if (window.confirm(`你确定要离开当前页面吗?`)) {
// 移除阻塞函数,这里必须要移除,不然该阻塞函数会一直执行
unblock();
// 继续执行路由更改操作
tx.retry();
}
});
页面效果如下:
源码分析
// 此方法在beforeunload事件触发时执行,beforeunload事件是在浏览器窗口关闭或刷新的时候才触发的
// 该方法中做了两件事:
// 1. 调用event.preventDefault()
// 2. 设置event.returnValue为字符串值,或者返回一个字符串值
// 此时达成的效果是,页面在刷新或关闭时会被弹出的提示框阻塞,直至用户与提示框进行交互
function promptBeforeUnload(event: BeforeUnloadEvent) {
// 阻止默认事件
event.preventDefault();
// 官方注释:Chrome和部分IE需要设置返回值
event.returnValue = '';
}
// 这里可以看出,blockers和listeners一样是一个观察者模式的事件中心
let blockers = createEvents<Blocker>();
const BeforeUnloadEventType = 'beforeunload';
let history = {
// ...
block(blocker) {
// 往blockers中注册回调函数blocker
let unblock = blockers.push(blocker);
// blockers中被注册回调函数时,则监听'beforeunload'以阻止默认操作
if (blockers.length === 1) {
window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
}
return function() {
unblock();
// 当blockers中注册的回调函数数量为0时,移除事件监听
// 官方注释:移除beforeunload事件监听以让页面文档document在pagehide事件中是可回收的
if (!blockers.length) {
window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
}
};
}
}
history.block
的源码中,只编写了关于beforeunload
事件监听的注册和注销。而beforeunload
事件是在浏览器窗口关闭或刷新的时候才触发的,beforeunload
事件触发后执行传入的回调函数promptBeforeUnload
,此时会达到下面的效果:
要注意一点,上面的效果图中的弹窗内容是不可自定义的,根据history.block
源码中的内容可以推断出,history.block
中的形参blocker
是不会参与手动刷新页面或者跳转页面时的阻塞交互的。history.block
做到的是,在blockers
阻塞事件中心的回调函数不为空时,进行上述两个操作(手动刷新页面或者跳转页面)时,就会按照上面的效果图进行阻塞,而阻塞事件中心的回调函数为空时,则不会阻塞。
这么一说,history.block
中传入的回调函数blocker
只会影响到history.push
、history.replace
、history.go
这些history库
中定义的用于改变当前路由的API
。这里我们先研究history.block
怎么影响到history.push
和history.replace
的。至于如何影响history.go
的就放在下面介绍history.go
时说明。
首先再次看一下history.push
中的源码:
function push(to: To, state?: State) {
let nextAction = Action.Push;
let nextLocation = getNextLocation(to, state);
function retry() {
push(to, state);
}
// 上一次说到allowTx是用于处理history.block中注册的回调函数的。
// 这里可以看出,allowTx返回true时才会往历史栈添加新的历史条目(globalHistory.pushState)
if (allowTx(nextAction, nextLocation, retry)) {
let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
try {
globalHistory.pushState(historyState, '', url);
} catch (error) {
window.location.assign(url);
}
applyTx(nextAction);
}
}
function allowTx(action: Action, location: Location, retry: () => void) {
return (
!blockers.length ||
// 此处(xxx,false)的格式用于保证无论xxx返回什么,最后该条件的值还是false
(blockers.call({ action, location, retry }), false)
);
}
从allowTx
可知,只有blockers
中注册的的回调函数数量为0(blockers.length
)时,allowTx
才会返回true
。当在开头用法中的示例代码是这么写的:
let unblock = history.block(tx => {
if (window.confirm(`你确定要离开当前页面吗?`)) {
unblock();
tx.retry();
}
});
当window.confirm
形成的弹出框被用户点击确定后,会进入if
语句块,从而执行unlock
和retry
。unlock
用于注销该注册函数,retry
用于再次执行路由更改的操作。在retry
执行后,会再次调用push
函数,然后再次进入allowTx
函数,然后执行被history.block
注册的阻塞函数。
在存在多个被注册的阻塞函数,且history.push
被调用时,会有以下流程:
- 进入
history.push
函数 - 进入
allowTx
函数,有两种情况:blockers
中含被阻塞回调函数时:执行其中一个注册的阻塞回调函数,然后到下面第3步👇blockers
中不含被阻塞回调函数时,allowTx
函数返回true,然后到下面第4步👇
- 阻塞回调函数中调用
unlock
把自己从blockers
中移除,然后调用retry
回到第1步,一直重复1~3直到blockers
中的阻塞函数被清空。 - 调用
globalHistory.pushState
更改路由
注意:实现以上流程需要我们在每个被注册的阻塞回调函数中必须写调用unlock
和retry
的逻辑。
go
用法
类似的,我们可以推理出history.go
的作用是基本当前历史条目在历史栈中的位置去前进或者后退到附近的历史条目上。如:
// 下面两条语句实现的效果都一样,都是后退到前面的历史条目上
history.go(-1);
history.back();
源码分析
function go(delta: number) {
globalHistory.go(delta);
}
let history: BrowserHistory = {
// ...
go,
back() {
go(-1);
},
forward() {
go(1);
},
}
从源码看出,go
、back
、forward
其实就是间接性地调用globalHistory.go
。那么现在问题来了,使用go
可以触发在history.listen
和history.block
中注册的回调函数吗?
答案是可以的,我们看一下源码中下面这部分的代码:
const PopStateEventType = 'popstate';
// 前面也说过,'popstate'是在调用window.history.go或者点击浏览器的回退前进按钮才触发的
window.addEventListener(PopStateEventType, handlePop);
// handlePop主要用于在popstate触发后,触发阻塞事件中心blockers和监听事件中心listeners
function handlePop() {
if (blockedPopTx) {
blockers.call(blockedPopTx);
blockedPopTx = null;
} else {
let nextAction = Action.Pop;
let [nextIndex, nextLocation] = getIndexAndLocation();
if (blockers.length) {
if (nextIndex != null) {
let delta = index - nextIndex;
if (delta) {
// Revert the POP
blockedPopTx = {
action: nextAction,
location: nextLocation,
retry() {
go(delta * -1);
}
};
go(delta);
}
} else {
// Trying to POP to a location with no index. We did not create
// this location, so we can't effectively block the navigation.
warning(
// ...打印内容就不展示了
);
}
} else {
applyTx(nextAction);
}
}
}
我们假设popstate
刚被触发的情况下去逐步分析handlePop
的执行。首先,blockedPopTx
为空,所以会执行下面的操作:
// 定义行为为"POP"
let nextAction = Action.Pop;
// 从现在的historyState生成当前历史条目的index和location
let [nextIndex, nextLocation] = getIndexAndLocation();
// 如果有注册阻塞函数,则进入该语句执行
if (blockers.length) {
// 极少数情况存在某些历史条目的state中没记录idx即index,但这种情况我们不做讨论
if (nextIndex != null) {
// 根据delta可以知道当前历史条目的位置nextIndex和上一次的历史条目的位置index
// nextIndex:当前历史条目的位置
// index:上一次的历史条目的位置,调用history.go或者点击导航栏前后键时,
// history中的index不会立即被改变,只有执行了applyTx才会改变index和location
// 因此,delta即是表示上述两个索引的差值
let delta = index - nextIndex;
if (delta) {
// blockedPopTx用于在blockers.call时作为参数传入
blockedPopTx = {
action: nextAction,
location: nextLocation,
// 注意这里的retry里的逻辑,retry的调用可是做放行,此时调用go是为了让页面回到原位,
// 而retry的内部是调用go然后跳转到相对目前的第(delta * -1)的历史条目,
// 结合下面的go(delta)可知,在赋值了blockedPopTx后,通过go(delta)跳回到上一个历史条目上,
// 由此再次触发popstate事件,导致handlePop再次被执行
retry() {
go(delta * -1);
}
};
go(delta);
}
} else {
// ...这部分内容不作探讨
}
}
// 如果没有注册阻塞函数,则执行applyTx,执行监听回调函数的同时更新index和location
else {
applyTx(nextAction);
}
根据上面的注释,当blockedPopTx
被赋值后,通过go(delta)
再次触发popstate
事件。继而导致handlePop
再次被执行。此时会执行handlePop
下面的逻辑:
if (blockedPopTx) {
// 就是触发阻塞事件中心blockers去执行注册的阻塞函数
blockers.call(blockedPopTx);
blockedPopTx = null;
}
当阻塞函数被执行时,陆续执行内部的unlock
和retry
后。又会达到类似上一章block.源码分析章节中说到的循环的流程,如下所示:
- 初次
popstate
事件被触发,执行handlePop
,因blockedPopTx
为空,在此会有两种情况:- 存在被注册的阻塞回调函数:此时,在赋值赋值
blockedPopTx
后通过调用history.go
再次触发popstate
事件,继而到下面👇第2步 - 不存在被注册的阻塞回调函数:调用
applyTx
以执行监听回调函数的同时更新index和location
- 存在被注册的阻塞回调函数:此时,在赋值赋值
popstate
事件再次被触发,此时因blockedPopTx
已被赋值,因此触发blockers
轮询执行注册其中的阻塞函数,继而到下面👇第3步- 阻塞函数在用户交互中陆续调用
unlock
和retry
,但轮询不会因为retry
的调用而中断,因为history.go
是异步的(来源:MDN History.go),因此不会立即执行。且值得注意的是:**在一个宏任务或微任务中,无论history.go(-1)
被执行多少次,最终达到的效果只是路由只会回到前一次的路由,而不是多次。**因此retry
在轮询过程中被阻塞注册函数调用多次不会影响最终只回到相对现在距离delta
的页面的效果。轮询结束后,blockedPopTx
被置为null
值。然后到下面👇第4步。 - 轮询结束后,因为
retry
的调用,页面再次跳转。在依次经历了history.go(delta*-1)
和hsitory.go(delta)
后,页面回到初次触发popstate
时所在的历史条目。而popstate
事件再次被触发,此时会回到上面👆第1步的不存在被注册的阻塞回调函数的情况。
流程比较巧妙,看不懂可以多在脑子里想象一下流程。
基本history库
在history模式下的所有API
用法和分析都写完了。另外的两种模式其实大同小异,读者可以自行阅读。
后记
之后会更新一篇分析react-routerV6
源码的文章,由于react-routerV6
目前还不存在稳定版本,因此,我会一直观察到可以执笔写下文章的时候。