众所周知,react-router / react-router-dom 在 v6 版本取消了对 remix-run / history 的依赖,大幅减负,内部自己实现了更简约、轻量的 history ,所以不再提供 useHistory 方法,这会导致:
如果从 react router v5 升级,迁移困难。
history.listen 无法使用,新的 useLocation 、useNavigate 学习成本等。
为了解决这个问题,本文介绍 三种不同场景 下重新实现 useHistory 的思路。
创建的路由使用 创建的路由多出现于 react router v6.4 以前,这是 < v6.4 的路由推荐创建方式,不带有 remix router 数据流功能。同时,在 >= v6.4 以后,react router v6 不再推荐使用该方式创建路由,但仍可继续使用。
实例:
import { BrowserRouter, Routes, Route } from 'react-router-dom'
function Root() {
return (
<BrowserRouter>
<Routes>
<Route path='/' elements={<Page />} />
</Routes>
</BrowserRouter>
)
}
这里基于以上层级结构,分析源码后我们直接给出两个结论:
react router 导出了 UNSAFE_NavigationContext 等内部 context 可以 hack 式的获取到 router 内部的数据。
在 UNSAFE_NavigationContext 层上,react router v6 提供了 navigator ,他即是内部创建出来的 history 。
有了以上两个结论,我们可以编写 useHistory 的实现:
// useHistory.ts
import { UNSAFE_NavigationContext } from 'react-router-dom'
import { type History } from '@remix-run/router'
export const useHistory = () => {
const navigator = useContext(UNSAFE_NavigationContext).navigator
return navigator as History
}
这个实现看似没有问题,但是存在一个限制,由于我们还是使用了 react router v6 内部创建的轻量化 history ,但是这个 history 限制了 history.listen 的监听器数量 最多添加 1 个 ,不巧的是在 中已经使用了 1 次 history.listen ,这意味着我们再无法使用 history.listen 了。
history.listen 的解法如果你并不使用 history.listen ,则可以安心使用这个 useHistory 的实现。但如果要使用,则需要重新实现 ( 源码见:function BrowserRouter() )
// 源码部分
export function BrowserRouter({
basename,
children,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
historyRef.current = createBrowserHistory({ window, v5Compat: true });
}
let history = historyRef.current;
let [state, setState] = React.useState({
action: history.action,
location: history.location,
});
// 🔴 这里已经使用了仅有 1 次的 `history.listen`
React.useLayoutEffect(() => history.listen(setState), [history]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
如上源码所示,我们在重新实现 时,新增一层订阅机制即可,让 history.listen() 触发时,不光执行 setState 还执行我们的订阅机制。
此处实现代码较多,展示略过,详见 react-router-use-history > BrowserRouter 。
到此为止,我们可以完美模拟
创建的路由中useHistory的实现。
createBrowserRouter 和 创建的路由createBrowserRouter + 创建路由法是 react router >= v6.4 后带来的,在 react router v6.4-pre 预发阶段,他被称为 DataBrowserRouter ,意味着使用 createBrowserRouter() 创建的路由具有 remix router 数据流的功能(如 loader / action )。
同时,createBrowserRouter 在 >= v6.4 以后也成为 react router v6 文档中推荐的创建路由方式。在该场景下,即使你不使用 remix router 带来的数据流功能,在其他方面都可以获得与 路由近似一致的体验。
useHistory 的实现与 不同的是,具备 remix router 数据流功能的 DataBrowserRouter 内部实现更为复杂,我们的思路与 场景一 相同,从 context 中 hack 数据:
// useHistory.ts
import { UNSAFE_DataRouterContext } from 'react-router-dom'
import { type History } from '@remix-run/router'
export const useHistory = () => {
const context = useContext(UNSAFE_DataRouterContext)
const navigator = context?.navigator
const state = context?.router?.state
const subscribe = context?.router?.subscribe
return {
get location() {
return state?.location
},
get action() {
return state?.historyAction
},
listen: subscribe,
...navigator,
} as History
}
场景一 需要考虑 history.listen 问题,那么在该场景下需不需要考虑 history.listen ?答案是不需要的,从如上编码的命名可以看出,DataBrowserRouter 内部已经额外实现了一套 subscribe 订阅机制,无需我们再做任何修改。
到此为止,我们可以完美模拟
createBrowserRouter创建的路由中useHistory的实现。
history此种场景是 场景一 的延伸,在该场景下:
history ,这意味着 history 可以在任意位置使用,不再局限于 react router 。在 场景一 中我们 fork 了 的实现,history 的创建时机也恰好在 中 ,从而我们可以从外部提供完全自定义的 history 实例:
import { type BrowserRouterProps } from 'react-router-dom'
import { type History as LegacyHistory } from 'history'
import { type BrowserHistory } from '@remix-run/router'
interface IBrowserRouterProps extends BrowserRouterProps {
history?: BrowserHistory | LegacyHistory
}
export function BrowserRouter({
basename,
children,
window,
history: specifiedHistory
}: IBrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
// 🟢 ↓ 在此处,我们优先使用传入的 history 实例
historyRef.current = specifiedHistory || createBrowserHistory({ window, v5Compat: true });
}
// ...
从而我们可以将 history 实例完全外置,实现在任意位置使用的目的:
// history.ts
import { createBrowserHistory } from 'history'
export const history = createBrowserHistory()
// App.tsx
import { BrowserRouter } from 'react-router-use-history'
import { history } from './history'
function Root() {
return (
<BrowserRouter history={history}>
{/* ... */}
</BrowserRouter>
)
}
// anywhere.ts
import { history } from './history'
// ...
history.push('...')
到此为止,我们实现了
history的完全自定义外置。
从某种程度上来说,使用 UNSAFE_* 的 router 内部 context 确实可以解决我们的问题,但也带来了一定的不确定性。
此外,react router v6 的 api 变化速度很快,保持敏感持续跟进才是王道。
本文完整代码见于:react-router-use-history