我把 Vue Router 搬到了 React —— 从 API 到文件路由、Keep-Alive、转场动画,一个都不少

117 阅读12分钟

如果你同时写 Vue 和 React,一定懂那种感觉:切回 React 项目,想用 useRoute() 拿参数,却发现根本没有这个 hook。


起因

我平时 Vue 和 React 都写。Vue Router 的体验一直让我很满意——useRouteuseRouter、导航守卫、嵌套路由、文件路由……每一块都设计得恰到好处。

切回 React 项目,用 React Router 时总觉得哪里别扭:

  • 路由信息碎片化useParamsuseSearchParams 是两个 hook,拿不到一个统一的 route 对象;没有 meta 字段,鉴权信息无处挂载
  • 守卫缺失:没有全局导航守卫,鉴权得自己包一层高阶组件或 loader
  • 文件路由绑定框架:文件系统路由要靠 Next.js / Remix,单独用 Vite 就得手写
  • 转场动画没有官方方案:路由切换动画得引入 Framer Motion 等第三方库
  • 没有 Keep-Alive:列表页跳详情页再返回,滚动位置和状态全部丢失

于是我决定自己搓一个:把 Vue Router 的 API 完整搬到 React,同时把文件系统路由、Keep-Alive、转场动画这些好东西一并带过来。

这就是 @tangmu1121/rvue-router——一个为 React 打造的 Vue Router 风格路由库。


30 秒速览:它长什么样

三步让你的 React 项目拥有 Vue Router 的开发体验:

npm install @tangmu1121/rvue-router

第一步:创建路由

// src/router/index.ts
import { createRouter, createWebHistory } from '@tangmu1121/rvue-router'
import { lazy } from 'react'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',      redirect: '/home' },
    { path: '/home',  name: 'home',  component: lazy(() => import('@/pages/Home')),  meta: { title: '首页' } },
    { path: '/about', name: 'about', component: lazy(() => import('@/pages/About')), meta: { title: '关于' } },
    { path: '/users/:id', name: 'user-detail', component: lazy(() => import('@/pages/User')) },
    { path: '*', component: lazy(() => import('@/pages/NotFound')) },
  ],
  scrollBehavior(_to, _from, savedPosition) {
    return savedPosition ?? { top: 0 }
  },
})

// 全局鉴权守卫——一行搞定
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !localStorage.getItem('token')) {
    next('/login')
  } else {
    next()
  }
})

// 全局后置守卫——更新页面标题
router.afterEach((to) => {
  document.title = String(to.meta.title ?? 'App')
})

第二步:注入 Provider

// src/main.tsx
const { RouterProvider } = router

createRoot(document.getElementById('root')!).render(
  <RouterProvider>
    <App />
  </RouterProvider>
)

第三步:渲染路由出口

// src/App.tsx
import { RouterView, RouterLink } from '@tangmu1121/rvue-router'

export default function App() {
  return (
    <div>
      <nav>
        <RouterLink to="/home" activeClass="active">首页</RouterLink>
        <RouterLink to="/about" activeClass="active">关于</RouterLink>
      </nav>
      <RouterView transition="fade" />  {/* 自带淡入淡出转场 */}
    </div>
  )
}

就这些。如果你写过 Vue Router,基本不用看文档就能上手


核心功能一览

1. useRoute —— 一个 hook 拿到所有路由信息

function UserDetail() {
  const route = useRoute()

  route.params.id      // 动态参数
  route.query.page     // 查询参数
  route.hash           // URL hash
  route.meta.title     // 路由元信息
  route.fullPath       // 完整路径
  route.matched        // 从根到当前的匹配链
}

对比 React Router: 需要 useParams() + useSearchParams() + useLocation() + 自己实现 meta,四个 hook 才能凑齐这些信息。

2. 导航守卫 —— 完整的四阶段执行链

每次路由切换,守卫按以下顺序依次执行:

① 组件 useBeforeRouteLeave  →  离开旧页面前拦截
② 全局 router.beforeEach    →  统一鉴权 / 权限校验
③ 组件 useBeforeRouteUpdate →  参数变化但组件复用时
④ 路由级 beforeEnter        →  进入特定路由前拦截

在组件里用 hook 直接注册,不需要包装高阶组件

function EditForm() {
  const [isDirty, setIsDirty] = useState(false)

  // 离开前确认——表单未保存时阻止跳转
  useBeforeRouteLeave((to, from, next) => {
    if (isDirty && !confirm('有未保存的更改,确认离开?')) {
      next(false)  // 阻止导航
    } else {
      next()       // 放行
    }
  })

  // 路由参数变化时重新加载(/users/1 → /users/2,组件复用不卸载)
  useBeforeRouteUpdate((to, from, next) => {
    fetchUserData(to.params.id)
    next()
  })
}

每个守卫都返回取消函数,动态注册/注销不会内存泄漏:

const removeGuard = router.beforeEach((to, from, next) => { /* ... */ })
removeGuard() // 不再需要时移除

3. RouterLink —— 声明式导航 + 智能激活状态

// 前缀匹配加 active 类,精确匹配加 active-exact 类
<RouterLink to="/users" activeClass="active" exactActiveClass="active-exact">
  用户管理
</RouterLink>

// 对象形式导航(支持 query、hash)
<RouterLink to={{ path: '/users', query: { page: '2' }, hash: '#list' }}>
  用户列表第 2 页
</RouterLink>

// 禁用状态(渲染为 <a> 但阻止跳转,自动追加 aria-disabled)
<RouterLink to="/admin" disabled>管理员(无权限)</RouterLink>

内置行为:

  • 精确匹配时自动添加 aria-current="page",满足无障碍标准
  • Ctrl/Meta/Shift 点击时走浏览器默认行为(新标签页打开)
  • 支持所有原生 <a> 属性

需要完全自定义导航组件?用 useLink hook:

function MyNavItem({ to, children }) {
  const { href, isActive, isExactActive, navigate } = useLink({ to })

  return (
    <a href={href} onClick={navigate} className={isActive ? 'active' : ''}>
      {children}
    </a>
  )
}

重头戏一:文件系统路由

这是我最花时间的部分。只需要一个 Vite 插件,创建文件就等于注册路由,告别手写 routes 数组。

接入

// vite.config.ts
import { fileRouter } from '@tangmu1121/rvue-router/vite'

export default defineConfig({
  plugins: [react(), fileRouter({ dir: 'src/pages' })],
})
// src/router/index.ts
import routes from 'virtual:rvue-routes'  // 自动生成!
import { createRouter, createWebHistory } from '@tangmu1121/rvue-router'

export const router = createRouter({ history: createWebHistory(), routes })

文件命名约定

src/pages/
  index.tsx           →  /           name: 'index'
  about.tsx           →  /about      name: 'about'
  users/
    index.tsx         →  /users      name: 'users'
    [id].tsx          →  /users/:id  name: 'users-id'
    [id]/
      posts.tsx       →  /users/:id/posts  name: 'users-id-posts'
  404.tsx             →  *           name: '404'
  [...all].tsx        →  *           name: 'all'(通配符的另一种写法)
文件/目录规则
index.tsx当前层级的索引页
[id].tsx动态参数路由 :id
[...all].tsx / 404.tsx通配符路由 *
_layout.tsx布局组件,将目录内路由变为嵌套子路由
[page].route.ts同级路由配置文件(meta、守卫、name 覆盖)
_*.tsx下划线前缀文件被忽略

嵌套布局

加上 _layout.tsx 就能做嵌套路由:

src/pages/
  _layout.tsx         ← 根布局(导航栏 + 页脚)
  index.tsx
  about.tsx
  users/
    _layout.tsx       ← /users 布局(侧边栏)
    index.tsx
    [id].tsx

生成结果:

[{
  path: '/',
  component: lazy(() => import('./pages/_layout.tsx')),
  children: [
    { path: '', name: 'index', component: lazy(() => import('./pages/index.tsx')) },
    { path: 'about', name: 'about', component: lazy(() => import('./pages/about.tsx')) },
    {
      path: 'users',
      name: 'users',
      component: lazy(() => import('./pages/users/_layout.tsx')),
      children: [
        { path: '', name: 'users', component: lazy(...) },
        { path: ':id', name: 'users-id', component: lazy(...) },
      ],
    },
  ],
}]

同级路由配置文件(*.route.ts

想给某个页面加 meta 或路由守卫,但不想污染组件文件?创建一个同名的 .route.ts

// src/pages/dashboard.route.ts
import { defineRouteConfig } from '@tangmu1121/rvue-router'

export default defineRouteConfig({
  name: 'dashboard',          // 覆盖自动生成的 name
  meta: {
    requiresAuth: true,
    title: '控制台',
    roles: ['admin'],
  },
  beforeEnter: (to, from, next) => {
    if (!hasPermission(to.meta.roles)) next('/403')
    else next()
  },
})

插件会自动将这个文件的导出 spread 到路由对象上。页面逻辑和路由配置完全分离,整洁。

defineRouteConfig 是一个纯透传函数,运行时零开销,仅提供 TypeScript 类型推断。

HMR 支持

开发时新增/删除页面文件、修改 *.route.ts 都会自动触发路由更新,不需要手动重启


重头戏二:路由缓存(Keep-Alive)

这是 Vue 开发者切到 React 后最怀念的功能之一:列表页跳到详情页再返回,滚动位置和表单状态全部保留

基础用法

// 全部路由都缓存
<RouterView keepAlive />

// 精确控制缓存范围
<RouterView
  keepAlive={{
    include: ['user-list', 'settings'],  // 只缓存这些路由
    exclude: /^admin/,                   // 排除管理后台优先级高于 includemax: 10,                             // 最多缓存 10 个实例超出按 LRU 淘汰
  }}
/>

缓存中的组件还"活着"

缓存中的组件不会被卸载,只是通过 display: none 隐藏。这意味着 useStateuseRef、定时器等状态都会保留。

但有时候你希望在页面"退到后台"时暂停某些操作(轮询、视频播放等),这就需要区分"激活"和"未激活"状态。useKeepAliveActive 就是为此而生:

import { useKeepAliveActive } from '@tangmu1121/rvue-router'

function DataDashboard() {
  const isActive = useKeepAliveActive()

  // 只在页面展示时轮询数据
  useEffect(() => {
    if (!isActive) return              // 页面被缓存时跳过
    const id = setInterval(fetchData, 5000)
    return () => clearInterval(id)
  }, [isActive])

  return <div>...</div>
}
  • true:当前页面正在展示,或未使用 Keep-Alive
  • false:当前页面在缓存中但被隐藏(等同于 Vue 的 deactivated

这跟 Vue 的 activated / deactivated 生命周期完全对应。


重头戏三:路由转场动画

这块我参照 Vue 的 <Transition> 设计,做到了零额外依赖

一行开启

<RouterView transition="fade" />
/* 全局 CSS 中定义 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }

六个生命周期类

动画模式是 out-in:旧组件先完成离开动画,新组件再进入,不会出现两个组件叠加。

离开阶段:

时机添加移除
离开开始name-leave-fromname-leave-active
下一帧name-leave-toname-leave-from
离开结束name-leave-activename-leave-to

进入阶段(旧组件离开完成后):

时机添加移除
进入开始name-enter-fromname-enter-active
下一帧name-enter-toname-enter-from
进入结束name-enter-activename-enter-to

跟 Vue 的 <Transition> 类名约定完全一致,如果你之前写过 Vue 的过渡动画 CSS,直接复制过来就能用。

几个常用的动画效果

/* 水平滑动 */
.slide-enter-active, .slide-leave-active { transition: all 0.35s ease; }
.slide-enter-from { opacity: 0; transform: translateX(30px); }
.slide-leave-to { opacity: 0; transform: translateX(-30px); }

/* 缩放 */
.zoom-enter-active, .zoom-leave-active { transition: all 0.25s ease; }
.zoom-enter-from, .zoom-leave-to { opacity: 0; transform: scale(0.95); }

用 Tailwind CSS?也完美支持

通过自定义类名,直接使用 Tailwind 的原子类,不需要写任何额外 CSS:

<RouterView
  transition={{
    enterFromClass:   'opacity-0 translate-x-4',
    enterActiveClass: 'transition-all duration-300 ease-out',
    enterToClass:     'opacity-100 translate-x-0',
    leaveFromClass:   'opacity-100 translate-x-0',
    leaveActiveClass: 'transition-all duration-200 ease-in',
    leaveToClass:     'opacity-0 -translate-x-4',
  }}
/>

CSS Modules 同理,传入 styles.enterFrom 等即可。

不同路由用不同动画

通过路由 meta 动态切换:

function App() {
  const route = useRoute()
  return <RouterView transition={route.meta.transition ?? 'fade'} />
}

// 路由配置
{ path: '/home',      meta: { transition: 'fade'  } },
{ path: '/dashboard', meta: { transition: 'slide' } },
{ path: '/settings',  meta: { transition: 'zoom'  } },

其他细节

  • 自动检测时长:未指定 duration 时,自动从 getComputedStyle 读取 transition-duration / animation-duration,无需手动配置
  • 快速导航安全:动画进行中触发新的路由跳转,会缓存起来等当前动画结束后立即执行,不会出错
  • 首次渲染动画:设置 appear: true 即可在页面首次加载时也执行进入动画
  • React.lazy() 兼容:进入动画会在 Suspense 完成后再执行

其他实用功能

命名视图

一个路由同时渲染多个并列组件:

// 路由配置
{
  path: '/dashboard',
  components: {
    default: MainContent,   // <RouterView />
    sidebar: SidebarPanel,  // <RouterView name="sidebar" />
    header: PageHeader,     // <RouterView name="header" />
  },
}

// 布局组件中
function DashboardLayout() {
  return (
    <div className="layout">
      <header><RouterView name="header" /></header>
      <aside><RouterView name="sidebar" /></aside>
      <main><RouterView /></main>
    </div>
  )
}

动态路由

登录后按权限动态增删路由:

// 登录后动态添加
router.addRoute({ path: '/admin', name: 'admin', component: AdminPage })
router.addRoute({ path: 'logs', component: Logs }, 'admin') // 添加到 admin 的子路由

// 退出时清理
router.removeRoute('admin')

// 检查是否存在
router.hasRoute('admin') // boolean

useIsNavigating —— 全局加载指示器

守卫执行期间(异步鉴权、数据预加载等),可以展示全局 loading:

function GlobalProgressBar() {
  const isNavigating = useIsNavigating()
  return isNavigating ? <ProgressBar /> : null
}

滚动行为

与 Vue Router 一致的 scrollBehavior 配置:

createRouter({
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition     // 前进/后退恢复位置
    if (to.hash) return { el: to.hash, behavior: 'smooth' }  // 锚点滚动
    return { top: 0 }                           // 默认回到顶部
  },
})

三种历史模式

createWebHistory()    // /path        HTML5 History API,需服务器配置
createHashHistory()   // /#/path      无需服务器配置,兼容性好
createMemoryHistory() // 内存模式      SSR / 单元测试

// 也支持字符串简写
createRouter({ history: 'browser', routes })
createRouter({ history: 'hash',    routes })
createRouter({ history: 'memory',  routes })

router.isReady()

等待初始导航完成:

await router.isReady()
console.log('路由已就绪,可以执行后续逻辑')

技术实现简记

几个有意思的实现细节,也是我在开发过程中花了不少时间打磨的地方:

响应式路由

基于 React 18 的 useSyncExternalStore。路由状态存放在 React 外部(闭包变量),所有订阅者在路由变化时同步更新,不会出现状态撕裂(tearing)。这也是 React 官方推荐的外部 store 接入方式。

Keep-Alive 的缓存策略

缓存用模块级 Map 按 RouterView 的渲染深度(depth)维度存储,而不是 useRef。这样即便在 React 18 的 Strict Mode 下,开发环境那次"先卸载再重挂载"也不会清空缓存。对同一个缓存 key,只在路由变化时更新 route 对象,复用原来的组件引用,避免懒加载组件被重复创建。

转场动画时序

用双帧 requestAnimationFrame(代码里叫 nextFrame)确保浏览器在类名变化之间完成一次 paint,这样 CSS transition 才能正确触发——单帧 rAF 在某些浏览器上不够稳定。过渡结束时间从 getComputedStyle 读取 transition-duration + transition-delayanimation-duration + animation-delay 取最大值,开发者不需要在 JS 和 CSS 两边同步维护时长。

文件路由的匹配排序

路由按「静态 > 动态(:param) > 通配符(*)」排序,避免 :id 拦截掉 about 这类静态路径。无 _layout 的子目录路由会"提升"到父层并拼接路径前缀,保持结构扁平。

守卫执行流水线

完整复刻 Vue Router 的守卫顺序:组件 leave → 全局 beforeEach → 组件 update → 路由级 beforeEnter。每个守卫通过 Promise 串行执行,支持 next(false) 取消、next('/path') 重定向。如果守卫抛出异常,会被 router.onError 捕获,不会中断应用。


与 React Router 的对比

功能rvue-routerReact Router
统一路由对象useRoute() 一个 hook 全包useParams() + useSearchParams() + useLocation()
路由元信息内置 meta 字段无原生支持
全局导航守卫router.beforeEach / afterEach需自己实现
组件级守卫useBeforeRouteLeave / useBeforeRouteUpdate无原生支持
文件系统路由内置 Vite 插件需要框架(Remix / Next.js)
路由转场动画内置,零依赖,Vue Transition 类名约定需要 Framer Motion 等第三方库
Keep-Alive 缓存内置,支持 include / exclude / max / LRU无原生支持
动态路由addRoute / removeRoute有,但 API 不同
命名视图components + <RouterView name>无原生支持
滚动行为内置 scrollBehavior需自己实现
TypeScript完整类型 + 类型增强完整类型
零运行时依赖仅 React 作为 peer dep

并非说 React Router 不好——它在自己的设计理念下做得很出色。但如果你更喜欢 Vue Router 的 API 风格,或者你的团队同时维护 Vue 和 React 项目希望统一心智模型,rvue-router 会是一个值得尝试的选择。


安装

npm install @tangmu1121/rvue-router
# or
pnpm add @tangmu1121/rvue-router
# or
yarn add @tangmu1121/rvue-router

npm 地址:@tangmu1121/rvue-router


最后

这个库目前已发布 v0.3.4,核心功能都已稳定:

  • Vue Router 风格的完整 APIcreateRouteruseRouteuseRouterbeforeEach / afterEach、命名路由、路由别名……
  • 文件系统路由:Vite 插件一键接入,自动路由名称 + .route.ts 配置文件 + HMR 热更新
  • 路由缓存(Keep-Alive):include / exclude / max / LRU 淘汰 + useKeepAliveActive 激活状态检测
  • 路由转场动画:Vue <Transition> 六阶段类名约定,支持 Tailwind / CSS Modules / 自定义类名
  • 完整 TypeScript 类型:类型导出、模块增强、defineRouteConfig 类型推断
  • 零运行时依赖:只有 React 作为 peer dep,打包体积友好

如果你也是个 Vue 转 React(或者两个都写)的开发者,欢迎试试看。

有问题或建议欢迎在 npm 页面留言或提 issue,我会持续迭代。