本系列作为 SPA 单页应用相关技术栈的探索与解析,路由管理在单页应用中的重要性不言而喻,而路由的跳转与拦截等操作都依赖于 history API。本系列对 react-router 方案中使用的 history.js 与相关技术进行解析。
说到当前市面上流行的 React 路由管理方案,大家一定会想到 react-router。其底层依赖于 history.js ,其所提供的能力本质上又是对 History API 的二次封装,同时增添了监听路由变化与阻塞路由跳转的能力。本文我们基于当前最新的稳定版本 v5.3.0 的 history.js 进行分析。
聊聊路由和页面跳转的区别
许多小伙伴可能会有疑问:同样要实现针对不同路径展示不同UI组件,那路由跳转和页面跳转有什么区别呢?
这就要讲到单页应用(SPA)与多页应用(MPA)的区别了:多页应用意味着项目存在多个 HTML,url 变化时,加载新的 HTML 实现页面跳转;而单页应用只存在一个主 HTML,通过 js 脚本中对 url 变化的监听,实现不同页面的组件加载,这也就是我们所称的路由管理。
我们熟知的问卷星就是典型的多页应用,可以看到切换 Tab 时会重新加载页面以及 HTML:
而腾讯文档收集表项目则是单页应用,切换 Tab 时 url 上的 hash 会变化,展示不同页面组件,页面不会重新加载,且始终只有一个 HTML 文件:
路由会话 History 管理实例
对于基于 react-router 进行路由管理的项目中,路由操作都封装 history.js 中,通过生成的 history 实例来操作并记录 url 及对应状态的变化。主要有三种方式:
Hash History
如腾讯文档收集表项目,通过管理与操作 location.hash 来加载对应页面,可以通过如下方法创建实例对象:
import { createHashHistory } from 'history';
const history = createHashHistory({ window });
其中 window 属性可选,默认为 document.defaultView。
很简单吧,history 对象也就是当前封装后的 history 实例了,我们在项目中通过调用 history.push、history.replace、history.back 等方法就可以操作 url 的 hash 来实现路由跳转了!
Browser History
类似于常规的微前端方案,根据 location.pathname/search/hash 共同管理页面,可以通过如下方法创建:
import { createBrowserHistory } from 'history';
const history = createBrowserHistory({
// 指定 history 对象为 iframe 内的 window 的会话 History 管理实例
window: iframe.contentWindow,
});
同样,window 属性默认为 document.defaultView。该方法和 hash 路由十分相似,唯一的区别就是路由的跳转从 hash 变为了 pathname。
Memory History
上述两种方案都需要借助浏览器的能力,需要借助 window.history 的原生能力。但对于无浏览器的环境下,例如 Native 环境中,我们就可以考虑在内存中维护当前页面地址的索引,以达到和 url 类似的效果。而 Memory 路由就是借助了这一思路实现的。
import { createMemoryHistory } from 'history';
const history = createMemoryHistory({
initialEntries: ['/'],
initialIndex: 0,
});
由于三种方式的核心逻辑相同,为了不给大家增加太多的心智负担,本文仅针对 browser history 进行分析。
基于 browser history 的路由管理原理
首先我们解读 createBrowserHistory 主逻辑:
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;
}
可以看到,返回的 history 实例主要包含的方法都是我们熟悉的,和 Web 的 History Api 也基本保持一致。
此外,函数中的其他主要执行逻辑如下:
export function createBrowserHistory() {
let { window = document.defaultView! } = options;
let globalHistory = window.history;
let blockedPopTx: Transition | null = null;
function handlePop() { /* 省略逻辑 */ }
window.addEventListener(PopStateEventType, handlePop);
// 初始行为默认为 POP
let action = Action.Pop;
let [index, location] = getIndexAndLocation();
let listeners = createEvents<Listener>();
let blockers = createEvents<Blocker>();
if (index == null) {
index = 0;
globalHistory.replaceState({ ...globalHistory.state, idx: index }, '');
}
// 省略大量逻辑...
return history;
}
为了更深入地理解,我们对上面的逻辑简单拆分为三个部分:
操作与 Location 状态字段维护与初始化
在 createBrowserHistory 函数内部,维护了三个重要的字段:
-
action:上一个执行的操作类型,可能为 POP、PUSH、REPLACE
-
index:用于记录当前操作的历史条目的位置,即当前页面的 history 栈中的第几次操作
-
location:当前操作的 location 对象,用于记录 url 与 location 相关的信息。
getIndexAndLocation 方法在 browser history 中,截取了 window.location 对象,对 pathname,search 与 hash 字段进行了封装,连同 history.state.idx 字段一起返回:
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'
})
];
}
值得注意的是,window.history.state 可能是 null,所以在执行 createBrowserHistory 创建 history 对象时,这里生成的 index 值应该为 undefined :
对于这种场景,上面代码块中 if (index == null) 操作做了规范了初始化时的 index 为 0,且为 window.history.state 属性增添了从 0 开始计数的 idx 字段属性。
事件监听
这一步也是路由 history 管理中的核心,逻辑稍微有些绕,不理解也没关系,等我们分析完了其余部分,会回来仔细讲这里的执行逻辑的。现在,我们只需要知道,在调用 createBrowserHistory 创建 history 实例时,对 popstate 事件做了监听就好。
初始化监听队列与阻塞队列
对于 listeners 与 blockers,则是创建了类数组的数据结构,用于储存注册的监听函数/阻塞函数:
function createEvents<F extends Function>(): Events<F> {
let handlers: F[] = [];
return {
get length() { return handlers.length },
// 增添新的 fn,返回值为函数,调用后将这个 fn 从 handlers 中移除
push(fn: F) {
handlers.push(fn);
return function () {
handlers = handlers.filter((handler) => handler !== fn);
};
},
// 依次调用 handlers 中的方法
call(arg) {
handlers.forEach((fn) => fn && fn(arg));
}
};
}
browser history 是如何实现跳转、监听、拦截的
首先,要理解 browser history 的原理,我们需要对浏览器原生 History API 有较为全面的理解。不了解的同学可以先看看 MDN 中的文档:developer.mozilla.org/zh-CN/docs/…
在分析 broswer history 的 API 实现之前,我们先来看几个工具函数,这对我们理解后面的逻辑十分有用:
getNextLocation:
// 根据传入的新地址解析出对应的 Location 对象
function getNextLocation(to: To, state: any = null): Location {
// 生成不可变对象
return readOnly<Location>({
pathname: location.pathname,
hash: '',
search: '',
// parsePath 是将 string 类型 url 解析为类 Location 对象字段,省略
...(typeof to === 'string' ? parsePath(to) : to),
state,
key: createKey() // 生成随机 key
});
}
getHistoryStateAndUrl:
// 根据新的 Location 对象获取 url 和 State 对象
function getHistoryStateAndUrl(
nextLocation: Location,
index: number
): [HistoryState, string] {
return [
{
usr: nextLocation.state,
key: nextLocation.key,
idx: index
},
// 解析 Location 对象,拼出 URL
createHref(nextLocation)
];
}
allowTx & applyTx:
// 如果没有 blockers 返回 true,否则依次执行 blockers,并返回 false
function allowTx(action: Action, location: Location, retry: () => void) {
return (
!blockers.length || (blockers.call({ action, location, retry }), false)
);
}
// 执行 Action:同步设置全局 action、index、location 字段,随后调用 listeners
function applyTx(nextAction: Action) {
action = nextAction;
[index, location] = getIndexAndLocation();
listeners.call({ action, location });
}
重头戏来了,有了上面的分析基础,我们可以来看看跳转、监听、拦截是如何实现的了。
监听
注册监听函数很简单,生成的 history 实例对象调用 listen 方法,参数中传入监听函数即可。返回结果为取消监听的函数:
unlisten = history.listen(() => alert(222));
unlisten();
拦截
注册拦截函数的方法和监听函数相同:
unBlock = history.block(() => alert(333));
unBlock();
不同之处在于,在注册了 blocker 函数之后,需要监听 beforeunload 事件,进而在关闭浏览器窗口时页面取消注册的事件:
跳转
history.go
很简单,就是调用了 window.history.go 方法,支持传入 delta 参数指定浏览器走几步。
history.replace
可以看到,其本质是调用了 window.history.replaceState 方法,如果注册了 blockers,则不会进行跳转,函数内部状态也将不变。只有没有注册 blockers 时才会继续执行,且执行 listeners。
history.push
类似的,push 方法实际上是对 window.history.pushState 的封装,其余逻辑同上。不同点在于 push 对于 history 调用栈来说,相当于给它增加了一个 【state 对象与对应的 url】;而 replace 则是替换栈顶的【state 对象与对应的 url】:
值得注意的是,window.history 的 pushState 与 replaceState 方法的主要目的是为了修改或增添 history.state 对象,并通过栈的方式组织其关系。虽然在大多场景下 url 也会相应改变,但也可以实现不修改任何 url 的情况下修改 state 。这时,我们仍然可以点击浏览器左上角的【返回】与【前进】(也就是 history.go 方法),只不过 url 和页面并不一定改变。
所以,上述的 push 与 replace 方法,实际上都是对 history.state 对象进行操作,可以视作对 history 栈的修改。而 go, back, forward 以及浏览器的【返回】、【前进】按钮,则是在已有的 history 栈上游走,只有 history.go 时,才会触发浏览器的 popstate 事件。
回看 popstate 事件的玄机
在前面的【事件监听】小节,createBrowserHistory 对 popstate 事件注册了回调函数 handlePop,它主要的作用是获取 POP 操作后的 history.state 和 url 信息,并根据是否存在 blockers 决定 1)执行阻塞函数,还是 2)触发监听函数且更新全局 location 状态
let blockedPopTx: Transition | null = null;
function handlePop() {
// 首次触发 popstate 时,blockedPopTx 为空
// 不走 if 分支内的逻辑,进入 else 分支
// 此处出现在 设置了阻塞函数,且回撤掉了之前的 POP,需要借助 blockedPopTx 执行阻塞器
if (blockedPopTx) {
blockers.call(blockedPopTx);
blockedPopTx = null;
} else {
// 因为是 popstate 事件,所以指定下一个 Action 为 POP
let nextAction = Action.Pop;
// 从现在的 history.state 与 url 生成当前历史条目的对应的 index 和 location 对象
// 注意,只有调用了 applyTx 才会将 action、index、location 设置到全局,这里的 nextAction, nextIndex, nextLocation 都只是暂存在局部
let [nextIndex, nextLocation] = getIndexAndLocation();
// 如果设置了阻塞器,说明当前 POP 操作需要被拦截且回撤掉
if (blockers.length) {
if (nextIndex != null) {
// 计算当前历史条目和之前的差值,这个值代表着我们需要回撤几步
// 例如,index = 3, nextIndex = 5 -> delta = 2
// 说明前一个操作的 index 为 3,当前操作的 index 为 5,意味着 POP 操作让我们从 3 -> 5
// 有 blocker 的情况下,就需要再从 5 退回 3,所以需要走下面的 go(2) 回到原来的状态
let delta = index - nextIndex;
if (delta) {
blockedPopTx = {
action: nextAction,
location: nextLocation,
retry() {
go(delta * -1);
}
};
// 因为走了 go 方法后又会触发 popstate,而此时的 blockedPopTx 已经附带了当前的 state 与 location 信息
// 交给函数开头的 blockers 执行即可
go(delta);
}
} else {
// 在某些场景下的 history.state 中没有 idx 字段
// 这可能是因为使用的 history 并不是 createBrowserHistory 创建的
// 这种场景暂时不做讨论
}
} else {
// 如果没有注册阻塞函数,走 applyTx
// 更新全局 action,index,location 为新的 history.state 与 url 生成当前历史条目
applyTx(nextAction);
}
}
}
注释部分详细讲解了整个流程,如果还是觉得不够清晰,可以参考如下流程图:
以上,我们就较为全面地分析了 history.js 中三种不同的 History 类型以及 createBrowserHistory 的实现。本质上还是对 History API 的封装,同时结合对 popstate 事件的监听,基于 history.state 与 url 的角度实现路由监听、跳转与阻塞。
了解了 Browser History 的实现原理,我们就理解了 history.js 的核心逻辑。其余两种类型 createHashHistory 与 createMemoryHistory 的主线逻辑相同,我们将在下一篇文章对三者的差异部分进行分析。