约定式路由的极简主义实践:一个插件搞定 React/Vue × Vite/Rspack

约定式路由,再出发

"如果代码是一场修行,那么好的架构就是通往顿悟的第一步。" ------ 某只熬夜重构代码的程序员


前情提要

故事要从一个风和日丽的下午说起。

我盯着屏幕上那个 3000 多行的 context.ts 文件,感觉自己的灵魂也在随着代码一起发霉。明明只是一个约定式路由插件,为什么会复杂到这种程度?

8000+ 行冗余代码。这五个字是我那天最不想看到的东西。

没错,27 天前,unplugin-convention-routes 还不是一个插件,更像是一个代码灾难现场------职责混乱、重复满天飞、测试?那是什么?能吃吗?

但现在,我终于可以挺起胸膛说:这一切,都结束了。


灾难现场回顾

让我们穿越回重构之前,看看那个"黑暗时代"的目录结构:

bash 复制代码
src/
├── core/
│   ├── constants.ts     
│   ├── context.ts       # 上下文管理(175行,塞了配置+扫描+生成)
│   ├── customBlock.ts   # 自定义块处理(105行,处理个啥?)
│   ├── files.ts         
│   ├── initVirualPackage.ts  # 虚拟包初始化(名字还拼错了 Virual)
│   ├── options.ts       # 配置解析
│   ├── path.ts          # 路径处理
│   ├── stringify.ts     # 代码生成(90行)
│   ├── types.ts         # 类型定义
│   └── utils.ts         # 工具函数
├── resovlers/           # resolvers 居然还拼错了!
│   ├── index.ts
│   ├── react.ts
│   ├── solid.ts         
│   └── vue.ts
├── esbuild.ts           # 等等,esbuild 用得上吗?
├── farm.ts              # Farm?Rspack 之前叫 Farm?
├── rollup.ts            # Rollup?这是给库用的吧?
├── rspack.ts
├── vite.ts
├── webpack.ts           
└── ...其他入口文件

看完之后,我的感受是这样的:∑(っ°Д°;)っ

问题?那可太多了:

  1. 职责边界混乱 - context.ts 简直是代码界的"万金油",什么都往里塞
  2. 重复代码遍地 - React 和 Vue 的解析器像双胞胎,但散落在宇宙的不同角落
  3. 构建工具耦合 - 一堆用不着的入口文件,不知道是给谁准备的遗产
  4. 无法单元测试 - 高度耦合的代码,测试?那等于重新写一遍

作为一个有追求的程序员,我陷入了深深的沉思...

是继续凑合,还是大刀阔斧?


重构,新生

我选择了后者。毕竟,代码写得好不好,直接影响我喝咖啡的心情。

破茧:新的架构

重构后的目录结构,简单到让我自己都感动:

bash 复制代码
src/
├── core/                 # 核心共享层(只放真正共享的东西)
│   ├── options.ts       # 配置解析(干净利落)
│   ├── path.ts           # 路径处理(只干这一件事)
│   └── types.ts          # 类型定义(一目了然)
├── resolvers/            # 路由生成器层(这里是重点)
│   ├── index.ts          # 统一出口,像个正经的网关
│   ├── react.ts          # React 的归 React
│   ├── vue.ts            # Vue 的归 Vue
│   └── utils.ts          # 公共工具,不多不少
├── index.ts              # 插件工厂(核心中的核心)
├── vite.ts               # Vite 用户要用
└── rspack.ts             # Rspack 用户要用

21 个文件 精简到 9 个文件 。这不叫重构,这叫断舍离

立新:三层模型

新的架构遵循一个朴素的哲学:术业有专攻

层级 职责 比喻
插件工厂层 统一入口,拦截虚拟模块 前台接待,根据访客类型分流
核心共享层 配置解析、路径处理、类型定义 技术部门,只负责底层能力
路由生成层 根据框架生成路由代码 生产车间,流水线作业

每一层都各司其职,互不干涉,却又紧密协作。

就像一个完美的餐厅:前台接单,后厨做菜,服务员上菜。没有人会跑到后厨去炒菜,对吧?


核心代码解析

说了这么多,还是要看代码说话。

插件工厂:大道至简

typescript 复制代码
// src/index.ts
export const unpluginFactory: UnpluginFactory<UserOptions> = (userOptions, { framework }) => {
  const options = resolveOptions(userOptions)
  const buildTool: BuildTool = framework === "vite" ? "vite" : "rspack"
  const virtualIds = VIRTUAL_MODULE_IDS[options.resolver]

  return {
    name: "unplugin-convention-routes",

    // 拦截虚拟模块请求
    resolveId(id) {
      if (virtualIds.includes(id)) {
        return virtualIds[0]
      }
      return null
    },

    // 生成路由代码
    load(id) {
      if (id === virtualIds[0]) {
        return generateRoutes(options, buildTool)
      }
      return null
    },
  }
}

整个插件的核心就这么区区 40 行

  • resolveId 像一个门卫,看见虚拟模块就放行
  • load 像一个工厂,根据你要的框架,吐出对应的路由代码

没有奇技淫巧,只有返璞归真。

配置解析:五湖四海皆兄弟

最让我头疼的 Windows 路径问题:

typescript 复制代码
// src/core/options.ts
export function resolveOptions(userOptions: UserOptions): ResolvedOptions {
  // ...

  // Windows 用户注意了:所有路径统一使用正斜杠
  const root = slash(process.cwd())

  const resolvedDirs: PageDir[] = toArray(dirs).map((dir) => {
    if (typeof dir === "string") {
      return { dir: slash(dir), baseRoute: "" }
    }
    return {
      ...dir,
      dir: slash(dir.dir),
    }
  })

  return { root, resolver, dirs: resolvedDirs, ... }
}

slash() 函数是我对抗 Windows 反斜杠的武器。C:\Users\xxx\srcC:/Users/xxx/src。世界从此和平。

路径处理:火眼金睛

路由段识别是整个插件最有趣的环节:

typescript 复制代码
// src/core/path.ts

// 动态路由正则 - Next.js 风格 [id]
const DYNAMIC_ROUTE_RE = /^\[(.+)\]$/
// 动态路由正则 - Remix 风格 $id
const REMIX_DYNAMIC_ROUTE_RE = /^\$(.+)$/
// 捕获所有路由正则 - Next.js 风格 [...all]
const CATCH_ALL_ROUTE_RE = /^\[\.{3}(.*)\]$/
// 捕获所有路由正则 - Remix 风格 $
const REMIX_CATCH_ALL_ROUTE_RE = /^\$$/

export function isDynamicRoute(segment: string): boolean {
  return DYNAMIC_ROUTE_RE.test(segment) || REMIX_DYNAMIC_ROUTE_RE.test(segment)
}

export function isCatchAllRoute(segment: string): boolean {
  return CATCH_ALL_ROUTE_RE.test(segment) || REMIX_CATCH_ALL_ROUTE_RE.test(segment)
}

一个字符串进来,正则一匹配,立马知道它是:

  • 普通路由 → 直接使用
  • 动态路由 [id]$id → 转换成 :id
  • 捕获所有 [...all]$ → 转换成 :all/*

就像孙悟空的七十二变,一个咒语就现原形。

路由生成器:一厂两制

typescript 复制代码
// src/resolvers/index.ts
const generators: Record<Resolver, (options: ResolvedOptions, buildTool: BuildTool) => string> = {
  vue: generateVueRoutes,
  react: generateReactRoutes,
}

export function generateRoutes(options: ResolvedOptions, buildTool: BuildTool): string {
  return generators[options.resolver](options, buildTool)
}

策略模式的应用。你要 Vue?我给你 Vue 的流水线。你要 React?我给你 React 的流水线。

简单、清晰、可扩展。如果哪天要加 Svelte,加就是了。


技术亮点

亮点一:零运行时依赖

插件利用构建工具内置的 glob 扫描能力,不需要任何运行时依赖:

javascript 复制代码
// Vite 使用 import.meta.glob(相对路径)
const __pages__ = import.meta.glob("/src/pages/**/*.vue")
Object.entries(__pages__).forEach(([path, module]) => {
  routes.push({ path, name, component: module }) // 直接用 ES Module
})
javascript 复制代码
// Rspack 使用 import.meta.webpackContext(绝对路径)
const __pages__ = import.meta.webpackContext("/path/to/project/src/pages", {
  recursive: true,
  regExp: /\.(vue|ts|js)$/
})
__pages__.keys().forEach((key) => {
  routes.push({ path, name, component: () => __pages__(key) })
})

纯天然,无添加。 你不需要为这个插件多装任何一个 npm 包。

亮点二:双框架通吃

同一套架构,Vue 和 React 都能用:

typescript 复制代码
// Vue 的 routes(Vite 版本)
routes.push({
  path: routePath,
  name,
  component: module // 直接用,躺平
})

// Vue 的 routes(Rspack 版本)
routes.push({
  path: routePath,
  name,
  component: () => context(key) // 需要返回一个 Promise 函数
})

// React 的 routes
routes.push({
  path: routePath,
  element: React.createElement(React.lazy(loadModule))
  // React.lazy 接收一个返回 Promise 的函数
})

Vue 和 React 的路由格式略有不同,但插件帮你屏蔽了这些细节,一行配置搞定

亮点三:排除机制

有时候我们不希望某些文件被扫到路由里:

typescript 复制代码
exclude = [
  "node_modules", // 肯定不要
  ".git", // 绝对不要
  "**/__*__/**", // __xxx__ 目录不要(Remix 风格)
  "**/components/**", // 组件目录不要
]

底层用正则表达式优化,glob 模式会自动转换成正则:

javascript 复制代码
**/components/**  →  .*/components/.*

性能优化:抠门大赛

作为一个有追求的程序员,我对性能有着近乎偏执的追求。

1. 正则预编译

typescript 复制代码
// 模块顶层预编译,只创建一次
const ESCAPE_REGEXP = /[.*+?^${}()|[\]\\/:]/g
const GLOB_DOUBLE_STAR_RE = /\*\*/g
const GLOB_SINGLE_STAR_RE = /\*/g

以前在循环里 new RegExp(),每次迭代都创建新实例。现在在模块顶层创建一次,整个生命周期复用

2. 排除检查外提

typescript 复制代码
// 循环外创建正则数组
const excludePatternsCode = createExcludePatterns(options.exclude)

// 循环内直接用,不重复创建
if (excludePatterns.some(pattern => pattern.test(path)))
  return

想象一下,如果有 1000 个文件,以前每遍历一个文件就创建 5 个正则。现在只创建 5 个正则,用 1000 次。

3. 链式调用

typescript 复制代码
// 链式调用,减少中间变量创建
let routePath = path
  .replace(/${escapedDir}/, '')
  .replace(/^\\//, '')
  .replace(/\\.(vue|ts|js)$/, '')
  .replace(/\\/index$/, '')
// ...

每一步都在原字符串上操作,不产生临时变量。省内存,省心情。


使用方式

安装

bash 复制代码
pnpm i unplugin-convention-routes

就是这么快。

Vite + Vue

typescript 复制代码
// vite.config.ts
import Pages from "unplugin-convention-routes/vite"

export default defineConfig({
  plugins: [
    Pages({ resolver: "vue" }),
  ],
})

Vite + React

typescript 复制代码
// vite.config.ts
import Pages from "unplugin-convention-routes/vite"

export default defineConfig({
  plugins: [
    Pages({ resolver: "react" }),
  ],
})

Rspack + Vue

typescript 复制代码
// rsbuild.config.ts
import Pages from "unplugin-convention-routes/rspack"

export default defineConfig({
  tools: {
    rspack: {
      plugins: [
        Pages({ resolver: "vue" }),
      ],
    },
  },
})

Rspack + React

typescript 复制代码
// rsbuild.config.ts
import Pages from "unplugin-convention-routes/rspack"

export default defineConfig({
  tools: {
    rspack: {
      plugins: [
        Pages({ resolver: "react" }),
      ],
    },
  },
})

四行代码,开启约定式路由之旅。


文件约定

这是插件的灵魂规则,也是 Remix 精神的体现:

文件名 路由路径 说明
index.vue / 首页
about.vue /about 关于页
about/index.vue /about 和上面一样
blog/[id].vue /blog/:id Next.js 风格动态路由
blog/[...all].vue /blog/:all(.*)* Next.js 风格捕获所有
$id.vue /:id Remix 风格动态路由 ✨
$.vue /* Remix 风格捕获所有 ✨
__xxx.vue 忽略 Remix 风格忽略文件/目录

带 ✨ 的是 Remix 独有的浪漫,用 $ 代替 [...,更简洁,更优雅。


写在最后

这次重构教会了我一件事:代码的复杂度不是能力的体现,简洁才是。

8000+ 行代码可以在 Git 提交记录里沉睡,因为新的架构只需要 500 行 就能完成同样的事情。

没有 context.ts 的臃肿,没有散落各处的工具函数,没有理不清的职责边界。

只有:

  • 清晰的三层架构
  • 对 Remix 约定式路由的致敬
  • 对代码简洁之美的追求

如果你厌倦了手动配置路由,如果你想体验"文件即路由"的快感,不妨试试 unplugin-convention-routes

也许,它就是你一直在寻找的那个人。


相关链接:


"代码是最好的产品文档。当你的代码会说话,注释就成了配角。" ------ 鲁迅(不是我说的)

(好吧,是我说的,但确实是这样喵~)

相关推荐
我就是马云飞2 小时前
停更5年后,我为什么重新开始写技术内容了
android·前端·程序员
品克缤2 小时前
Vue3 + Router 页面切换时滚动条闪烁问题记录
前端·javascript·css·vue.js
前端老石人2 小时前
文本级语义与变更标记
前端·html
冰暮流星2 小时前
javascript之dom方法访问内容
开发语言·前端·javascript
有意义2 小时前
滴滴一面复盘:从CSS布局到TS核心思想
前端·面试
竹林8182 小时前
React + wagmi 实战:从零构建一个能“读”能“写”的 DeFi 前端,我踩了这些坑
前端·javascript
我命由我123452 小时前
在 React 项目中,配置了 setupProxy.js 文件,无法正常访问 http://localhost:3000
开发语言·前端·javascript·react.js·前端框架·ecmascript·js
俺不会敲代码啊啊啊2 小时前
封装 ECharts Hook 适配多种图表容器
前端·vue.js·typescript·echarts
J2虾虾2 小时前
在Vue3中推荐使用的函数定义方法
前端·javascript·vue.js