从 pages.json 生成 uni-app 类型化路由

从 pages.json 生成 uni-app 类型化路由

这篇文章讨论的不是 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 不能省略;缺少它时,对象属性值通常会扩大成普通 stringRoutePath 就失去字面量联合的约束。我的模板仓库仍应把这一项和后文的重复键检查补进生成器,所以本文展示的是经过收紧的目标设计,而不是宣称旧脚本已经覆盖全部边界。

读取 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 中再执行一次生成,并检查工作区是否产生未提交差异。

自动生成的价值来自“单一事实源 + 可验证产物”。如果生成器只是把手写错误复制到另一个文件,它反而会扩大维护成本。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。