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

0 阅读5分钟

基于约定优于配置的思想,用 Vite 插件机制 + fast-glob 实现零配置自动路由注册。


背景与动机

在 Vue3 项目里,手动维护路由表是一件重复且容易出错的事:

// 每新增一个页面都要改这里 😩
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 导入:

const pages = import.meta.glob('./pages/**/*.vue')

问题在于它是运行时行为,需要在业务代码里手动处理路径到路由的映射,侵入业务层,不够优雅。

方案 B:自定义 Vite 插件 + 虚拟模块 ✅

插件在构建阶段扫描文件系统,生成一个虚拟模块 virtual:auto-routes,业务代码只需:

import { routes } from 'virtual:auto-routes'

完全透明,零侵入,支持热更新。这是主流方案(vite-plugin-pagesunplugin-vue-router 都是这个思路)。

方案 C:直接用现成库

vite-plugin-pages 开箱即用,但失去了定制空间,也失去了理解底层机制的机会。

最终选方案 B,自己实现,完全可控。


核心机制:Vite 虚拟模块

Vite 插件通过两个钩子实现虚拟模块:

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 会把它当作真实模块编译:

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 递归实现,能跑,但代码冗长:

// 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 之后:

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

实现核心就是一个字符串转换:

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:

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/,只有当前页面用
src/pages/
  user/
    [id].vue
    components/
      UserAvatar.vue    ← 私有组件,不应注册路由

插件通过 fast-glob 的 ignore 规则跳过所有 components/ 目录:

const files = fg.sync('**/*.vue', {
  cwd: pagesDir,
  onlyFiles: true,
  ignore: ['**/components/**'],  // 任意层级的 components 目录均忽略
})

组件的自动导入交给 unplugin-vue-components 处理,它会扫描模板里实际用到的组件,编译时自动插入 import,用不到的不打包,tree-shaking 完全有效。

<!-- 直接用,无需手动 import -->
<UserAvatar :user="user" />

404 页面

插件在生成路由表时,检测 pages/404.vue 是否存在:

  • 存在 → 用它作为 404 页面
  • 不存在 → 内联一个最简提示兜底

/:pathMatch(.*)* 是 Vue Router 的通配符写法,永远追加在路由表末尾:

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>' } }`

最终效果

项目结构:

src/
  components/          ← 全局组件,unplugin-vue-components 按需导入
  pages/
    index.vue          ← /
    about.vue          ← /about
    404.vue            ← 404 兜底
    user/
      [id].vue         ← /user/:id
      components/
        UserAvatar.vue ← 私有组件,不注册路由

业务代码只需:

import { routes } from 'virtual:auto-routes'

const router = createRouter({
  history: createWebHistory(),
  routes,
})

新增页面文件 → 路由自动出现,删除 → 自动消失。全程不需要碰路由配置文件。


总结

关键点选择理由
实现方式Vite 插件虚拟模块零侵入,构建时生成,无运行时开销
文件扫描fast-globVite 已内置,简洁高性能
热更新HMR update 信号只重载路由模块,保留页面状态
路由约定文件路径即路由直观,符合 Next.js/Nuxt 用户习惯
私有组件ignore components/不污染路由表,配合 unplugin 按需导入
404 处理检测 404.vue + 兜底约定优先,无文件时自动降级

整个插件核心代码不到 100 行,覆盖了虚拟模块、文件扫描、动态路由、热更新、私有组件隔离、404 兜底六个能力。这也是 Vite 插件体系的魅力所在:用少量代码撬动强大的构建能力。