Vite 插件开发入门:从零写一个自动生成路由的插件
上周接手了一个中后台项目,200 多个页面,router/index.ts 写了 1800 行。每次新建页面都得手动往路由表里加一条记录,路径拼错了不报错,组件引用写错了要等构建阶段才能发现。整个团队每周至少因为路由配置出一次线上事故。
Nuxt 和 Next.js 都有基于文件系统的自动路由,Vite 生态里也有 vite-plugin-pages 这类方案。但我们项目的路由规则比较特殊:有权限前缀、有多 layout 嵌套、还有一套自定义的路由元信息约定。现成插件的扩展能力撑不住这些需求,硬改源码的成本比自己写还高。
这篇文章是那次从零开发插件的完整复盘,从最小插件结构讲到 HMR 支持和嵌套路由,踩的坑都会具体说明。写完这个插件之后,我对 Vite 的插件机制理解明显深了一截。
自动路由插件的核心思路
动手写代码之前,先把需求拆解清楚。这个插件要做三件事:扫描 src/pages/ 下的所有 .vue 文件,根据文件路径推导出路由配置,然后让业务代码能通过 import routes from 'virtual:auto-routes' 直接使用生成的路由表。
这三件事分别对应 Vite 插件的三个核心能力:文件监听、代码生成、虚拟模块。
虚拟模块是怎么工作的
虚拟模块是 Vite 插件开发中最常用的模式。所谓"虚拟",指的是这个模块不存在于磁盘上,它的内容由插件在运行时动态生成。业务代码写 import routes from 'virtual:auto-routes',其实磁盘上根本没有这个文件——是插件在 resolveId 和 load 两个钩子里"凭空捏造"了它。
const VIRTUAL_MODULE_ID = 'virtual:auto-routes'
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
export default function autoRoutes(): Plugin {
return {
name: 'vite-plugin-auto-routes',
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
},
load(id) {
if (id === RESOLVED_ID) {
return `export default [{ path: '/', component: () => import('/src/pages/index.vue') }]`
}
}
}
}
resolveId 负责"认领"模块 ID,load 负责返回模块内容,两者是固定搭配。那个 \0 前缀是 Rollup 的约定:以 \0 开头的模块 ID 不会被文件系统解析,其他插件看到这个前缀也会主动跳过,避免冲突。
我第一次写的时候忘了加 \0 前缀,结果在 vite-plugin-inspect 里死活看不到虚拟模块的输出,排查了大半个小时才在 Rollup 文档里翻到这条约定。
上面这个硬编码的例子只是为了演示虚拟模块的最小结构。接下来要做的事情才是插件的核心——扫描文件、生成路由表、处理嵌套关系。
扫描文件并转换为路由路径
第一步是用 fast-glob 扫描 src/pages/ 目录下所有 .vue 文件,然后把文件路径转换成路由 path。转换规则和 Nuxt 类似:index.vue 对应 /,[id].vue 对应 /:id,[...all].vue 对应 /:all(.*)*。
import fg from 'fast-glob'
import path from 'path'
function scanPages(pagesDir: string) {
const files = fg.sync('**/*.vue', {
cwd: pagesDir,
onlyFiles: true,
ignore: ['**/components/**', '**/_*'], // 排除组件目录和下划线前缀文件
})
return files.map(file => {
// 统一为 posix 路径
const filePath = file.replace(/\\/g, '/')
// 去掉 .vue 后缀
let routePath = filePath.replace(/\.vue$/, '')
// index 文件映射为目录路径
routePath = routePath.replace(/\/index$/, '') || '/'
// [param] -> :param(动态路由)
routePath = routePath.replace(/\[([^\]\.]+)\]/g, ':$1')
// [...param] -> :param(.*)*(兜底路由)
routePath = routePath.replace(/\[\.\.\.([^\]]+)\]/g, ':$1(.*)*')
// 确保以 / 开头
if (!routePath.startsWith('/')) routePath = '/' + routePath
return {
filePath: path.posix.join(pagesDir, filePath),
routePath,
rawFile: filePath,
}
})
}
举个具体例子,假设 src/pages/ 下有这些文件:
src/pages/
├── index.vue → /
├── about.vue → /about
├── users/
│ ├── index.vue → /users
│ ├── [id].vue → /users/:id
│ └── [id]/
│ └── settings.vue → /users/:id/settings
└── [...404].vue → /:404(.*)*
构建嵌套路由树
扫描得到的是一个扁平列表,但 Vue Router 需要的是树形结构——/users/:id/settings 应该嵌套在 /users/:id 下面,而 /users/:id 又嵌套在 /users 下(前提是 users/ 目录下有对应的 layout 文件)。
嵌套路由的判定规则是:如果一个路径存在同名目录,该目录下的文件就成为它的子路由。比如 users.vue 和 users/ 目录同时存在时,users/ 下的所有页面就是 users.vue 的 children。
interface RouteNode {
path: string
component?: string
children: RouteNode[]
meta?: Record<string, any>
}
function buildRouteTree(pages: ReturnType<typeof scanPages>): RouteNode[] {
const root: RouteNode[] = []
// 按路径深度排序,确保父路由先被处理
const sorted = [...pages].sort((a, b) => {
const depthA = a.routePath.split('/').length
const depthB = b.routePath.split('/').length
return depthA - depthB
})
// 用 Map 记录已注册的路由节点,key 是 routePath
const nodeMap = new Map<string, RouteNode>()
for (const page of sorted) {
const node: RouteNode = {
path: page.routePath,
component: page.filePath,
children: [],
}
// 查找父路由:逐级向上寻找同名 layout 文件
const segments = page.routePath.split('/').filter(Boolean)
let inserted = false
if (segments.length > 1) {
// 从最近的父级开始向上查找
for (let i = segments.length - 1; i >= 1; i--) {
const parentPath = '/' + segments.slice(0, i).join('/')
const parentNode = nodeMap.get(parentPath)
if (parentNode) {
// 子路由的 path 只保留相对部分
node.path = segments.slice(i).join('/')
parentNode.children.push(node)
inserted = true
break
}
}
}
if (!inserted) {
root.push(node)
}
nodeMap.set(page.routePath, node)
}
return root
}
这里有一个容易踩的坑:排序必须保证父路由先于子路由被处理,否则子路由找不到父节点,会被错误地挂到根级别。我最初用字母序排序,结果 users.vue 排在 users/index.vue 后面,整棵子树都散架了。
把路由树序列化为模块代码
拿到路由树之后,需要把它序列化成 JavaScript 代码字符串,作为虚拟模块的内容返回:
function generateRouteCode(routes: RouteNode[]): string {
function serialize(node: RouteNode): string {
const parts: string[] = []
parts.push(`path: '${node.path}'`)
if (node.component) {
parts.push(`component: () => import('${node.component}')`)
}
if (node.meta && Object.keys(node.meta).length > 0) {
parts.push(`meta: ${JSON.stringify(node.meta)}`)
}
if (node.children.length > 0) {
parts.push(`children: [${node.children.map(serialize).join(',\n')}]`)
}
return `{ ${parts.join(', ')} }`
}
return `export default [${routes.map(serialize).join(',\n')}]`
}
HMR 支持:文件变化时自动更新路由
开发阶段最重要的体验就是新增或删除页面文件后路由自动更新,不需要手动重启 dev server。这需要用到 configureServer 和 handleHotUpdate 两个钩子。
export default function autoRoutes(options: { pagesDir?: string } = {}): Plugin {
const pagesDir = options.pagesDir || 'src/pages'
let rootDir: string
// 缓存当前路由代码,用于判断是否真的有变化
let currentRouteCode: string
function regenerateRoutes() {
const pages = scanPages(path.resolve(rootDir, pagesDir))
const tree = buildRouteTree(pages)
const sorted = sortRoutes(tree) // 排序逻辑见下文
return generateRouteCode(sorted)
}
return {
name: 'vite-plugin-auto-routes',
configResolved(config) {
rootDir = config.root
},
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
},
load(id) {
if (id === RESOLVED_ID) {
currentRouteCode = regenerateRoutes()
return currentRouteCode
}
},
// 监听 pages 目录下的文件变化
configureServer(server) {
const pagesFullPath = path.resolve(rootDir, pagesDir)
function handleFileChange(filePath: string) {
if (!filePath.startsWith(pagesFullPath)) return
if (!filePath.endsWith('.vue')) return
const newCode = regenerateRoutes()
// 只有路由表真正变化时才触发更新,避免无意义的刷新
if (newCode === currentRouteCode) return
currentRouteCode = newCode
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
if (mod) {
server.moduleGraph.invalidateModule(mod)
server.ws.send({ type: 'full-reload' })
}
}
server.watcher.on('add', handleFileChange)
server.watcher.on('unlink', handleFileChange)
},
// .vue 文件内容变化时,检查 <route> 块是否有修改
handleHotUpdate({ file, server }) {
const pagesFullPath = path.resolve(rootDir, pagesDir)
if (!file.startsWith(pagesFullPath) || !file.endsWith('.vue')) return
const newCode = regenerateRoutes()
if (newCode === currentRouteCode) return
currentRouteCode = newCode
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
if (mod) {
server.moduleGraph.invalidateModule(mod)
server.ws.send({ type: 'full-reload' })
}
},
}
}
configureServer 里监听 add 和 unlink 事件处理文件新增和删除;handleHotUpdate 处理文件内容修改(比如 <route> 块里的元信息变了)。两处都做了 newCode === currentRouteCode 的比对——这个判断很关键,没有它的话,任何 .vue 文件的修改都会触发路由模块更新,进而导致全页面 reload,HMR 的细粒度更新优势就全丢了。
解析权限前缀:文件名到路由元信息的映射
前面提到我们项目有一套权限路由命名约定:文件名前缀用 . 分隔,第一段是权限组标识。比如 admin.user-list.vue 表示这个页面属于 admin 权限组,路由路径是 /user-list,同时路由的 meta 里会自动注入 { auth: 'admin' }。
这套约定让我们的权限路由完全由文件名驱动,不需要在每个页面里手写 meta,新人建页面时也不容易漏配权限。
function parseFileName(rawFile: string): { routeName: string; meta: Record<string, any> } {
// rawFile 示例: 'admin.user-list.vue' 或 'dashboard/admin.stats.vue'
const basename = rawFile.split('/').pop()!.replace(/\.vue$/, '')
const segments = basename.split('.')
// 已知的权限组前缀列表,可以从配置文件读取
const knownAuthGroups = ['admin', 'editor', 'viewer', 'super']
const meta: Record<string, any> = {}
let routeName = basename
if (segments.length > 1 && knownAuthGroups.includes(segments[0])) {
meta.auth = segments[0]
// 路由路径只取前缀之后的部分
routeName = segments.slice(1).join('.')
}
return { routeName, meta }
}
实际效果:
| 文件名 | 路由路径 | meta.auth |
|---|---|---|
admin.user-list.vue | /user-list | admin |
editor.article-edit.vue | /article-edit | editor |
dashboard.vue | /dashboard | (无前缀,不注入) |
admin.settings.vue | /settings | admin |
然后在 scanPages 里调用这个函数,把解析出的 meta 附加到每条路由上:
// 在 scanPages 的 map 回调末尾
const { routeName, meta } = parseFileName(filePath)
return {
filePath: path.posix.join(pagesDir, filePath),
routePath: routeName.startsWith('/') ? routeName : '/' + routeName.replace(/\./g, '/'),
rawFile: filePath,
meta,
}
路由守卫那边只需要统一检查 to.meta.auth,不用关心权限信息从哪来。整条链路从"建文件"到"鉴权生效"完全自动化。
解析 <route> 自定义块
除了文件名约定,有些路由元信息确实更适合写在 .vue 文件里,比如页面标题、是否缓存、面包屑配置等。我们支持在 .vue 文件中使用 <route> 自定义块来声明这些信息:
<route>
{
"title": "用户详情",
"cache": true,
"breadcrumb": ["用户管理", "用户详情"]
}
</route>
<template>
<div>用户详情页</div>
</template>
在插件中提取 <route> 块的内容,需要读取 .vue 文件源码并解析:
import fs from 'fs'
function extractRouteBlock(filePath: string): Record<string, any> | null {
const content = fs.readFileSync(filePath, 'utf-8')
// 匹配 <route> 块,支持 <route lang="json"> 写法
const match = content.match(/<route(?:\s[^>]*)?>([^]*?)<\/route>/)
if (!match) return null
const raw = match[1].trim()
if (!raw) return null
try {
return JSON.parse(raw)
} catch (e) {
console.warn(`[auto-routes] Failed to parse <route> block in ${filePath}:`, e)
return null
}
}
然后在路由生成阶段合并两种来源的 meta——文件名前缀提供权限信息,<route> 块提供页面级配置,两者合并后写入路由的 meta 字段:
// 在 buildRouteTree 或 scanPages 中
const fileNameMeta = parseFileName(page.rawFile).meta
const routeBlockMeta = extractRouteBlock(page.filePath)
const mergedMeta = { ...fileNameMeta, ...routeBlockMeta }
// routeBlockMeta 的优先级更高,可以覆盖文件名前缀的约定
最终生成的路由对象类似:
{
path: '/user-list',
component: () => import('/src/pages/admin.user-list.vue'),
meta: { auth: 'admin', title: '用户列表', cache: true }
}
踩坑记录
Windows 路径分隔符
fast-glob 返回的路径统一用 / 分隔,但 path.resolve 在 Windows 上会生成 \ 分隔的路径。如果生成的 import 语句里混入了反斜杠,Vite 直接无法解析模块,页面白屏。
解决方法是所有拼接出来的路径都过一遍 p.replace(/\\\\/g, '/')。
路由排序影响匹配优先级
Vue Router 4 的匹配规则是先定义先匹配。如果 /:id 排在 /profile 前面,访问 /profile 时会命中 /:id,参数 id 的值变成字符串 "profile",页面渲染出完全错误的内容。
插件生成路由时的排序逻辑必须保证三个层级:静态路由最先,动态路由其次,兜底路由(包含 (.*) 的)排最后。同一层级内按字母序排列,确保结果稳定可预期。
function sortRoutes(routes: RouteNode[]): RouteNode[] {
return routes
.map(route => ({
...route,
children: route.children.length > 0 ? sortRoutes(route.children) : [],
}))
.sort((a, b) => {
const scoreA = getRouteScore(a.path)
const scoreB = getRouteScore(b.path)
if (scoreA !== scoreB) return scoreA - scoreB
// 同级别按字母序,确保排序稳定
return a.path.localeCompare(b.path)
})
}
function getRouteScore(path: string): number {
// 兜底路由排最后
if (path.includes('(.*)')) return 2
// 动态路由排中间
if (path.includes(':')) return 1
// 静态路由排最前
return 0
}
实际遇到的一个坑:我们有 /users/profile 和 /users/:id 两个路由,上线后发现所有用户的个人资料页都 404 了——因为最初的排序函数没有递归处理 children,只排了顶层路由,嵌套路由里的顺序完全随机。加上递归排序后问题解决。
开发环境和构建环境的行为差异
开发环境下,虚拟模块的 load 钩子在每次模块请求时都会调用,返回的路由表始终是最新的。构建时 load 只调用一次,结果会被缓存。
这个差异导致了一个隐蔽的 bug:我有一版实现会在 load 里生成一个 .routes.json 缓存文件用于调试,开发环境下每次 HMR 触发都会重写这个文件,文件变化又被 watcher 捕获,再次触发 HMR——形成无限循环,页面疯狂刷新停不下来。把调试文件的输出逻辑从 load 钩子里挪出来,改成手动调用,问题就消失了。
和现有方案的对比
| 维度 | vite-plugin-pages | unplugin-vue-router | 自己写 |
|---|---|---|---|
| 路由元信息 | <route> 块,YAML/JSON | definePage 宏 | <route> 块,JSON + 文件名前缀 |
| 类型安全 | 需要额外配置 | 开箱即用,类型自动推导 | 手动声明 .d.ts |
| 自定义路由规则 | 有限,靠 extendRoute 回调 | 较灵活 | 完全自由 |
| 嵌套路由 | 支持 | 支持 | 需要自己实现 |
| 维护成本 | 社区维护 | 社区维护,迭代更活跃 | 团队自己维护 |
| 包体积影响 | ~15KB | ~25KB | ~3KB(只有核心逻辑) |
如果你的项目路由规则比较标准,unplugin-vue-router 是目前社区最推荐的选择,类型推导的开发体验确实好。我们自己写是因为有一套权限路由命名约定——页面文件名的前缀代表权限组(比如 admin.user-list.vue 属于 admin 权限组),这套规则在现有插件里没法直接表达。
落地效果
插件上线两周后做了一次回顾。
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 路由配置文件行数 | 1800 行 | 15 行 |
| 新建页面耗时 | ~3 分钟 | ~30 秒 |
| 路由相关线上事故(周均) | 1.2 次 | 0 次 |
| 路由配置 CR 耗时 | 每次 ~10 分钟 | 基本不需要 |
最直观的反馈来自团队里的新人同事——他入职第一天就按照文件命名规范新建了一个页面,路由自动生成、权限自动挂载,全程没有碰过 router/index.ts。之前的入职文档里有整整一页是在讲"如何正确添加路由配置",现在这页直接删了。
通用经验
从这个插件的开发过程里可以提炼出三个通用模式,覆盖了绝大多数 Vite 插件的使用场景。
虚拟模块模式适合往项目里注入运行时数据——路由表、环境变量、自动导入的模块清单都属于这一类。resolveId + load 是固定搭配,\0 前缀不能省。
代码转换模式走 transform 钩子,用于修改已有模块的源码,比如给组件自动注入 import 语句、为 JSX 添加编译提示。
开发服务器增强模式走 configureServer 钩子,适合需要添加自定义中间件或者 WebSocket 通信的场景,mock 服务和组件预览面板都是典型用例。
如果你想动手试试,建议从最简单的虚拟模块入手——写一个把 package.json 的版本号注入到运行时的小插件,十几行代码就能跑通,用来理解钩子的调用流程刚刚好。等虚拟模块的机制摸熟了,再往上叠文件监听和 HMR 支持。路由排序和嵌套路由的树构建放到最后处理,这两块的边界条件最多,一上来就啃容易卡住。