前端路由解析系列一 —— history 会话历史管理库
背景介绍
提起前端路由,所有基于 react 技术栈的前端路由方案 react-router 、react-router-dom(web 端) 和 react-router-native(移动端)都离不开一个叫 history 的库。
react-router v4 和 v5 依赖的是 history v4 版本,当前 react-router@6.3.0 依赖了 history@5.2.0,而且官方也宣称 react-router v6 会依赖 history v5 版本,目前看 react-router 团队情绪稳定,起码在这个问题上不会再变了。实际上,history 也是 react-router 团队的产品。
前端路由方案差异
抛开 react-router 不谈,如何用原生 JavaScript 实现一个前端路由?
所谓前端路由,我们无非要实现两个功能:监听记录路由变化,匹配路由变化并渲染内容。以这两点需求作为基本脉络,我们就能大致勾勒出前端路由的形状。
1. Hash 实现
我们都知道,前端路由一般提供两种匹配模式, hash
模式和 history
模式,二者的主要差别在于对 URL 监听部分的不同,hash 模式监听 URL 的 hash 部分,也就是 #
号后面部分的变化,对于 hash 的监听,浏览器提供了 onHashChange
事件帮助我们直接监听 hash 的变化:
2. History 实现
相较于 hash 实现的简单直接,history 模式的实现需要我们稍微多写几行代码,我们先修改一下 a
标签的跳转链接,毕竟 history 模式相较于 hash 最直接的区别就是跳转的路由不带 #
号,所以我们尝试直接拿掉 #
号:
点击 a
标签,会看到页面发生跳转,并提示找不到跳转页面,这也是意料之中的行为,因为 a
标签的默认行为就是跳转页面,我们在跳转的路径下没有对应的网页文件,就会提示错误。
那么对于这种非 hash 的路由变化,我们应该怎么处理呢?大体上,我们可以通过以下三步来实现 history 模式下的路由:
- 拦截
a
标签 的点击事件,阻止它的默认跳转行为 - 使用 H5 的
history API
更新 URL - 监听和匹配路由改变以更新页面
在开始写代码之前,我们有必要先了解一下 H5 的几个 history API
的基本用法。
其实 window.history
这个全局对象在 HTML4 的时代就已经存在,只不过那时我们只能调用 back()
、go()
等几个方法来操作浏览器的前进后退等基础行为。
而 H5 新引入的 pushState()
和 replaceState()
及 popstate
事件 ,能够让我们在不刷新页面的前提下,修改 URL,并监听到 URL 的变化,为 history 路由的实现提供了基础能力。
详细的参数介绍和用法读者们可以进一步查阅 MDN,这里只介绍和路由实现相关的要点以及基本用法。了解了这几个 API 以后,我们就能按照我们上面的三步来实现我们的 history 路由:
History 模式的实现代码也比较简单,我们通过重写 a
标签的点击事件,阻止了默认的页面跳转行为,并通过 history API 无刷新地改变 url,最后渲染对应路由的内容。
history 模式的代码无法通过直接打开 html 文件的形式在本地运行,在切换路由的时候,将会提示:
Uncaught SecurityError: A history state object with URL ‘file://xxx.html’ cannot be created in a document with origin ‘null’.
这是由于 pushState 的 url 必须与当前的 url 同源,而
file://
形式打开的页面没有 origin ,导致报错。如果想正常运行体验,可以使用http-server
为文件启动一个本地服务。
到这里,我们基本上了解了hash
和history
两种前端路由模式的区别和实现原理,总的来说,两者实现的原理虽然不同,但目标基本一致,都是在不刷新页面的前提下,监听和匹配路由的变化,并根据路由匹配渲染页面内容。
在线上 SPA 项目中如果使用 HistoryRouter 路由模式,通常需要在 Nginx 层做转发,因为后端不会兼容多页面路由
阅读 react-router 源码,我们会发现 BrowserRouter 和 HashRouter 就是一个壳。
两者的代码量很少,代码也几乎一致,都是创建了一个 history
对象,然后将其和子组件一起透传给了**<Router>
**,二者区别只在于引入的 createHistory()
不同。
因此对于这二者的解析,其实是对 <Router>
(对 Router 的解析放到 前端路由解析系列二——路由匹配原理 )和 history
库的解析。
history 库设计及代码解读
1. 实例化
首先,我们需要获取一个会话历史管理实例(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';
createHistory 源码分析:
// 为看清结构,省略了部分代码
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
创建了实例,然后把该实例导出,这样子就成为了单例模式的导出。
2. 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
:代表当前路由地址的唯一值
location 源码分析:
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;
}
const readOnly:
// ts类型定义
<T extends unknown>(obj: T) => T
/**
* __DEV__ 在开发环境下为 true
* 在开发环境下使用 Object.freeze 让 obj 只读,
* 不可修改其中的属性和新增属性
*/
= __DEV__? obj => Object.freeze(obj): obj => obj;
从getIndexAndLocation
中可以看出location
中的pathname
、search
、hash
取自window.location
。state
和key
分别取自window.history.state
中的key
、usr
。至于window.history.state
中的key
、usr
是怎么生成的,我们留在后面介绍push
方法时介绍。
3. 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
获取
listen 源码分析:
// 基于观察者模式创建一个事件中心
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);
},
}
所以调用 listen
方法就相当于设置一个观察者,待事件中心在合适时机触发观察者携带的事件。
至于为啥回调函数的形参可以解构出action
和location
。可以看下面介绍push
的源码分析。
4. 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
});
push 源码分析:
// 上面说到 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 });
}
5. replace
与Vue-Router
和React-Router
类似,replace
用于把当前历史栈中的历史条目替换成新的历史条目。传入的参数和history.push
一样。
replace 源码分析:
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
方面有所不同。
6. block
history.block
是相比于原生的window.history
中新增的一个很好的特性。它能够在当前路由即将变化之前,执行回调函数从而阻塞甚至拒绝更改路由。看一下示例代码:
let unblock = history.block((tx:{nextAction, nextLocation, retry}) => {
if (window.confirm(`你确定要离开当前页面吗?`)) {
// 移除阻塞函数,这里必须要移除,不然该阻塞函数会一直执行
unblock();
// 继续执行路由更改操作
tx.retry();
}
});
block 源码分析:
// 此方法在 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
中传入的回调函数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
的逻辑。
7. go
类似的,我们可以推理出history.go
的作用是基本当前历史条目在历史栈中的位置去前进或者后退到附近的历史条目上。如:
// 下面两条语句实现的效果都一样,都是后退到前面的历史条目上
history.go(-1);
history.back();
go 源码分析:
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
是异步的,因此不会立即执行。且值得注意的是:在一个宏任务或微任务中,无论history.go(-1)
被执行多少次,最终达到的效果只是路由只会回到前一次的路由,而不是多次。因此retry
在轮询过程中被阻塞注册函数调用多次不会影响最终只回到相对现在距离delta
的页面的效果。轮询结束后,blockedPopTx
被置为null
值。然后到下面👇第4步。 - 轮询结束后,因为
retry
的调用,页面再次跳转。在经历了history.go(delta*-1)
后,页面回到初次触发popstate
时所在的历史条目。而popstate
事件再次被触发,此时会回到上面👆第1步的不存在被注册的阻塞回调函数的情况。
基本history库
在 history 模式下的所有API
用法和分析都写完了。另外的两种模式其实大同小异,读者可以自行阅读。
敬请期待下一篇文章前端路由解析系列二——路由匹配原理
转载自:https://juejin.cn/post/7198081320873918525