上周五下午,老板又站到我身后盯屏幕,看我一个个打开页面文件、复制路径、粘贴到路由配置文件里。十几条路由搞完,他叹了口气:“能不能别手动复制路由了?这玩意不是能自动的吗?”我嘴上说“能能能”,心里想的是:确实能,但我为什么没早做。
后来我花了两天时间,写了个自动扫描脚本,专门干这件事——扫一遍页面目录,路由配置自动生成。从那以后,新增页面再也不用碰路由文件,团队里几个同事也没再因为漏配路由提过“页面404了”的bug。这篇文章就把完整思路和实现拆开讲清楚,包含选型对比、核心代码、踩坑记录和后续扩展,适合被手动维护路由折磨过的前端、全栈同学参考。
1. 手动维护路由的痛,只有被线上事故打过的人才懂
1.1 一次漏配路由引发的“页面失踪”事故
事情得从更早的一次线上事故说起。当时项目里有个活动页,是运营那边催着上线的,开发同学加完页面文件、配好菜单入口,但忘了在路由配置文件里注册对应路径。结果上线后用户点菜单,页面直接白屏,控制台报错说匹配不到路由。排查了半天,最后发现只是 router/index.js 里少了一行 component: () => import(...)。
这种问题听起来特别低级,但在一个几十号人维护的中大型前端项目里,一点都不稀奇。页面文件散落在各个业务目录里,路由配置集中在另一个地方,两者之间靠人肉同步。文件一多、改动一频繁,漏配、错配、路径写错就是迟早的事。
还有更隐蔽的:A同事和B同事同一天各自加了页面,都改了路由文件,合代码的时候冲突了。Git合并冲突解决不好,就可能把另一个人刚加的路由覆盖掉。这种问题在code review里很难发现,因为路由文件动辄几百上千行,没人会逐行比对。
手动复制路由的另一个痛点是路由和实际文件结构容易出现“漂移”。页面文件改名了,路由没跟着改;目录调整了,路由还是老路径。时间一长,路由配置里残留一堆已经不存在页面的死条目,你也不知道哪些能删、哪些还在用。项目越大,维护成本越高,最后没人敢轻易动路由文件。
1.2 为什么“复制粘贴路由表”这个习惯特别危险
做前端的应该都有体会:路由配置看起来结构简单,无非是路径、组件、子路由、meta这几个字段,但一旦项目规模上来,它就会悄悄变得复杂。
最简单的场景是这样:
javascript复制{
path: '/user/list',
name: 'UserList',
component: () => import('@/views/user/List.vue'),
meta: { title: '用户列表', icon: 'user' }
}
这段配置本身不复杂,但如果有100个页面、50个嵌套路由、十几个需要配置 beforeEnter 守卫或 props 的路由呢?复制粘贴出错的地方就多了:
- 路径和组件实际位置不一致,拷过来的路径少了层级;
name重复,导致路由跳转时push({ name: 'xxx' })跳到错误的页面;- 嵌套路由层级错误,父组件里的
<router-view>渲染不出来; meta字段漏配,菜单、面包屑、权限判断全部出问题。
更重要的是,手动维护路由把精力浪费在了机械劳动上。路由配置本质上是从文件系统到URL的一种映射,这种映射完全可以通过约定自动生成。人该做的是关注业务逻辑,而不是每天给路径字符串做搬运工。
所以我当时的判断是:这个问题值得花时间根治,而不是每次遇到漏配再去补。写一个扫描脚本,用约定代替配置,把“人肉同步”变成“机器生成”,一劳永逸。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 选型思考:运行时扫描还是构建时生成?
2.1 备选方案对比:require.context、import.meta.glob、独立脚本
确定了要自动化,接下来就是选型。我调研了市面上常见的几种做法,各有优劣,这里直接对比给你看。
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
require.context |
Webpack运行时批量导入 | 改动小,实时生效 | 只适配Webpack,Vite不适用;路由不直观 |
import.meta.glob |
Vite内置动态导入 | 代码简洁,支持懒加载 | 路由信息不够显式,复杂场景难处理 |
| 独立扫描脚本 | 构建前运行Node脚本生成路由文件 | 生成结果真实可读,支持复杂逻辑 | 需要维护脚本本身 |
require.context 是Webpack时代比较流行的做法,大家应该都见过这段代码:
javascript复制const modules = require.context('@/views', true, /\.vue$/)
它能自动把 views 目录下的所有 .vue 文件批量导入,然后遍历生成路由。好处是简单、延迟加载,坏处是项目换到Vite就不能用了,而且生成逻辑不够直观——你无法直接看到最终的路由表长什么样,调试起来比较费劲。
import.meta.glob 是Vite环境下的替代方案,写法更现代:
javascript复制const modules = import.meta.glob('@/views/**/*.vue')
这个方案的优点是构建工具原生支持、性能好,但它同样有“隐式路由”的问题:路由的路径、meta等信息都写在组件的 defineOptions 或注释里,代码不运行起来,你很难一眼看出整个路由结构。
我最后选的是独立扫描脚本方案。核心原因是这个项目里路由不只是“文件路径映射”这么简单——消息页面需要动态参数、权限页面需要配置meta、部分目录需要嵌套布局。这些逻辑用 require.context 或 import.meta.glob 写起来要么很别扭,要么让路由表完全变成黑盒。独立脚本可以做到扫描文件系统后,生成一份明明白白、可读可改的路由配置文件,既有自动化的便利,又保留了手动调整的兜底手段。
2.2 约定式路由方案的取舍逻辑
说到“路由自动生成”,很多人的第一反应是想到Nuxt.js或Next.js那种约定式路由:页面文件放 pages 目录里,文件路径自动对应URL路径,完全不用写路由配置。
我这个脚本的思路本质上也是约定式路由,但我故意没有直接上Nuxt,原因很简单:存量项目改造不是从零开始。公司这个项目已经是Vue3 + Vue Router的组合,路由表里还有一些特殊的字段和守卫逻辑,全部改成框架级的约定式路由,工作量不仅在于迁移页面,还要处理各种边界情况,风险太大。
所以我采取的是一个更轻量的方案:保留现有路由文件作为最终出口,但用脚本自动生成绝大多数路由条目的骨架,再允许人工在生成结果上增量修改。
这样有几个好处:
- 不改变项目的技术栈和构建流程;
- 路由文件仍然是普通JS文件,IDE识别、代码跳转、git diff都正常;
- 新增页面时,脚本会扫描到并自动生成对应条目,不需要人工处理;
- 特殊路由(比如有独立布局或特殊守卫的页面)可以在生成后额外补充,脚本不会覆盖人工改动。
取舍逻辑总结成一句话:让脚本处理80%的机械工作,人工只保留20%的复杂判断。这个比例对团队来说最容易接受,推行阻力也最小。
3. 脚本核心实现:扫描src/pages目录,生成路由配置
3.1 目录结构与命名约定的设计
先花点时间设计目录约定。约定越清楚,脚本越简单,出错的概率越低。
我的设计是这样的——所有页面文件统一放在 src/pages 目录下,文件名一律使用小驼峰(camelCase),目录名使用短横线(kebab-case)。举个例子:
code复制src/pages/
├── home/
│ └── index.vue
├── user/
│ ├── list.vue
│ └── profile.vue
├── order/
│ ├── detail/
│ │ └── [id].vue
│ └── create.vue
└── login.vue
对应的路由路径规则:
src/pages/home/index.vue→/homesrc/pages/user/list.vue→/user/listsrc/pages/order/detail/[id].vue→/order/detail/:idsrc/pages/login.vue→/login
这里有几个约定细节:
index.vue作为目录的默认索引页,路径只到目录层级;- 方括号
[xxx]表示动态路由参数,脚本会自动将其转换为:xxx格式; - 组件名(
name字段)由文件路径推导,保持全局唯一; - 路由懒加载采用动态导入写法,也就是
component: () => import(...)的形式。
另外还需要一个元信息文件来配置 meta。原本想过把meta写进文件名里,比如 user-list_meta_admin.vue,但试了下实在太丑,而且很容易解析出错,果断放弃。最后改用 page.config.js 文件,放在页面目录里,和页面文件并列:
javascript复制// src/pages/user/list.config.js
export default {
title: '用户列表',
icon: 'user',
permission: 'user:list',
hidden: false
}
脚本扫描到 list.vue 时会自动去读同目录下的 .config.js 文件,把配置合并进生成的路由条目里。没写配置文件的页面也能正常生成路由,只是 meta 为空对象。这样既保持了可读性,又给了业务灵活配置的空间。
3.2 遍历目录、解析参数、生成配置的完整代码
脚本我用的Node.js,因为前端环境本身就有Node,不需要额外引入依赖。整个脚本拆成三个模块:文件扫描、路径解析、代码生成。
文件扫描这部分,直接遍历 src/pages 下的所有 .vue 文件,排除掉以 _ 开头的私有目录(比如 _components、_utils 这类不参与路由的目录)。
javascript复制// scripts/scan-routes.js
const fs = require('fs')
const path = require('path')
const PAGES_DIR = path.resolve(__dirname, '../src/pages')
function walkDir(dir) {
let results = []
const items = fs.readdirSync(dir, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dir, item.name)
if (item.isDirectory()) {
if (item.name.startsWith('_')) continue // 私有目录不参与路由
results = results.concat(walkDir(fullPath))
} else if (item.isFile() && item.name.endsWith('.vue')) {
results.push(fullPath)
}
}
return results
}
接着是路径解析,把物理路径转换成路由对象。核心逻辑是把绝对路径转成相对于 pages 的虚拟路径,再按规则拆分成路由层级。
javascript复制function parseRoute(filePath) {
const relativePath = path.relative(PAGES_DIR, filePath)
const parsed = path.parse(relativePath)
let routePath = parsed.dir
.split(path.sep)
.map(seg => (seg.startsWith('[') && seg.endsWith(']') ? `:${seg.slice(1, -1)}` : seg))
.join('/')
const fileName = parsed.name
if (fileName === 'index') {
routePath = routePath || '/'
} else {
routePath = routePath ? `${routePath}/${fileName}` : `/${fileName}`
}
if (routePath !== '/' && !routePath.startsWith('/')) {
routePath = `/${routePath}`
}
const fileRelative = relativePath.replace(/\\/g, '/')
const componentPath = `@/pages/${fileRelative}`
const routeName = fileRelative
.replace(/\.vue$/, '')
.replace(/\[|\]/g, '')
.replace(/[\/\\]/g, '-')
let meta = {}
const configFile = filePath.replace(/\.vue$/, '.config.js')
if (fs.existsSync(configFile)) {
// 这里用同步读取+正则提取,避免引入Babel解析
const content = fs.readFileSync(configFile, 'utf-8')
const match = content.match(/export default\s+(\{[\s\S]*?\})/)
if (match) {
try {
meta = new Function(`return ${match[1]}`)()
} catch (e) {
console.warn(`[scan-routes] 解析配置文件失败: ${configFile}`, e.message)
}
}
}
return {
path: routePath,
name: routeName,
component: `() => import('${componentPath}')`,
meta
}
}
最后是代码生成。考虑到路由可能存在父子嵌套关系,脚本会先扫描所有路由,再按照目录层级组装出 children 字段。组装的关键在于:每一层目录都可以有 index.vue 作为父容器,比如 src/pages/order/detail/[id].vue 我们希望生成的是 /order/detail/:id,但如果 order 目录下存在自己的 index.vue,它就会成为父级路由,子路由放在 children 里。
javascript复制function buildTree(routes) {
const rootChildren = []
const map = {}
routes.forEach(route => {
const segments = route.path.split('/').filter(Boolean)
let currentChildren = rootChildren
let currentPath = ''
let parentRoute = null
segments.forEach((seg, index) => {
currentPath += `/${seg}`
const isLast = index === segments.length - 1
let existing = (parentRoute && parentRoute.children) || currentChildren
let target = existing.find(r => r.path === seg)
if (!target) {
// 无显式父路由时,仅当存在父级页面文件才生成嵌套
target = createPlaceholder(seg)
existing.push(target)
}
if (isLast) {
Object.assign(target, route)
}
parentRoute = target
currentChildren = target.children || (target.children = [])
})
})
return rootChildren
}
这段代码的重点在于:它会先扫描所有文件,把路由树建好,再对每条路由做二次修正,确保只有真正存在页面文件的节点才有 component 字段。
生成出来的路由文件长这样:
javascript复制// src/router/routes.js (自动生成,请勿手动修改)
import layout from '@/layout/index.vue'
export const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/pages/login.vue'),
meta: {}
},
{
path: '/home',
name: 'home',
component: () => import('@/pages/home/index.vue'),
meta: {
title: '首页',
icon: 'home'
}
},
{
path: '/order',
component: layout,
children: [
{
path: 'create',
name: 'order-create',
component: () => import('@/pages/order/create.vue'),
meta: {}
},
{
path: 'detail/:id',
name: 'order-detail-id',
component: () => import('@/pages/order/detail/[id].vue'),
meta: {}
}
]
}
]
为了方便观察生成结果,脚本还会在控制台输出一份路由汇总表,包含路径、name、组件路径三列,一眼能看出有没有漏扫或路径异常。
3.3 对meta、布局、重定向的特殊处理
Meta的处理前面已经提到了,通过同目录下的 .config.js 文件配置。不过实际开发中遇到的情况比这复杂,这里再补充几个我在脚本里做的特殊处理。
第一是布局(layout)的识别。项目中部分页面需要独立布局,比如登录页是全屏页面,后台管理页是“侧边栏+顶栏”布局。我约定:src/pages 下如果存在 layout.vue 文件,就作为该目录下所有页面的父级组件。比如 src/pages/order/layout.vue 存在时,src/pages/order/create.vue 生成的路由会嵌套在 order/layout.vue 的 children 中。生成逻辑在遍历文件的时候同步处理。
第二是重定向(redirect)的约定。有些目录希望访问 /order 时默认跳转到 /order/list,我约定在目录配置里写 redirect 字段:
javascript复制// src/pages/order/config.js
export default {
redirect: '/order/list'
}
脚本会把这个目录下的 index.vue 路由的 redirect 字段设置为该值。如果目录没有 index.vue 但又设置了 redirect,脚本自动生成一个纯重定向的路由占位。
第三是隐藏路由的处理。像“个人中心”这种入口在菜单里藏着的页面,依然要能访问,但不希望在菜单渲染时出现。这种只需要在 .config.js 里把 meta.hidden 写成 true,生成的路由自然带上了这个标记。
这些特殊逻辑看起来挺多,但本质上都是“约定驱动”,规则写在脚本里一次性搞定,业务侧只需要按约定放文件、写配置,完全不需要关心路由本身。
4. 实测效果与踩坑记录
4.1 从“手动20分钟”到“自动5秒”的变化
脚本写完以后,我最直观的感受是:新增一个页面的流程从原来五步变成两步。
之前的流程是这样的:
- 在
views目录下新建页面文件; - 打开
router/index.js; - 根据目录结构手动拼接访问路径;
- 复制一行路由配置,改路径、改name、改组件引入地址;
- 如果需要meta,还要再编辑一份菜单配置。
现在变成:
- 在
pages目录下新建页面文件; - 保存后跑一次
npm run scan:routes(或者等提交时自动执行)。
唯一要注意的是 .config.js 文件里 meta 的配置,但这也比改路由文件直观——因为配置文件就在页面旁边,打开目录就能看到,不用在整个路由文件里翻找。
我拿项目里最复杂的一个业务模块做了测试。那个模块有37个页面,嵌套四层,原来同事手动配路由大概要20分钟,还不包括检查遗漏的时间。脚本跑完不到1秒,生成了完整的嵌套路由结构,我对比之后发现除了几个需要手动加守卫的特殊路由,其余全部符合预期。
团队里两个新来的实习生也很快上手了。他们只需要知道“在pages里建文件、想要什么meta就写配置文件”这两条规则,完全不需要理解Vue Router的路由配置语法。
4.2 动态路由参数、嵌套路由排序这些坑
脚本开发过程中踩了不少坑,挑几个最典型的说。
第一个坑是Windows路径分隔符。脚本在Windows上开发时,path.relative() 得到的路径用的是反斜杠 \,直接拼进 import 路径里会报错。我在 parseRoute 函数里加了一行 relativePath.replace(/\\/g, '/'),把Windows路径统一转成Unix格式。这个坑不上线根本发现不了,但一旦项目里有同事用Windows开发,脚本就会直接挂掉,所以跨平台兼容必须提前做好。
第二个坑是动态路由参数的重复命名。比如 order/detail/[id].vue 和 product/[id].vue 这两个文件,通过路径规则生成的 name 都是 detail-id 和 product-id,理论上不会冲突,但如果你在同一个路由层级下建了 [id].vue 和 detail/[id].vue,生成的 name 可能都包含id,需要加更精确的路径前缀来避免重复。我的处理方式是直接用文件相对路径做 name,然后用正则把非字母数字字符替换掉,保证全局唯一。
第三个坑是嵌套路由的排序。嵌套路由里,如果既有静态路由又有动态路由,Vue Router对静态路由和动态路由的匹配优先级和写入顺序有关。所以脚本在生成 children 时会做一次排序:静态路由排前面,动态路由排后面,避免出现 /user/list 被 /user/:id 抢先匹配的情况。
javascript复制function sortChildren(children) {
return children.sort((a, b) => {
const aDynamic = a.path.includes(':')
const bDynamic = b.path.includes(':')
if (aDynamic === bDynamic) return 0
return aDynamic ? 1 : -1
})
}
这个排序规则看着简单,实际特别关键。我当时没加排序,结果出现了“访问 /user/create 被 /user/:id 截胡”的bug,页面渲染出来是空白。排查了半天才发现是路由顺序问题,加完排序后一切正常。
第四个坑是配置文件解析。一开始想用 @babel/parser 去解析 .config.js 文件,因为配置文件里可能是ESModule的 export default 语法。但项目里不一定装了Babel全家桶,引入这些依赖会让脚本又重又慢。后来我改成正则提取 + new Function 的方式去解析,虽然只支持纯对象写法,但对配置文件这种简单场景完全够用。如果配置文件里有复杂计算逻辑,我会建议你把计算逻辑放到页面组件里,配置文件只留静态数据。
第五个坑是缓存问题。项目用的构建工具是Vite,Vite会缓存依赖预构建的结果。第一次跑脚本生成新的路由文件后,需要重启开发服务器才能生效。后来我在脚本末尾加了一句提示,告诉执行者在路由变化后重启dev server。这个不是脚本本身的坑,但确实会影响使用体验,不提的话同事会以为脚本没生效。
4.3 怎么在团队里推广而不被抵制
写脚本是一回事,让团队用起来是另一回事。我总结了自己这次推广过程中的几个经验:
- 先同步一个“旧路由文件切换成自动生成路由文件”的PR,让评审的人看到生成结果和原配置的对比,知道这不是玩具,而是能直接落地的东西;
- 把
scan:routes加进package.json的scripts里,并在提交代码的钩子(比如husky+lint-staged)中自动执行,确保生成的routes.js文件始终是最新的; - 做一个简单的约定文档贴到项目README里,用三个例子说明:普通页面、动态路由页面、带meta的页面分别需要怎么建文件,五分钟就能看完;
- 保留手动微调的兜底入口。生成的
routes.js文件里明确标注“此文件自动生成,请勿直接修改”,但同时也允许开发者新建一个routes.override.js,在最终合并时覆盖或追加自定义路由,避免有人因为满足不了特殊需求而退回手动维护。
心态上要认识到:不是所有同事都会愿意改变自己的工作习惯,但只要“新增页面自动生成路由”这件事真的能让他们少干活,他们自然会接受。
5. 顺着这个思路再进一步:API路由、nginx路由都能自动生成
5.1 后端接口路由的同类自动化
做完前端页面路由自动化后,我发现这个思路完全能套到后端接口路由上。公司在用的Node.js后端框架是Koa,路由定义分散在几十个文件中,每次新增接口也要手动在 router/index.js 里注册,和前端路由的问题一模一样。只不过后端的“页面文件”变成了“接口处理函数”。
我的方案是:约定 src/controllers 目录下的文件名和导出函数名决定接口路径。比如 src/controllers/user.js 里导出了 getList 和 create 两个函数,脚本自动生成:
GET /api/user/list→user.getListPOST /api/user→user.create
这里我约定了一个 http-method 映射规则,函数名前缀决定HTTP方法:
javascript复制// src/controllers/user.js
exports.getList = async (ctx) => { /* ... */ }
exports.create = async (ctx) => { /* ... */ }
exports.getDetail = async (ctx) => { /* ... */ }
脚本扫描函数名,get 开头映射成 GET 请求,create 开头映射成 POST 请求。生成的Koa中间件路由直接注册到应用入口。需要改成其他方法也可以加前缀约定,比如 updateXxx 映射成 PUT,deleteXxx 映射成 DELETE,路径部分去掉前缀后转成 kebab-case。
用这个脚本之后,后端新增接口的流程也从“改控制器 + 改路由注册文件”变成了“只写控制器函数”,路由由扫描生成。最关键的是,前后端接口文档的路径定义和实际路由实现了同一份来源,不会再出现“文档说 /api/user/list,实际代码注册的是 /api/user/list/”这种低级不一致。
5.2 集成进CI/CD和提交前检查
自动化脚本最大的价值不在单机运行,而在于嵌入到现有的开发和交付流程里。我在项目里做了两层集成:
第一层是提交前检查。每次执行 git commit 前,通过 husky 钩子自动跑一遍 scan:routes,如果生成的路由文件和仓库里的文件不一致,说明有开发者改了页面文件但没同步路由,脚本会直接报错并提示开发者先执行生成命令。这样就从源头避免了“页面文件和路由配置不一致”的问题。
第二层是CI流水线校验。在代码推送触发CI构建时,增加一个“路由一致性校验”的任务:跑一遍扫描脚本,然后 git diff --exit-code 检查生成文件有没有变化。如果有变化,说明有人在提交时绕过了本地钩子,CI直接拦截并给出提示。这层防护主要防止有同事在本地禁用了钩子或跨平台环境下钩子没执行成功。
这两层加完以后,团队基本上不会再出现漏配路由导致的线上问题了。即使有,也会在提交阶段被拦截下来,而不是到了用户访问时才爆出来。
另外,脚本生成的辅助产物还能继续用。比如我可以让脚本在生成路由文件的同时,输出一份 routes.json,里面包含所有页面路径和对应权限标识。这份JSON可以直接喂给后端做接口权限校验,或者喂给前端做菜单和面包屑渲染的静态数据源。路由信息在系统内的复用范围远比你想象的大。
6. 这套方案在真实项目里用的效果和后续规划
目前这个脚本已经稳定运行了四个月,覆盖了公司主项目里的一百多个页面。从结果上看,原先频繁出现的“漏配路由”“路由路径写错”“路由文件冲突”这三类问题基本清零。新来的同事也不用花时间理解路由配置写法,只需要知道“页面上线 = 往pages目录丢文件”,心智负担大幅降低。
当然,这套方案不是没有短板。最大的代价是约定本身的约束力——如果哪天有人创建了一个不懂约定的目录结构,脚本可能会生成出不符合预期的路由。我的应对方式是在扫描脚本里做合法性校验,遇到未匹配约定的目录结构时主动报错,而不是默默生成一个错误结果。另外,由于路由是构建前生成的静态文件,不能在运行时动态修改,所以那种“用户自定义页面”需要运行时动态注册路由的场景,这套方案就帮不上忙了。
如果后续要继续演进,我打算做三件事:
第一,把扫描脚本从 CommonJS 迁移到 ES Module,顺便支持 watch 模式,做到页面文件保存后路由自动更新,连手动跑脚本都省掉;
第二,写一个IDE插件(VSCode插件),在pages目录下右键新键文件时自动同步生成对应的路由配置、菜单配置和权限配置,把“约定”的约束变成工具的自动行为;
第三,把路由扫描和接口契约测试打通——生成路由时自动收集每个页面调用的后端接口,在CI阶段检测该页面是否有对应的接口权限配置,提前发现接口权限配置缺失的问题。
说到底,写脚本不只是帮老板省事,更多是帮自己从“重复劳动”里解放出来,把精力放在真正需要人判断的事情上。如果你也因为手动维护路由感到头疼,不妨试试这个思路,从最简单的文件扫描开始,把机械工作交给代码。
