约定式路由,再出发
"如果代码是一场修行,那么好的架构就是通往顿悟的第一步。" ------ 某只熬夜重构代码的程序员
前情提要
故事要从一个风和日丽的下午说起。
我盯着屏幕上那个 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
└── ...其他入口文件
看完之后,我的感受是这样的:∑(っ°Д°;)っ
问题?那可太多了:
- 职责边界混乱 -
context.ts简直是代码界的"万金油",什么都往里塞 - 重复代码遍地 - React 和 Vue 的解析器像双胞胎,但散落在宇宙的不同角落
- 构建工具耦合 - 一堆用不着的入口文件,不知道是给谁准备的遗产
- 无法单元测试 - 高度耦合的代码,测试?那等于重新写一遍
作为一个有追求的程序员,我陷入了深深的沉思...
是继续凑合,还是大刀阔斧?
重构,新生
我选择了后者。毕竟,代码写得好不好,直接影响我喝咖啡的心情。
破茧:新的架构
重构后的目录结构,简单到让我自己都感动:
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\src → C:/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。
也许,它就是你一直在寻找的那个人。
相关链接:
"代码是最好的产品文档。当你的代码会说话,注释就成了配角。" ------ 鲁迅(不是我说的)
(好吧,是我说的,但确实是这样喵~)