从零实现一个 Vite 自动路由插件

基于约定优于配置的思想,用 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-pagesunplugin-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 插件体系的魅力所在:用少量代码撬动强大的构建能力。

相关推荐
终端鹿2 小时前
Vue2 迁移 Vue3 避坑指南
前端·javascript·vue.js
程序员陆业聪2 小时前
工程师的瓶颈,已经不是代码了
前端
毛骗导演2 小时前
@tencent-weixin/openclaw-weixin 源码ContextToken 持久化改造:实现微信自定义消息发送能力
前端·架构
爱丽_2 小时前
Pinia 状态管理:模块化、持久化与“权限联动”落地
java·前端·spring
SuperEugene3 小时前
TypeScript+Vue 实战:告别 any 滥用,统一接口 / Props / 表单类型,实现类型安全|编码语法规范篇
开发语言·前端·javascript·vue.js·安全·typescript
我是永恒3 小时前
上架一个跨境工具导航网站
前端
电子羊3 小时前
Spec 编程工作流文档
前端
GISer_Jing3 小时前
从CLI到GUI桌面应用——前端工程化进阶之路
前端·人工智能·aigc·交互
还是大剑师兰特4 小时前
Vue3 报错:computed value is readonly 解决方案
前端·vue.js