受 Vue Router 启发的 React 路由库。Vue Router 风格守卫、Koa 风格中间件、内置 LRU 缓存,Router 实例即全局 API
| 特性 | @jl-org/react-router | React Router | TanStack Router |
|---|---|---|---|
全局守卫 beforeEach/beforeResolve/afterEach |
✅ Vue Router 风格全局 API | route.beforeLoad 路由 / 子树级 |
|
| Koa 风格中间件 | ✅ route middlewares,(ctx, next) |
✅ middleware / clientMiddleware |
beforeLoad + loader 生命周期 |
| 内置组件 keep-alive / LRU 页面缓存 | ✅ 组件实例级 LRU keep-alive | ❌ 无内置组件 keep-alive | |
全局布局 layouts |
✅ 集中配置 pathname 匹配规则 | ||
| 路由过渡动画 + 前进/后退方向感知 | ✅ headless 四阶段 + direction,浏览器前进/后退可感知 |
pnpm i @jl-org/react-router
# or npm i @jl-org/react-router
# or yarn add @jl-org/react-routerimport { lazy } from 'react'
import { RouterProvider, Outlet, createBrowserRouter } from '@jl-org/react-router'
const router = createBrowserRouter({
routes: [
{ path: '/', component: lazy(() => import('./views/home')) },
{
path: '/dashboard',
component: lazy(() => import('./views/dashboard')),
meta: { title: 'Dashboard', requiresAuth: true },
middlewares: [
async (ctx, next) => {
if (ctx.meta?.requiresAuth && !getUser()) { ctx.redirect('/login'); return }
await next()
},
],
},
],
options: {
cache: { limit: 5, exclude: ['/login'] },
beforeEach: async (to, _from, next) => next(),
afterEach: (to) => { document.title = to.meta?.title ?? 'App' },
},
})
export function App() {
return <RouterProvider router={router}><Outlet /></RouterProvider>
}
// Router 实例即全局 API
router.navigate('/dashboard')
router.replace('/login')createBrowserRouter({
routes: RouteObject[],
options: {
base?: string,
cache?: boolean | {
limit?: number, // @default 10
include?: (string | RegExp)[], // 不传则缓存所有路径,传空数组则不缓存
exclude?: (string | RegExp)[], // 优先于 include,命中则不缓存
},
cacheKey?: (loc: LocationLike) => string,
loadingComponent?: ReactElement | ComponentType,
notFoundComponent?: ReactElement | ComponentType,
layouts?: LayoutConfig[], // 全局布局
transition?: { // 路由过渡动画,不传则完全不启用(零行为差异)
enterTimeout?: number, // 进场兜底超时(毫秒)@default 500
exitTimeout?: number, // 退场兜底超时(毫秒)@default 500
respectReducedMotion?: boolean, // 遵循 prefers-reduced-motion @default true
},
beforeEach?: NavigationGuard,
beforeResolve?: NavigationGuard,
afterEach?: AfterEachGuard,
},
})cache 开启后,命中的页面会以 keep-alive 形式保留组件状态。需要在退出登录、切换租户、切换账号等场景释放缓存时,可以直接调用 Router 实例方法:
// 清空所有 keep-alive 页面缓存
router.clearCache()
// 只删除某个缓存 key
router.deleteCache('/cards')
// 按正则或函数删除一组缓存 key
router.deleteCache(/^\/cards/)
router.deleteCache(key => key.includes(':guest'))如果清缓存后会立刻跳转,推荐在目标路由进入后再清,避免当前可缓存页面在同一次 render 中重新写回缓存:
router.afterEach((ctx) => {
if (ctx.to.pathname === '/login')
router.clearCache()
})| 字段 | 说明 |
|---|---|
path |
路径 |
component |
组件或 lazy() |
children |
嵌套路由 |
meta |
自定义信息 |
middlewares |
Koa 风格 (ctx, next) |
loadingComponent |
懒加载占位,优先于全局 |
layoutComponent |
路由级布局(包裹当前路由) |
transition |
路由级过渡:与全局字段级合并(路由优先);false 单独关闭 |
按 pathname 匹配,第一个命中的布局包裹渲染结果。exclude 优先;include 为空则匹配全部
const router = createBrowserRouter({
routes: [...],
options: {
layouts: [
{
component: MainLayout, // 接收 children 的布局组件
include: ['/dashboard', '/users'],
exclude: ['/login'],
},
{
component: AdminLayout,
include: ['/admin'],
},
],
},
})| 字段 | 说明 |
|---|---|
component |
布局组件,({ children }) => ReactNode |
include |
命中任一则使用;空则匹配全部 |
exclude |
命中任一则跳过此布局 |
options: {
notFoundComponent: () => <div>页面不存在</div>,
}headless 设计:库只提供阶段状态机与方向信号,不绑定任何动画库——用纯 CSS、motion/react 或其它方案消费均可。与 keep-alive 缓存完全独立,未缓存的路由同样有退场窗口
// 全局开启
options: {
transition: { enterTimeout: 350, exitTimeout: 350 },
}
// 路由级粒度:与全局字段级合并(路由字段优先),就近生效
routes: [
{ path: '/heavy', component: Heavy, transition: { exitTimeout: 800 } }, // 覆盖退场超时
{ path: '/instant', component: Instant, transition: false }, // 单独关闭过渡
{ path: '/only-me', component: OnlyMe, transition: { enterTimeout: 350 } }, // 全局未配置时也可单路由开启
]开启后路由切换不再立即生效,而是经过可控的过渡窗口。退场中的页面用自己的配置播完动画,不受目标路由配置影响。页面内用 useRouteTransition() 读取状态:
| 字段 | 说明 |
|---|---|
phase |
entering 进场中 → entered 稳定展示 → exiting 退场中 → exited 退场完成 |
direction |
本次切换的导航方向:forward(push / 浏览器前进)、back(浏览器后退 / navigate(-1))、replace(显式 replace / 守卫重定向) |
finishEnter() |
手动确认进场动画结束;不调用则由 enterTimeout 兜底 |
finishExit() |
手动确认退场动画结束;不调用则由 exitTimeout 兜底 |
direction 在切换瞬间快照,动画播放中途不受后续导航影响;基于 history.state 位点推导,浏览器原生前进 / 后退按钮同样可感知
用 useRouteTransitionBindings()——它把消费过渡的三个易错细节收进了库里(进场双帧节奏、子元素 transitionend 冒泡过滤、动画结束自动回调 finish),你只写样式,把 bind 展开到做动画的元素上即可:
import { useRouteTransitionBindings } from '@jl-org/react-router'
const DURATION = 300 // 必须小于 enterTimeout / exitTimeout,否则动画被兜底腰斩
export function PageTransition({ children }: { children: React.ReactNode }) {
const { isEntering, isExiting, direction, bind } = useRouteTransitionBindings()
/** forward / back 走横向滑动;replace 无栈方向语义,退化为竖直淡入淡出 */
const axis = direction === 'replace'
? 'Y'
: 'X'
const enterOffset = direction === 'back'
? -12
: 12
return (
<div
style={{
transition: `all ${DURATION}ms ease-out`,
opacity: isExiting || isEntering
? 0
: 1,
transform: isExiting
? `translate${axis}(${-enterOffset}px)`
: isEntering
? `translate${axis}(${enterOffset}px)`
: `translate${axis}(0)`,
}}
{...bind}
>
{ children }
</div>
)
}bind 同时监听 transitionend 与 animationend,CSS animation 写法同样免接线
JS 动画库不经过 DOM 过渡事件,不需要 bind——用 phase / direction 驱动 animate,在动画完成回调里调用 finishExit / finishEnter 原语通知路由:
import { useRouteTransition } from '@jl-org/react-router'
import { motion } from 'motion/react'
export function MotionPageTransition({ children }: { children: React.ReactNode }) {
const transition = useRouteTransition()
/** 未开启过渡(全局未配或路由级 false):正常渲染即可 */
if (!transition)
return <>{ children }</>
const { phase, direction } = transition
const isExiting = phase === 'exiting'
// forward 从右进左出、back 相反;replace 走竖直。x / y 恒同时给出,避免残留上一轴旧值
const enter = direction === 'back'
? -24
: 24
const offset = (v: number) => (direction === 'replace'
? { x: 0, y: v }
: { x: v, y: 0 })
return (
<motion.div
initial={phase === 'entering'
? { opacity: 0, ...offset(enter) }
: false}
animate={isExiting
? { opacity: 0, ...offset(-enter) }
: { opacity: 1, x: 0, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
onAnimationComplete={() => {
if (transition.phase === 'exiting')
transition.finishExit()
else if (transition.phase === 'entering')
transition.finishEnter()
}}
>
{ children }
</motion.div>
)
}要点:initial 只在 phase === 'entering' 时给起始态(entered 稳定态不重播入场);动画时长同样要小于兜底超时。完整可运行版本见 demo 的 MotionPageTransition(/push-replace 路由使用它,其余路由为 CSS 版,可对比手感)
页面组件零侵入,新增页面自动获得过渡,不会漏包:
const withPageTransition = (Component: ComponentType<any>) => (props: any) => (
<PageTransition>
<Component {...props} />
</PageTransition>
)
const router = createBrowserRouter({
routes: [
{ path: '/', component: withPageTransition(Home) },
{ path: '/dashboard', component: withPageTransition(lazy(() => import('./views/dashboard'))) },
],
options: {
transition: { enterTimeout: 350, exitTimeout: 350 },
},
})- 全局
transition下,不消费过渡状态的页面不会报错,但每次切换会等满兜底超时才完成——要么统一注入,要么给该路由配transition: false - 命中
prefers-reduced-motion: reduce(默认遵循)或未配置transition时,一切退化为立即切换 - 未开启过渡(含路由级
false)时useRouteTransition()返回null、useRouteTransitionBindings()返回无害的 no-op 绑定,同一组件无需条件分支 - 方向推导会在每个历史条目的
history.state上写入位点字段(键名见导出的NAV_POSITION_KEY),自行读写history.state时请展开合并保留该字段
| 方法 | 说明 |
|---|---|
router.navigate(path) |
推入历史,触发守卫/中间件 |
router.replace(path) |
替换当前 |
router.back() |
history.back() |
router.getLocation() |
当前 LocationLike |
router.navigationDirection |
最近一次导航的方向:forward / back / replace |
router.beforeEach/beforeResolve/afterEach(handler) |
守卫注册 |
router.clearCache() |
清空所有 keep-alive 页面缓存 |
router.deleteCache(matcher) |
删除匹配指定 key 的 keep-alive 页面缓存,支持 string / RegExp / function |
router.subscribe(listener) |
监听 location |
router.dispose() |
清理 |
| 名称 | 说明 |
|---|---|
<RouterProvider router> |
入口 |
<Outlet /> |
嵌套出口 |
<Link /> / <NavLink /> |
导航;NavLink 带激活态(activeClassName / inactiveClassName),end 切换精确 / 前缀匹配(默认前缀,父路径在子路由下仍激活) |
useRouter() |
实例(引用终身恒定,不随导航重渲染;读 .location / .navigationDirection 值新鲜但不触发刷新,需要响应式请用 useLocation()) |
useNavigate() |
navigate 函数(引用恒定,可安全放入 deps / memo 组件 props) |
useLocation() |
全局当前 pathname、search、hash,默认跟随真实路由切换 |
useLocation({ scope: 'cache' }) |
当前 keep-alive 缓存 entry 的 pathname、search、hash |
useParams() |
{ params, query, hash } |
useRouteKeepAliveEffect(effect) |
keep-alive 缓存页的可见性感知 useEffect:激活时跑 effect,失活(被缓存隐藏)/ 卸载时跑其 cleanup |
useRouteTransition() |
当前路由过渡状态 { phase, direction, finishEnter, finishExit };未开启过渡时为 null |
useRouteTransitionBindings() |
开箱即用封装:{ isEntering, isExiting, direction, bind },{...bind} 展开到动画元素即完成接线 |
开启 cache 后,离开缓存页只是把它隐藏(组件不卸载),普通 useEffect 的 cleanup 此时不会触发 —— 凡是「仅当前页可见时才该生效」的副作用 / 信号(如上报“当前在某页”、暂停视频、释放摄像头),用普通 useEffect 都会在切走后残留。useRouteKeepAliveEffect 专门解决这点:
useRouteKeepAliveEffect(() => {
reportActiveSurface(true)
return () => reportActiveSurface(false) // 隐藏 / 卸载都会复位
})- 页面激活(首次可见,或被缓存隐藏后切回)时执行
effect;失活(隐藏)或卸载时执行其返回的 cleanup - 自动解析所属缓存单元,无需手动传 key;内部用 ref 始终调用最新闭包,故不需要依赖数组
- 覆盖「晚于初次激活才挂载的后代」(如异步 /
SplitPane内晚挂载的子组件):挂载即补激活 - 未被 keep-alive 包裹时退化为普通
useEffect(挂载执行、卸载清理)
// 守卫签名
type NavigationGuard = (to, from, next) => void | Promise<void>
// next() | next(false) | next('/redirect')
// 中间件签名
type Middleware = (ctx, next) => void | Promise<void>
// ctx.redirect('/path') 可跳转src/router/ 为库源码,其余为示例:
src/router/ # 库源码
src/routes/ # 路由配置示例
src/views/ # 页面示例
src/App.tsx # Demo 入口