基于约定优于配置的思想,用 Vite 插件机制 + fast-glob 实现零配置自动路由注册。
背景与动机
在 Vue3 项目里,手动维护路由表是一件重复且容易出错的事:
js
// 每新增一个页面都要改这里 😩
const routes = [
{ path: '/', component: () => import('./pages/index.vue') },
{ path: '/about', component: () => import('./pages/about.vue') },
{ path: '/user/:id', component: () => import('./pages/user/[id].vue') },
// ...
]
页面一多,这个文件就变成了负担。能不能让工具自动做这件事?
答案是:可以,用 Vite 插件。
方案选型
在动手之前,我考虑了三种方案:
方案 A:import.meta.glob(纯运行时)
Vite 内置支持 glob 导入:
js
const pages = import.meta.glob('./pages/**/*.vue')
问题在于它是运行时行为,需要在业务代码里手动处理路径到路由的映射,侵入业务层,不够优雅。
方案 B:自定义 Vite 插件 + 虚拟模块 ✅
插件在构建阶段扫描文件系统,生成一个虚拟模块 virtual:auto-routes,业务代码只需:
js
import { routes } from 'virtual:auto-routes'
完全透明,零侵入,支持热更新。这是主流方案(vite-plugin-pages、unplugin-vue-router 都是这个思路)。
方案 C:直接用现成库
vite-plugin-pages 开箱即用,但失去了定制空间,也失去了理解底层机制的机会。
最终选方案 B,自己实现,完全可控。
核心机制:Vite 虚拟模块
Vite 插件通过两个钩子实现虚拟模块:
js
resolveId(id) {
// 拦截特定模块 id,返回一个内部标识
if (id === 'virtual:auto-routes') return '\0virtual:auto-routes'
},
load(id) {
// 对内部标识返回动态生成的代码字符串
if (id === '\0virtual:auto-routes') {
return generateCode(pagesDir)
}
}
\0 前缀是 Vite/Rollup 的约定,表示这是一个内部虚拟模块,不会被其他插件误处理。
generateCode 的输出就是一段普通的 JS 字符串,Vite 会把它当作真实模块编译:
js
import Page0 from '/src/pages/index.vue'
import Page1 from '/src/pages/about.vue'
import Page2 from '/src/pages/user/[id].vue'
export const routes = [
{ path: '/', name: 'index', component: Page0 },
{ path: '/about', name: 'about', component: Page1 },
{ path: '/user/:id', name: 'user-:id', component: Page2 },
]
文件扫描:为什么选 fast-glob
最初用 Node 内置的 fs.readdirSync 递归实现,能跑,但代码冗长:
js
// 40 行递归,处理目录、过滤扩展名、拼接路径...
function scanPages(dir, base = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) { /* 递归 */ }
else if (entry.name.endsWith('.vue')) { /* 处理 */ }
}
}
换成 fast-glob 之后:
js
const files = fg.sync('**/*.vue', { cwd: pagesDir, onlyFiles: true })
一行搞定,且:
- 自动忽略隐藏文件和
node_modules - 性能更好(并发 I/O + 优化的目录遍历)
- Vite 本身已依赖 fast-glob,无需额外安装
路径到路由的映射规则
约定优于配置,映射规则简单直观:
| 文件路径 | 路由 path |
|---|---|
pages/index.vue |
/ |
pages/about.vue |
/about |
pages/user/index.vue |
/user |
pages/user/[id].vue |
/user/:id |
pages/blog/[slug]/edit.vue |
/blog/:slug/edit |
实现核心就是一个字符串转换:
js
const segments = file
.replace(/\.vue$/, '') // 去掉扩展名
.split('/')
.map(s =>
s === 'index' ? '' : // index 段消除
s.replace(/\[(\w+)\]/g, ':$1') // [id] → :id
)
const routePath = '/' + segments.filter(Boolean).join('/')
热更新支持
开发时新增或删除页面文件,路由应该自动更新,不需要重启 dev server。
通过 configureServer 钩子拿到 Vite 的内部 watcher:
js
configureServer(server) {
const { watcher, moduleGraph, ws } = server
watcher.on('add', onFileChange)
watcher.on('unlink', onFileChange)
function onFileChange(file) {
const mod = moduleGraph.getModuleById('\0virtual:auto-routes')
if (!mod) return
// 让虚拟模块缓存失效
moduleGraph.invalidateModule(mod)
// 发送 HMR update 信号,只重载路由模块,不刷新整页
// 相比 full-reload,页面状态(表单、滚动位置等)得以保留
ws.send({
type: 'update',
updates: [{
type: 'js-update',
path: '\0virtual:auto-routes',
acceptedPath: '\0virtual:auto-routes',
timestamp: Date.now(),
}],
})
}
}
Vite 的 watcher 底层是 chokidar,已内置,无需额外依赖。
页面私有组件:按需导入而非全局注册
页面组件有两种归属:
- 全局组件 :放
src/components/,整个项目复用 - 页面私有组件 :放在页面目录下的
components/,只有当前页面用
bash
src/pages/
user/
[id].vue
components/
UserAvatar.vue ← 私有组件,不应注册路由
插件通过 fast-glob 的 ignore 规则跳过所有 components/ 目录:
js
const files = fg.sync('**/*.vue', {
cwd: pagesDir,
onlyFiles: true,
ignore: ['**/components/**'], // 任意层级的 components 目录均忽略
})
组件的自动导入交给 unplugin-vue-components 处理,它会扫描模板里实际用到的组件,编译时自动插入 import,用不到的不打包,tree-shaking 完全有效。
vue
<!-- 直接用,无需手动 import -->
<UserAvatar :user="user" />
404 页面
插件在生成路由表时,检测 pages/404.vue 是否存在:
- 存在 → 用它作为 404 页面
- 不存在 → 内联一个最简提示兜底
/:pathMatch(.*)* 是 Vue Router 的通配符写法,永远追加在路由表末尾:
js
const has404 = fg.sync('404.vue', { cwd: pagesDir }).length > 0
const notFoundRoute = has404
? `{ path: '/:pathMatch(.*)*', component: NotFound }`
: `{ path: '/:pathMatch(.*)*', component: { template: '<div>404 Not Found</div>' } }`
最终效果
项目结构:
bash
src/
components/ ← 全局组件,unplugin-vue-components 按需导入
pages/
index.vue ← /
about.vue ← /about
404.vue ← 404 兜底
user/
[id].vue ← /user/:id
components/
UserAvatar.vue ← 私有组件,不注册路由
业务代码只需:
js
import { routes } from 'virtual:auto-routes'
const router = createRouter({
history: createWebHistory(),
routes,
})
新增页面文件 → 路由自动出现,删除 → 自动消失。全程不需要碰路由配置文件。
总结
| 关键点 | 选择 | 理由 |
|---|---|---|
| 实现方式 | Vite 插件虚拟模块 | 零侵入,构建时生成,无运行时开销 |
| 文件扫描 | fast-glob | Vite 已内置,简洁高性能 |
| 热更新 | HMR update 信号 | 只重载路由模块,保留页面状态 |
| 路由约定 | 文件路径即路由 | 直观,符合 Next.js/Nuxt 用户习惯 |
| 私有组件 | ignore components/ | 不污染路由表,配合 unplugin 按需导入 |
| 404 处理 | 检测 404.vue + 兜底 | 约定优先,无文件时自动降级 |
整个插件核心代码不到 100 行,覆盖了虚拟模块、文件扫描、动态路由、热更新、私有组件隔离、404 兜底六个能力。这也是 Vite 插件体系的魅力所在:用少量代码撬动强大的构建能力。