Vite 插件开发入门:从零写一个自动生成路由的插件

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',其实磁盘上根本没有这个文件------是插件在 resolveIdload 两个钩子里"凭空捏造"了它。

ts 复制代码
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(.*)*

ts 复制代码
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/ 下有这些文件:

bash 复制代码
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.vueusers/ 目录同时存在时,users/ 下的所有页面就是 users.vuechildren

ts 复制代码
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 代码字符串,作为虚拟模块的内容返回:

ts 复制代码
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。这需要用到 configureServerhandleHotUpdate 两个钩子。

ts 复制代码
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 里监听 addunlink 事件处理文件新增和删除;handleHotUpdate 处理文件内容修改(比如 <route> 块里的元信息变了)。两处都做了 newCode === currentRouteCode 的比对------这个判断很关键,没有它的话,任何 .vue 文件的修改都会触发路由模块更新,进而导致全页面 reload,HMR 的细粒度更新优势就全丢了。

解析权限前缀:文件名到路由元信息的映射

前面提到我们项目有一套权限路由命名约定:文件名前缀用 . 分隔,第一段是权限组标识。比如 admin.user-list.vue 表示这个页面属于 admin 权限组,路由路径是 /user-list,同时路由的 meta 里会自动注入 { auth: 'admin' }

这套约定让我们的权限路由完全由文件名驱动,不需要在每个页面里手写 meta,新人建页面时也不容易漏配权限。

ts 复制代码
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 附加到每条路由上:

ts 复制代码
// 在 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> 自定义块来声明这些信息:

vue 复制代码
<route>
{
  "title": "用户详情",
  "cache": true,
  "breadcrumb": ["用户管理", "用户详情"]
}
</route>

<template>
  <div>用户详情页</div>
</template>

在插件中提取 <route> 块的内容,需要读取 .vue 文件源码并解析:

ts 复制代码
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 字段:

ts 复制代码
// 在 buildRouteTree 或 scanPages 中
const fileNameMeta = parseFileName(page.rawFile).meta
const routeBlockMeta = extractRouteBlock(page.filePath)
const mergedMeta = { ...fileNameMeta, ...routeBlockMeta }
// routeBlockMeta 的优先级更高,可以覆盖文件名前缀的约定

最终生成的路由对象类似:

ts 复制代码
{
  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",页面渲染出完全错误的内容。

插件生成路由时的排序逻辑必须保证三个层级:静态路由最先,动态路由其次,兜底路由(包含 (.*) 的)排最后。同一层级内按字母序排列,确保结果稳定可预期。

ts 复制代码
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 支持。路由排序和嵌套路由的树构建放到最后处理,这两块的边界条件最多,一上来就啃容易卡住。

相关推荐
清汤饺子2 小时前
搞懂 Cursor 后,我一行代码都不敲了《进阶篇》
前端·javascript·后端
哟哟耶耶2 小时前
vue3-<script setup>是Vue3.2+引入编译语法糖与编译器宏以及useSlots()和useAttrs()
前端·javascript·vue.js
哈__2 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-haptic-feedback
javascript·react native·react.js
吴声子夜歌2 小时前
JavaScript——DOM与事件
开发语言·javascript·ecmascript
紫_龙2 小时前
最新版vue3+TypeScript开发入门到实战教程之路由详解
javascript·typescript·智能路由器
四千岁2 小时前
如何精准统计 Token 消耗,使用对账工具控制成本?
前端·javascript·vue.js
jiayong232 小时前
0基础学习VUE3 第 3 课:任务页怎么把列表、筛选、表单、弹窗串起来
前端·javascript·学习
蜡台2 小时前
Monorepo 架构管理多个子项目实现
前端·javascript·vue.js·pnpm·monorepo
前端小趴菜052 小时前
Vue项目,前端如何来做登录密码加密传输?
前端·javascript·vue.js