这篇文章讨论的不是 Vue Router,而是 uni-app 项目如何从 pages.json 生成可复用、可推导类型的路由常量。在 uni-app 中,pages.json 才是页面入口事实源;生成器的职责是减少业务代码里的路径字符串,而不是再维护一份独立路由表。
完整实践可参考我的 uniapp-template。
推荐的目标产物
假设 pages.json 中有首页、登录页和一个分包协议页,推荐生成结果如下:
// 此文件为路由映射,由脚本自动生成,请勿手动修改
export const routeNames = {
pagesTabbarHomeIndex: '/pages/tabbar/home/index',
pagesLoginIndex: '/pages/login/index',
pagesCommonAgreementIndex: '/pages/common/agreement/index'
} as const
export const tabbar = [
{ text: '首页', url: '/pages/tabbar/home/index' }
] as const
export type RouteKey = keyof typeof routeNames
export type RoutePath =
(typeof routeNames)[keyof typeof routeNames]
业务代码从常量读取路径:
uni.navigateTo({
url: routeNames.pagesCommonAgreementIndex
})
这样改页面路径时,生成文件的差异和 TypeScript 报错会暴露受影响调用处。
as const 不能省略;缺少它时,对象属性值通常会扩大成普通 string,RoutePath 就失去字面量联合的约束。我的模板仓库仍应把这一项和后文的重复键检查补进生成器,所以本文展示的是经过收紧的目标设计,而不是宣称旧脚本已经覆盖全部边界。
读取 JSONC,而不是直接 JSON.parse
uni-app 的配置文件可能包含注释。用 jsonc-parser 解析能保留这种使用习惯:
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from 'jsonc-parser'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pagesJsonPath = path.resolve(
currentDir,
'../src/pages.json'
)
const raw = await fs.readFile(pagesJsonPath, 'utf8')
const config = parse(raw)
如果解析结果不是对象、页面路径缺失或存在重复键,生成器应直接失败并返回非零退出码,避免把不完整文件当成成功结果提交。
合并主包与分包
function collectPages(config) {
const mainPages = (config.pages ?? []).map((page) => ({
...page,
fullPath: page.path
}))
const subPages = (config.subPackages ?? []).flatMap(
(pkg) =>
(pkg.pages ?? []).map((page) => ({
...page,
fullPath: [pkg.root, page.path]
.filter(Boolean)
.join('/')
}))
)
return [...mainPages, ...subPages]
}
这里要兼容 subPackages 不存在的项目,也要避免生成双斜杠。路径拼接应使用配置里的 URL 语义,而不是文件系统的 path.join;后者在 Windows 上可能生成反斜杠。
生成稳定键名
function formatRouteKey(routePath) {
return routePath
.split('/')
.filter(Boolean)
.map((segment, index) =>
index === 0
? segment
: segment[0].toUpperCase() + segment.slice(1)
)
.join('')
.replace(/[^a-zA-Z0-9_$]/g, '')
}
生成后还应检查:
const seen = new Map()
for (const page of collectPages(config)) {
const key = formatRouteKey(page.fullPath)
if (!key) {
throw new Error(
`无法从路径生成路由键:${page.fullPath}`
)
}
if (seen.has(key)) {
throw new Error(
`路由键冲突:${seen.get(key)} 与 ${page.fullPath}`
)
}
seen.set(key, page.fullPath)
}
路径在清理特殊字符后可能得到同一个键。静默覆盖会让导航指向错误页面,所以冲突必须阻止生成。
路由参数类型
模板同时维护 RouteParams.d.ts:
type DefaultParam = Record<string, string>
export interface RouteParams {
/** 登录 */
pagesLoginIndex: {
redirect?: string
}
/** 协议 */
pagesCommonAgreementIndex: {
type: 'privacy' | 'service'
}
}
新增路由时可以先生成 DefaultParam,但参数一旦进入真实业务,就应改成具体类型。生成器要保留开发者已经收紧的字段,不能每次执行都把接口覆盖回宽泛类型。
TabBar 页面
TabBar 页面通常不能使用普通的 navigateTo。生成器可以从 config.tabBar?.list ?? [] 输出清单,导航封装据此选择 switchTab:
const tabbarPaths = new Set(
tabbar.map((item) => item.url)
)
export function navigate(path: RoutePath) {
if (tabbarPaths.has(path)) {
return uni.switchTab({ url: path })
}
return uni.navigateTo({ url: path })
}
这里只根据路径决定导航方式;权限、登录重定向和业务参数不应该被隐藏在代码生成器里。
监听文件变化
开发模式可以用 chokidar 监听 pages.json,然后以子进程执行同一生成脚本。实现时需要:
- 对连续保存做防抖,避免多个生成进程同时写文件。
- 使用临时文件写完后原子替换,避免开发服务器读到半个文件。
- 生成失败时保留旧文件,并把错误清楚打印到终端。
- 处理进程退出,关闭 watcher 与子进程。
- 在 CI 中再执行一次生成,并检查工作区是否产生未提交差异。
自动生成的价值来自“单一事实源 + 可验证产物”。如果生成器只是把手写错误复制到另一个文件,它反而会扩大维护成本。