最近开始阅读
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模式
进行分析,获取
会话历史管理实例
的代码如下:
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
const iframeHistory = createBrowserHistory({
window: iframe.contentWindow
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,
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
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:
<T extends unknown>(obj: T) => T
* __DEV__在开发环境下为true
* 在开发环境下使用Object.freeze让obj只读,
* 不可修改其中的属性和新增属性
= __DEV__? obj => Object.freeze(obj): obj => obj;
export function createBrowserHistory(): BrowserHistory {
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
)变化。作为形参传入的回调函数会在当前路由变化时执行。示例代码如下:
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
的源码分析。
用过Vue-Router
和React-Router
都知道,push
就是把新的历史条目添加到历史栈上。而history.push
也是同样的效果,实例代码如下:
history.push('/home');
history.push('/home', { some: 'state' });
history.push({
pathname: '/home',
search: '?the=query'
some: state
export function createPath({
pathname = '/',
search = '',
hash = ''
}: PartialPath) {
return pathname + search + hash;
function createHref(to: To) {
return typeof to === 'string' ? to : createPath(to);
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) {
let nextAction = Action.Push;
let nextLocation = getNextLocation(to, state);
function retry() {
push(to, state);
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 applyTx(nextAction: Action) {
action = nextAction;
[index, location] = getIndexAndLocation();
listeners.call({ action, location });
replace
与Vue-Router
和React-Router
类似,replace
用于把当前历史栈中的历史条目替换成新的历史条目。传入的参数和history.push
一样。这里就不展示代码实例了。
function replace(to: To, state?: State) {
let nextAction = Action.Replace;
let nextLocation = getNextLocation(to, state);
function retry() {
replace(to, state);
if (allowTx(nextAction, nextLocation, retry)) {
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();
页面效果如下:
function promptBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = '';
let blockers = createEvents<Blocker>();
const BeforeUnloadEventType = 'beforeunload';
let history = {
block(blocker) {
let unblock = blockers.push(blocker);
if (blockers.length === 1) {
window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
return function() {
unblock();
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);
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 ||
(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
的逻辑。
类似的,我们可以推理出history.go
的作用是基本当前历史条目在历史栈中的位置去前进或者后退到附近的历史条目上。如:
history.go(-1);
history.back();
function go(delta: number) {
globalHistory.go(delta);
let history: BrowserHistory = {
back() {
go(-1);
forward() {
go(1);
从源码看出,go
、back
、forward
其实就是间接性地调用globalHistory.go
。那么现在问题来了,使用go
可以触发在history.listen
和history.block
中注册的回调函数吗?
答案是可以的,我们看一下源码中下面这部分的代码:
const PopStateEventType = 'popstate';
window.addEventListener(PopStateEventType, handlePop);
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) {
blockedPopTx = {
action: nextAction,
location: nextLocation,
retry() {
go(delta * -1);
go(delta);
} else {
warning(
} else {
applyTx(nextAction);
我们假设popstate
刚被触发的情况下去逐步分析handlePop
的执行。首先,blockedPopTx
为空,所以会执行下面的操作:
let nextAction = Action.Pop;
let [nextIndex, nextLocation] = getIndexAndLocation();
if (blockers.length) {
if (nextIndex != null) {
let delta = index - nextIndex;
if (delta) {
blockedPopTx = {
action: nextAction,
location: nextLocation,
retry() {
go(delta * -1);
go(delta);
} else {
else {
applyTx(nextAction);
根据上面的注释,当blockedPopTx
被赋值后,通过go(delta)
再次触发popstate
事件。继而导致handlePop
再次被执行。此时会执行handlePop
下面的逻辑:
if (blockedPopTx) {
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
目前还不存在稳定版本,因此,我会一直观察到可以执笔写下文章的时候。