上一篇文章,我们以 Browser History 为例分析了 history.js 的工作原理以及会话历史管理方式,为理解 react-router 路由体系打下了基础。针对不同使用场景,除了 Browser history 外,history.js 还提供了 Browser history 与 Memory history,本文来重点讲解下三者的使用场景与区别。
浏览器环境的路由
我们知道,前端路由体系的基础就是对路径的追踪与监听,而强行修改 URL 会导致页面的重加载,也就失去了单页应用与路由体系的作用。所以最直观的做法就是对 URL 变化进行监听,劫持 URL 变化并记录在 history 堆栈中,以钩子的形式暴露给上层框架注册页面组件加载、事件触发等行为。
可以看到,浏览器导航栏中的 URL 通常由四个部分组成,想必大家都不陌生。从 URL 的角度,history.js 以及 react-router 系统提供了对 pathname 或者 hash 的两种监听方式。
我们改编一下 react-router 官方 Demo 代码来进行演示:
// 入口文件
import React from "react";
import ReactDOM from "react-dom";
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom";
import App, { history } from "./App.tsx";
ReactDOM.render(
// 支持自定义传入 history
// react-router 提供的 BrowserRouter 其实就是 HistoryRouter 传入了
// createBrowserHistory 创建的 history 对象
<HistoryRouter history={history}>
<App />
</HistoryRouter>
document.getElementById("root")
);
// App.tsx
import { Routes, Route, Outlet } from "react-router-dom";
export default function App() {
return (
<div>
// 省略头部的主体部分
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
</div>
);
}
// 省略 <About />, <NoMatch />, <Home />, <Dashboard /> 的实现
export const history = /** 待填充 */;
function Layout() {
return (<div>
<nav>
<ul>
<li><div onClick={() => history.push('/')}>Home</div></li>
<li><div onClick={() => history.push('/about')}>About</div></li>
<li><div onClick={() => history.push('/dashboard')}>Dashboard</div></li>
<li><div onClick={() => history.push('/nothing-here')}>Nothing Here</div></li>
</ul>
</nav>
// 暂时忽略,下一篇文章会讲作用
// 大致就是在 <Outlet /> 处展示上面 <Route /> 里的 children 组件
<Outlet />
</div>);
}
监听 Pathname
对于 Browser History 场景,我们在上面 35 行引用 createBrowserHistory
方法创建 history
对象:
import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();
可以看到,在点击事件触发时,pathname 发生了改变,点击区域下方的文案也会相应变化,而整个过程中只加载了一次 HTML 文件。
监听 hash
现在让我们监听 hash,重新演示一遍上面的例子,可以看到,这回变动区域变成了地址的 hash 部分:
import { createHashHistory } from 'history';
export const history = createHashHistory();
从上一篇文章的解析中,我们也知道,Browser History 与 Hash History 的区别就仅仅是监听 URL 的位置由 location 变为 hash 而已。所以本文的重心将放在第三种路由实现方式 —— Memory History
非浏览器运行时的路由
在浏览器运行时下,借助 URL 这一利器可以很好的进行路由定位与追踪,但如果在 RN 等非浏览器运行时环境下,显然监听 pathname 与 hash 就没办法满足需求了。针对这种场景,history.js 提供了 MemoryHistory 的模式,将路由关系映射储存于内存中,就可以实现基于内存的路由管理了。
现在我们用 createMemoryHistory
来改造之前 Demo:
import { createMemoryHistory } from 'history';
export const history = createMemoryHistory();
我们利用上面的 GIF 演示来模拟非浏览器环境:可以看到,在完美复刻上一部分中的功能的前提下,所有的路由跳转并没有伴随 URL 的变化,这意味着所有的地址 & 页面组件的路由映射是保存在内存中的,不需要借助 URL 来做辅助。
Memory History 的实现原理
上一篇文章我们知道,Browser History 与 Hash History 本质上是对 window.location
的监听,以及对 window.history
原生state、原生跳转/回溯能力的封装。而在非浏览器环境中,Memory History 的核心思路就是模拟 location 构造映射关系,然后通过操作 location 映射关系实现原生 history 的能力。
createMemoryHistory
API 的整体逻辑大致如下:
export function createMemoryHistory(options: MemoryHistoryOptions = {}): MemoryHistory {
let { initialEntries = ['/'], initialIndex } = options;
let entries: Location[] = initialEntries.map((entry) => {
let location = readOnly<Location>({
pathname: '/',
search: '',
hash: '',
state: null,
key: createKey(),
...(typeof entry === 'string' ? parsePath(entry) : entry)
});
return location;
});
let index = clamp(
initialIndex == null ? entries.length - 1 : initialIndex,
0,
entries.length - 1
);
let action = Action.Pop;
let location = entries[index];
let listeners = createEvents<Listener>();
let blockers = createEvents<Blocker>();
// 省略大量逻辑...
let history: MemoryHistory = {
get index() { return index },
get action() { return action },
get location() { return location },
createHref,
push,
replace,
go,
back() { go(-1) },
forward() { go(1) },
listen(listener) { return listeners.push(listener) },
block(blocker) { return blockers.push(blocker) }
};
return history;
}
可以看到,整体结构与返回的 API 类型与 Browser/Hash History 几乎相同。不过不同于浏览器环境下只需将 window
对象传入即可,Memory History 可选提供 initialEntries
与 initialIndex
,用于初始化映射表(用于模拟 history,记录路由变化以及标识初次进入的页面所在映射表的位置(用于模拟当前路由在堆栈的位置)。
如果你现在看的还很懵,不着急,我们来拆解整个函数,看看具体与 Browser/Hash History 有什么不同
映射关系构建
映射关系构建的逻辑如下:
可以看到,针对传入的 initialEntries构建了 entries 数组,同时用 index 字段来表示当前所在的路由位置,默认取 entries 数组的最后一条为初始位置。
Location
上面截图的最后一行定义了 location 字段,默认取最后一条 entries
记录,用于模拟浏览器的 window.location
:
let location = entries[index];
可以理解为,location
就是在不借助 URL 的场景下,当前页面所对应的路由。
在上面的 Demo 中,我们没有给 createMemoryHistory
传入参数,这意味着打开页面的入口默认以根路由('/')作为首次加载的页面组件。此时 entries
中只有根路由('/')一个元素,location
也对应到根路由:
而如果需要指定页面入口为某个路由,我们可以在 initialEntries
与 initialIndex
中配置,例如:
const history = createMemoryHistory({
initialEntries: ['/about', '/', '/dashboard'],
initialIndex: 0
});
我们注册了三个路径,并指定了初始 index
指向第一条,也就是 '/about'
这一路由,也就意味着初始化页面时,location
会指向 '/about'
路由,上层的 react-router 就会识别并加载 <About />
组件。
其余初始化
与浏览器环境下的 History 类似,除了 location
和 index
字段外,action
、listeners
、blockers
也会在此时初始化。不过不同的是,非浏览器环境下无需关心除交互事件以外可能造成页面跳转的操作(例如浏览器环境下点击导航栏“前进/返回”按钮)。由于 Memory History 的所有跳转都是由 createMemoryHistory
生成的 history
对象完成的,就意味不需要监听并处理 window 的 hashchange 与 popstate 等事件,极大程度上简化了复杂度。
Memory History 是如何实现跳转与拦截的
在浏览器环境下,跳转、拦截与监听都极大程度上依赖 History API 与 window.location
,而在 Memory History 中,无需监听 URL 变化,一切操作都基于映射表,跳转与拦截操作也极大程度被简化。
首先我们先来看几个工具函数,这对我们理解后面的逻辑十分有用:
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
});
}
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 });
}
这一部分的实现与 Browser/Hash History 完全一致,也恰恰说明了在 Memory History 模式下,location 也被处理成了同 Browser/Hash History 的数据结构,更有便于我们分析。
跳转
同样,跳转能力可以分为三种:
-
push:堆栈叠加
-
replace:堆栈元素替换
-
go(n):堆栈中进行位置穿梭n为(n 可为负数)
push
可以看到,push 操作实际上是将 index
递增,随后将后面位置的所有 entries
元素都删除,然后插入新的 location
元素。在 index
之前的 entries
所有元素都会被保留,这也是为了方便回溯,可以使用 go(-delta)
访问之前的堆栈元素。entries
数组在这之中也起到了 history 的作用,可以追溯 location
的变化。
replace
类似地,replace
操作就是将 entries
数组中当前元素直接替换为新的 location 元素,非常直观。
go(n)
由于我们前面说到,entries
数组记录了全部 location 的变化,就意味着前进或倒退等方式,也可以访问到对应的 location。go
方法就起到了这样的作用,传入的 delta
值可以为负,意味【返回】,也可以为正,意味【前进】。
监听与拦截
由于少了监听和浏览器环境下的 handlePopup
逻辑函数,监听与拦截相对来说就十分简单了。通常,上层库会注册 listeners,实现监听路由的跳转;此外,想要弹出 Alert 等方式挽留用户,也可以直接注册 blockers。
对比 Browser/Hash History 的实现
回顾上一篇文章,浏览器环境下 history 的跳转实际上就是对 window.history
对应 go
/replaceState
/pushState
的封装;监听与拦截的方式也类似,生成的 history 实例对象调用 listen/block 方法,参数中传入监听/阻塞函数即可,不过浏览器环境下需要额外监听 beforeunload 事件,进而在关闭浏览器窗口时页面取消注册的事件。总体来说处理方式都是类似的,只是借助 window.history
与自己维护 entries
堆栈的区别。
事实上,市面上绝大多数的会话历史都是基于 URL 的管理方案,因为借助 URL 可以最简单且清晰地帮助追溯路由变化,且稳定性高;且利用内存管理路由,不当的操作还可能导致运行时内存膨胀,损耗性能(虽然不当的 URL 操作也会…)。但是了解 Memory History 的实现原理仍然是有必要的,它不仅有助于理解路由的实现与 history 原理,更提供了一种拓宽解决思路的思考方式。
最后总结来说,Memory History 的模式主要运用于以下场景:
- 没有 URL 或 window 的无浏览器环境,例如 RN
- 不希望将路由变化通过 URL 暴露出来时
至此,我们算是将 history.js 的全部细节都解析透彻了。作为 react-router 的底层库,了解 history.js 的实现原理对理解前端路由有着至关重要的作用。下一篇文章我们会正式进行 react-router 的源码解析,也欢迎大家一起讨论。