
🎙️ 前言
在我的 React SPA 应用中,路由用 react-router,但 useNavigate 的地方却几乎可以一只手能够数得过来。
怎么可能?你也许会质疑。
是的,你是否会想,为什么每次导航都要 useNavigate,不这么做就会全页刷新,也就丢失了 SPA 的良好体验。这就是用 react-router 的心智负担,不写 useNavigate 不行,写么又烦,难道就不能直接写链接么?作为一个懒人,我就是这么想的,也是这么做的。
TL;DR
我写了一个减轻 react-router 心智负担的库 @kcuf/react-router-link-hijacker,全局只需要装载一次,即可无痛导航,不再需要为用不用 useNavigate 而烦恼了,让你有更多的精力放在真正需要关心的业务上。
🤧 问题
在使用 react-router 构建的单页应用中,路由导航通常有三种写法:
useNavigate()配合onClick,最常用<Link to="..."><NavLink to="...">
路由是 SPA 中最常见的操作,所以实际开发中,你就得不断写类似下方的模式代码:
tsx
import {
useNavigate
} from 'react-router';
export default function SomeRoutingComponent(): ReactElement {
const navigate = useNavigate();
...
<Button {...{
label: 'xxx',
onClick: () => navigate('/another/route')
}} >
}
我一贯奉行的 没有好的开发体验,就不可能有好的用户体验 的开发准则。以上的模式代码,除了烦之外,还要担心哪里有没有错写成了纯链接从而导致整体刷新造成的用户体验问题。
useNavigate 重复性劳动
useNavigate 应该是用的最多的 API 了,贡献了不少枯燥乏味的模式代码。以下是它可能带来的隐患:
- 无链接语义 :多数开发者直接使用非链接 DOM(如
<span>、<div>),用户无法预知点击行为 ------ 没有状态栏地址提示(A11Y 很重要)、不能右键新窗口打开、不能复制链接地址、默认没有手型光标 - 需要手写拦截 :要解决上述的多个问题,就得改用
<a>标签,但代价是必须阻止默认行为、兼顾外部传入的onClick,每处都要写一遍,开发体验很差,如果忘记这么写就是整页刷新 - 重复历史记录 :
navigate不会判断当前路由,同一个链接点多次,会在浏览器历史里塞入多个重复项,用户要按多次「返回」才能真正回到上一页
Link / NavLink 难以适配样式
react-router 提供了组件 <Link> / <NavLink> 来处理上边的问题 2,但现如今的设计中,有多少链接是一个纯洁的 <a>?
如果你想把 Link 化妆成设计系统中常用的 Button,就需要重新适配一整套样式 ------ 写过 Button 的人都知道,这种基础组件「水很深」,适配成本很高。你想通过扩展的方式把设计系统中的 Button 组件底层换 Link 那更是难于登天了。
所以,Link 和 NavLink 在实际的项目中本身就不太受待见,几乎没什么使用场景。
无法跳脱 RouterContext
比如弹窗,纯组件方式,需要在组件树中先插入它,然后增加毫无激情的模式代码控制显隐。我很高兴看到已经有好多人已经在审视和反思这个问题,而我几乎从来没有写过组件式弹窗,都是以 Promise 形式封装的方法。
然而,这么做带来的问题就是,会多生一个 React 的根节点,而这个根节点只能跟宿主应用平起平坐,也就是说弹窗中没有 Router Hook 赖以生存的上下文,你不可能在弹窗中硬塞个 RouterProvider。路由上的参数等信息好说,以参数形式传入可以解决,但如果要在弹窗中进行路由切换怎么办呢?你可能会想到「回调」?
总归很麻烦,你可能会陷入鱼与熊掌不可兼得的尴尬。
💊 解决方案
所有喜欢造轮子的人本质上都是懒人,我就是这样的懒人,我不想到处引 useNavigate,也不想写 preventDefault(),更不想为 Link 复刻一份按钮的样式。我想要的是 href 打遍天下不用愁,并且不用担心任何用户体验问题。
go
所以我写了一个 `ReactRouterLinkHijacker`(`@kcuf/react-router-link-hijacker`),用于拦截整个页面上所有的链接点击,只要发现是相对路径,就执行 `navigate`,从而避免了到处 `useNavigate` 的瞎忙碌代码。
你只需要在应用顶层(Router 下)挂载一次,便能零侵入、毫无心智负担地完成全局整个应用内的 <a> 链接自动路由导航能力。唯一的限制就是你必须在根路由下载入它,但接下来的事情就太简单了。
你只需要像写普通链接一样写 <a href="/path">,组件会在背后拦截点击、判断是否为内部路由、调用 navigate 进行跳转,并自动避免重复历史记录。同样的,多数组件库都允许把按钮的底座改成 <a>,你要做的只是 <Button href="/path">。简单到 react-router 不存在一样。
🚀 如何使用
安装
bash
pnpm add @kcuf/react-router-link-hijacker
Peer 依赖 :react >= 19、react-router >= 7、@babel/runtime >= 7。
在根路由组件挂载
在项目的根路由组件进行挂载,可能想这样:
tsx
import ReactRouterLinkHijacker from '@kcuf/react-router-link-hijacker';
function RouteRoot(): ReactRouter {
return <>
<ReactRouterLinkHijacker />
<Header />
<Main />
</>;
}
清理既有代码
如果是既有项目,你可能需要逐步清理项目中冗余的导航写法:
- 清理
<Link>/<NavLink>,改回普通的<a>或使用封装好的 Button 组件(原有模拟 Button 的样式代码也可一并剔除)
- 清理
useNavigate()作点击处理的代码,对应的 DOM 改成<a>
哪些 useNavigate 要保留
你可能无法清理掉所有的 useNavigate。哪些不能清理呢?简单一句话,就是异步的 navigate(),比如你先调接口再判断要不要跳路由,跳哪个路由,这种必须保留。
哪些不要自动路由
@kcuf/react-router-link-hijacker 具有自动判断连接的 href 不需要路由的能力(见后面章节说明),除此之外,你也可以手动指定,只需要在链接上加上 data-route-not 属性即可:
tsx
<a href="/path" data-route-not="">下载</a>
<Button href="/path" data-route-not="">导出</Button>
需要手动忽略的场景其实非常少,更少的是需要动态检测的需求,真有的话可以在初始化的时候传入 props.ignore(el) 进行实时判断。
哪些不要增加路由历史
ReactRouterLinkHijacker 内部已经处理了重复历史问题,当前路由 /some-path 的时候,用户点击多少次 <a href="/some-path"> 都不会重复导航。
但有的时候,比如在登录页完成登录的提示中,让用户点击进入首页,你可能不想把登录页加到历史记录里,就可以给链接加上 data-route-replace 属性:
tsx
<Button href="/" data-route-replace="">进入首页</Button>
以上,整个项目中几乎看不到显式的路由导航代码 ------ 链接就是链接,路由跳转自然发生。
🧬 API
@kcuf/react-router-link-hijacker 的 API 简单且干净:
- 一个组件
ReactRouterLinkHijacker - 两个
data-router-*属性 - 自动忽略不应该是路由的链接
ReactRouterLinkHijacker
| Prop | 类型 | 说明 |
|---|---|---|
ignore |
(el: HTMLElement) => boolean |
自定义忽略规则,返回 true 表示该元素不参与劫持,但一般用 data-router-ignore 属性更简单 |
data-* 属性
在链接元素上可以使用以下属性来控制行为:
| 属性 | 说明 |
|---|---|
data-route-not |
标记该链接不参与路由,行为与原生 <a> 一致 |
data-route-replace |
使用 replace 模式导航(不新增历史记录) |
自动忽略的链接
以下情况会被自动跳过,无需手动标记:
- 外部链接(非同源)
- 带
target属性的相对地址链接(不论值) - 带
download属性的下载链接 - 用户按住
Meta键(Win:Ctrl、Mac:Cmd)点击,将触发浏览器默认行为 ------ 新开 Tab
🪭 原理
ReactRouterLinkHijacker 的实现原理其实就是结合了全局监听链接点击的依赖 react-router 的组件。
React 最重要的 API 是什么?个人认为是 Context,如果没有它,写 React 应用将陷入无法理清楚的超长 props 链和回调链。它也是 react-router 以及很多框架的核心。
打开 React Developer Tools 插件,一般你会看到 React 应用只会有一个根节点,并且你可以找到 ReactRouter 的根节点 RouterProvider,所有的 useNavigate 和 Link 等都跟它有关,也就是说,无论 Hook 还是组件,都必须在这个 RouterProvider 下才能运行。
以下便是一个使用了 ReactRouterLinkHijacker 的 React 应用的组件树(1 - 应用根,2 - 路由根,3 - 自动路由魔法):

ReactRouterLinkHijacker 上承 RouterProvider,因此可以使用所有路由 Hook,而其内部只是一个简单的全局监听的 Effect,当发现符合条件的 <a> 被点击后,执行路由导航。
🚬 总结
@kcuf/react-router-link-hijacker 的核心理念是 「让链接回归链接」:
- 开发者写纯粹的
<a href="...">,无需到处依赖react-router,减少模式代码 - 自动判断内部路由、自动忽略非路由链接、自动避免重复历史,为开发减负到极致
- 鼓励使用纯链接和既有 Button 组件,一方面代码冗余少了,更重要的是用户体验的提升
- 内置的「不要路由」的判断能够符合 95% 以上的特殊情况
- 通过
data-route-not/data-route-replace增加更多的灵活性
如果你的 react-router 项目里,到处是 useNavigate 样板代码,或者为包装 <Link> 要写大量 CSS,不妨试试这个方案 ------ 全局挂载一次,还项目一片清爽。
🙋 FAQ
❓ 为什么要在顶级路由加载?
你不能在应用顶层加载,因为应用顶层一般都不是 react-router 的顶层 RouterProvider,而 ReactRouterLinkHijacker 用到的 react-router 的 Hook。
当然,你也可以在所有的一级路由下进行挂载,不会有问题,组件会随着一级路由的卸载而取消其添加的副作用,但这样就失去了提效减负的意义。
❓ 为什么只监听连接?
之所以只监听 <a href="..."> 的原因是为了标准化,当然理论上也是可以监听任何带 href 属性或 data-href 的节点,但这个组件本身的目的之一就是为了标准化,所以不提供非标准的方式。
❓ 是否一定要清理 useNavigate 和 Link 等旧代码?
是,但也不是。
不需要清理,是因为 ReactRouterLinkHijacker 和应用中的代码不会产生实质性的冲突:
- 如果你用的不是
<a>或者是不带href的<a>,不属于组件的管辖范畴 - 如果你用的是
Link或者<a href="/path">+useNavigate+preventDeafult(),导航不会有问题(但可能会带来诡异的点一次出两个历史的现象)
但我推荐清理掉,因为代码中的污渍跟生活中的很像,如果不及时清理,会长毛泛滥污染更多的地方。
❓ 有什么防范替代 NavLink 的 active 状态?
<NavLink> 的一大卖点是自动高亮当前路由。使用 react-router-link-hijacker 后,可以改用 matchPath + useLocation 实现相同的效果:
tsx
import {
matchPath,
useLocation
} from 'react-router';
const SOME_PATH = '/dashboard';
function NavItem() {
const {
pathname
} = useLocation();
const active = !!matchPath(SOME_PATH, pathname);
return <a href={SOME_PATH} className={active ? 'is-active' : ''}>仪表盘</a>;
}
简单明了,不依赖 <NavLink> 的渲染回调,更容易和自定义样式集成。