Skip to content

工程结构与约定式路由 ​

适用端:移动端 | 类型:机制

这一页回答移动端开发里最高频的三个问题:一个新页面该放在哪个目录才会被路由认出来、组件为什么不写 import 就能用、以及哪些文件是生成物不该手改。

移动端的工程层比 Web 端多一层:Web 端只有 vite.config.ts 一个配置入口,移动端是 vite.config.ts + pages.config.ts + manifest.config.ts 三个配置源,其中两个的输出(src/pages.json、src/manifest.json)是被 gitignore 掉的构建产物。看不懂这层关系,就会出现「我明明加了页面,跳转却说页面不存在」这类症状。

前置阅读:移动端总览(先知道哪 17 个页面目录是真的交付了)、移动端环境准备与启动(init-baseFiles 与那两个生成文件的来龙去脉)。

它解决什么问题 ​

关键约束写在 vite.config.ts 的注释里,是顺序敏感的:

  • :90 UniLayouts()、:91 UniPlatform()、:92 UniManifest() —— 「UniXXX 需要在 Uni 之前引入」;
  • :124 「UniOptimization 插件需要 pages.json 文件,故应在 UniPages 插件之后执行」;
  • :133 「若存在改变 pages.json 的插件,请将 UniKuRoot 放置其后」。

所以不要重排 vite.config.ts:88-142 的插件数组,重排的后果是生成时机错位、报的错却指向别处。

代码在哪 ​

工程配置文件 ​

文件作用输出
vite.config.ts插件链、alias、env 加载(:70 loadEnv)、H5 dev server、构建分包—
pages.config.tsglobalStyle + easycom(:14-19)+ tabBar合入 src/pages.json
manifest.config.tsdefineManifestConfig:appid、H5 base、App 权限与图标、小程序 appidsrc/manifest.json
uno.config.tsUnoCSS:presetUni + presetIcons + 本地 SVG 图标集合—
env/.env*环境变量,见环境准备—

src/ 下的 30 个目录 ​

支撑层(13 个):

目录内容
src/api/按后端模块分子目录(system / infra / bpm / ai / …),与 Web 端 src/api/ 同构;含 login.ts 与 types/
src/http/http.ts(请求主体)、interceptor.ts(拦截器)、sse.ts(流式)、types.ts、tools/
src/store/6 个 Pinia store:token / user / dict / nav / theme / index(导出入口)
src/router/config.ts(登录策略与保留页常量)、interceptor.ts(登录拦截)、README.md
src/hooks/5 个:useAccess / useDict / useRequest / useRouteQuery / useScroll
src/components/只有三项:dict-tag/、system-select/、yudao-ui/
src/tabbar/自定义 tabbar:config.ts(4 个 tab)、index.vue、store.ts、types.ts
src/layouts/只有 default.vue,内容是空壳 <slot />
src/style/index.scss、tokens.scss(品牌令牌)、theme.ts(tabbar 配色常量)、iconfont.css
src/utils/19 项:download.ts(含 staticUrl())、encrypt.ts、url.ts、tree.ts、format.ts、toLoginPage.ts、social-login.ts、wot.ts、constants/ 等
src/types/4 个入库的 d.ts + 1 个生成的 uni-pages.d.ts(详见下文「生成物口径」)
src/static/app/(App 图标)、images/、tabbar/、my-icons/(本地 SVG 图标集)、logo.png
src/uni_modules/🔴 空目录(本机实测无任何文件);uni-app 约定用于放置插件市场模块,本项目未使用

页面层(17 个):见移动端总览 · 代码在哪那张按「能不能被用户点到」分类的表,本页不重复。

根文件 ​

src/App.vue(生命周期)、src/App.ku.vue(UniKuRoot 的全局根组件)、src/main.ts、src/uni.scss、src/env.d.ts、src/typings.ts、src/typings.d.ts、src/wot-ui.d.ts,以及两个生成物 src/pages.json、src/manifest.json。

核心机制 ​

1. 约定式路由:definePage() → src/pages.json ​

@uni-helper/vite-plugin-uni-pages(vite.config.ts:11 引入,:100-123 配置)扫描页面目录,读每个 .vue 里的 definePage() 宏,生成整个 src/pages.json。没有手写的路由表。

配置三处要看清:

ts
// vite.config.ts:100-123
UniPages({
  exclude: ['**/components/**/**.*', '**/sections/**/**.*'],
  subPackages: [
    'src/pages-core',   // 相对必要:登录 / 注册 / 404 / 个人中心 / 设置
    'src/pages-system', // 系统管理
    // … 共 16 个,见 :104-121
  ],
  dts: 'src/types/uni-pages.d.ts',
})
  • exclude 是「页面目录里的 components/ 不算路由」的机制来源。实测 src/pages/ 下 28 个 .vue,其中 21 个是 components/ 里的局部组件,最终只有 7 个成了路由。
  • subPackages 里的目录不能是 src/pages 的子目录(:102 的原注释:「分包目录不能配置在 pages 目录下!!」)。
  • 全仓 762 个 .vue 用了 definePage()(grep -rl "definePage(" src --include=*.vue | wc -l)。

最小写法:

vue
<script setup lang="ts">
definePage({
  type: 'home',            // 主包首页标记;普通页面用 'page'
  style: {
    navigationStyle: 'custom',
  },
})
</script>

生成结果(实测,src/pages.json 是 JSONC,含注释,不能直接 JSON.parse):

部分数量
主包 pages7:pages/index/index(工作台)、pages/index/search/index、pages/index/settings/index、pages/bpm/index(审批)、pages/message/index(消息)、pages/user/index(我的)、pages/contact/index(通讯录,tabbar 已隐藏)
subPackages16 个 root,共 757 页

16 个分包的页数分布:pages-mes 188、pages-mall 87、pages-erp 61、pages-system 58、pages-infra 45、pages-im 44、pages-ai 43、pages-iot 43、pages-crm 40、pages-wms 29、pages-bpm 27、pages-pay 22、pages-mp 19、pages-member 18、pages-statistics 18、pages-core 15。其中前 11 名里的 9 个属于后端已删除的模块——见总览里那段产物体积测算。

新增分包必须同时改 vite.config.ts

只在 src/ 下建目录、页面里写 definePage(),是不会被识别的:UniPages 只扫 src/pages + subPackages 里显式列出的目录。漏改的症状是 type-check 全过、dev server 不报错,但 uni.navigateTo 报「页面不存在」。

2. easycom:四条规则,其中一条是死的 ​

pages.config.ts:12-19:

ts
easycom: {
  autoscan: true,
  custom: {
    '^fg-(.*)': '@/components/fg-$1/fg-$1.vue',                                    // :15
    '^yd-(.*)': '@/components/yudao-ui/yd-$1/yd-$1.vue',                            // :16
    '^wd-(.*)': '@wot-ui/ui/components/wd-$1/wd-$1.vue',                            // :17
    '^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
      'z-paging/components/z-paging$1/z-paging$1.vue',                              // :18
  },
},
前缀解析到状态
fg-*@/components/fg-$1/🔴 死规则:src/components/ 下只有 dict-tag/、system-select/、yudao-ui/,没有任何 fg-* 目录。unibest 模板遗留,fg- 是模板作者的前缀
yd-*@/components/yudao-ui/yd-$1/yd-$1.vue✅ 8 个自研组件:yd-form-picker、yd-search-picker、yd-search-date-range、yd-tree-select、yd-upload、yd-upload-file、yd-upload-img、yd-upload-imgs
wd-*@wot-ui/ui 包内✅ wot-ui 全量组件
z-paging*z-paging 包内(排除 z-paging-refresh / z-paging-load-more 两个插槽组件)✅

autoscan: true 另外会扫 src/components/<name>/<name>.vue,所以 dict-tag 也能不写 import 直接用。

src/components/yudao-ui/ 目录名沿用上游,是刻意保留

它被 easycom 规则 :16 与 vite.config.ts:34-47 的 YudaoUiResolver() 双重引用(后者匹配 /^Yd[A-Z]/ 的大驼峰写法,用于 <YdUploadImg /> 这类 PascalCase 标签)。改名牵连 56 个文件的引用与自动生成的 d.ts,收益为零。目录名不代表归属变更。

3. 三个自动导入通道,各管一段 ​

移动端同时存在三条「不写 import 也能用」的通道,混用会搞不清一个名字从哪来:

通道配置位置覆盖范围生成物
easycompages.config.ts:12-19模板里的组件标签(wd-* / yd-* / z-paging*)无
UniComponentsvite.config.ts:93-99模板里的本地组件,递归扫 src/components/**(deep: true),加 YudaoUiResolversrc/types/local-components.d.ts(入库)
AutoImportvite.config.ts:155-160脚本里的 API:imports: ['vue', 'uni-app'](ref / computed / uni.xxx 等)+ dirs: ['src/hooks'](5 个 hook),vueTemplate: true 表示模板里也能用src/types/auto-import.d.ts(入库)

UniComponents 的 :98 注释点明了分工:「仅自动导入本地组件;Wot UI 与 z-paging 由 pages.config.ts 的 easycom 解析」。这么拆的原因是 dts 范围——如果把第三方组件也拉进 local-components.d.ts,会把 node_modules 里的 Vue 源码类型一起拽进来。

与 Web 端的关键差异:移动端的生成 d.ts 入库

Web 端 ainb-ui-admin-vue3 的 src/types/auto-*.d.ts 是 gitignore 掉的、只由 dev server 生成,所以新增组件后必须先 pnpm dev 才能 ts:check。移动端相反:.gitignore:34-40 把 src/types/* 全忽略后,又显式放行了 4 个:

src/types/*
# 保留自动导入类型,避免部分电脑生成的声明不完整导致类型提示报错
!src/types/auto-import.d.ts
# 保留本地组件精确类型,确保干净环境的 type-check 覆盖一致
!src/types/local-components.d.ts
# 保留 bundle-optimizer 类型,避免 type-check 依赖先运行 Vite
!src/types/async-component.d.ts
!src/types/async-import.d.ts

唯一仍被忽略的是 uni-pages.d.ts(页面路径类型)。后果:新增一个 src/components/ 下的组件后,local-components.d.ts 会出现在 git status 里,要跟着提交;忘记提交则同事那边 type-check 报找不到组件。

4. App.ku.vue:全局根组件 ​

@uni-ku/root 插件(vite.config.ts:21 引入,:134-140 配置)让 src/App.ku.vue 成为包在所有页面外层的根组件,页面通过 <KuRootView /> 占位注入。本项目的 App.ku.vue 承载四件事:

vue
<wd-config-provider :theme-vars="themeStore.themeVars" :theme="themeStore.theme">
  <KuRootView />
  <FgTabbar v-if="isCurrentPageTabbar" />
  <wd-toast />
  <wd-dialog />
</wd-config-provider>
  1. 主题:wd-config-provider 注入 useThemeStore() 的 themeVars / theme,全站 wot-ui 组件由此换肤;
  2. 自定义 tabbar:<FgTabbar>(来自 src/tabbar/index.vue),只在当前页是 tabbar 页时渲染;
  3. 全局 toast / dialog:useToast() / useDialog() 之所以在任何页面都能直接调,是因为这两个容器挂在这里;
  4. onShow 里同步 tabbar 选中态(tabbarStore.syncCurIdxByCurrentPageAsync()),并把线上环境 path === '/' 也当作首页——:15-17 的注释记录了这是上游 issue 反馈来的修复。

UniKuRoot 的 excludePages(:135-139)排除了 **/components/**、**/sections/** 与 src/pages-crm/statistics/**。

5. layout 是个空壳,tabbar 是自定义的 ​

src/layouts/default.vue 全文只有:

vue
<template>
  <slot />
</template>

UniLayouts()(:90)提供了 layout: 'xxx' 的能力,但本项目没有用它做任何布局——真正的布局外壳在 App.ku.vue。别为了「加个公共头」去改 default.vue,那会影响所有页面且与 App.ku.vue 的职责重叠。

tabbar 用的是 CUSTOM_TABBAR_WITH_CACHE 策略(src/tabbar/config.ts:25),即自己画 tabbar、隐藏原生的。customTabbarList(:45-120,其中真正生效的是 :92-118)当前 4 项:工作台(pages/index/index)、审批(pages/bpm/index)、消息(pages/message/index)、我的(pages/user/index),图标是 UnoCSS 的 i-carbon-*。「通讯录」那项被注释掉了(:103 注明「通讯录暂隐藏(需求二)」),但页面本身仍在 src/pages/contact/,路由也照样生成——所以 pages.json 主包是 7 页而 tabbar 只有 4 个。

图标类名必须进 UnoCSS safelist

i-carbon-* 是运行时按类名生成的,UnoCSS 静态扫不到 tabbar 配置里的字符串。src/tabbar/config.ts:90 的注释写明:要么在 tabbar/index.vue 里引一下再注释掉,要么配进 uno.config.ts 的 safelist。实测 uno.config.ts:74 的 safelist 当前是 6 个类名(i-carbon-{code,home,user,document,chat,user-avatar}),比在用的 4 个 tab 多两个——多出来的是已隐藏的「通讯录」user-avatar 与模板示例 code。新增 tab 时漏这一步的后果是图标位置空白,不报错。

config.ts:12 还有一句要紧的提醒:「本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效」——因为 tabBar 是 pages.config.ts 从这里 import 后合入 pages.json 的,属于构建期求值,热更新覆盖不到。

6. manifest.config.ts 自己解析 mode ​

manifest.config.ts 不能拿到 vite 注入的 mode(它在 vite 配置管线之外被求值),所以自己写了一个:

ts
function getMode() {
  const args = process.argv.slice(2)
  const modeFlagIndex = args.findIndex(arg => arg === '--mode')
  return modeFlagIndex !== -1
    ? args[modeFlagIndex + 1]
    : args[0] === 'build' ? 'production' : 'development'
}
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))

这带来一个隐蔽约束:给移动端加自定义 npm script 时,mode 必须写成 --mode xxx 的形式,否则 getMode() 落到兜底分支,manifest.json 里的 appid / name / H5 base 会取自错误的环境文件。现有脚本(dev:h5 = --mode development、build:h5 = --mode production)都符合。

生成物 src/manifest.json 里与本项目相关的几处:name ← VITE_APP_TITLE、appid ← VITE_UNI_APPID、h5.router.base ← VITE_APP_PUBLIC_BASE、mp-weixin.appid ← VITE_WX_APPID、mp-weixin.setting.urlCheck: false(微信开发者工具不校验域名,见多端构建与发布)。

生成物口径 ​

文件谁生成git改错了会怎样
src/pages.jsonUniPages(+ UniKuRoot 追加)忽略(.gitignore:43)手改会被下次 dev/build 覆盖;init-baseFiles 会写一个 stub 版
src/manifest.jsonUniManifest忽略(:42)同上
src/types/uni-pages.d.tsUniPages忽略(:34 通配)页面路径类型提示失效,不影响运行
src/types/auto-import.d.tsAutoImport✅ 入库不提交 → 同事 type-check 报 ref 未定义
src/types/local-components.d.tsUniComponents✅ 入库不提交 → 同事 type-check 报找不到组件
src/types/async-{component,import}.d.ts@uni-ku/bundle-optimizer✅ 入库不提交 → type-check 必须先跑一次 vite

init-baseFiles 写的 stub 里有一条不存在的路由

scripts/create-base-files.js 在 src/pages.json 缺失或只有空对象时,写入一份最小 stub。这份 stub 是 unibest 模板原文,第二项是:

js
{ path: 'pages/me/me', type: 'page', style: { navigationBarTitleText: '我的' } }

而本项目没有 src/pages/me/ ——芋道改造时把它删了换成 pages/user/(src/tabbar/config.ts:38 的注释:「原 me 被删除,改为 user 避免 IDE linter 报错」)。

正常情况下这只是个转瞬即逝的中间态:stub 存在的唯一目的是让 UniOptimization(vite.config.ts:125,注释写明「需要 pages.json 文件」)在 UniPages 生成真文件之前不至于崩溃,随后 UniPages 就把它整个覆盖了。

但任何在 vite 之外直接读 src/pages.json 的脚本(构建脚本、巡检脚本、CI 步骤)在全新克隆上跑,读到的会是这份含幽灵路由的 stub。排查这类问题时先确认文件是 stub 还是真产物:

bash
grep -c '"path"' src/pages.json   # stub = 2,真产物 = 700+

怎么扩展 ​

加一个页面(以在 pages-system 下加 foo/index.vue 为例):

  1. 建文件 src/pages-system/foo/index.vue,写 definePage({ style: { navigationBarTitleText: 'Foo' } });
  2. 确认目录已在 vite.config.ts:104-121 的 subPackages 里(src/pages-system 已在);
  3. pnpm dev:h5 已在跑则热更新即可;改的是 pages.config.ts / src/tabbar/config.ts 就必须重启;
  4. 跳转用 uni.navigateTo({ url: '/pages-system/foo/index' }),路径以 src/pages.json 里的 root + '/' + path 为准;
  5. 若要让它出现在工作台,还要在 Web 端「App菜单」里建 system_nav_node 节点 —— 见服务端驱动导航。只加页面不建节点,用户点不到。

加一个全局组件:放 src/components/<name>/<name>.vue(autoscan 直接可用),或放 src/components/yudao-ui/yd-<name>/yd-<name>.vue(用 <yd-name> 或 <YdName>)。两者都会让 local-components.d.ts 变化,记得提交。

加一个 hook:放 src/hooks/useXxx.ts 并 export function useXxx(),AutoImport 的 dirs: ['src/hooks'] 会自动导出,无需 import。

加一个 tabbar 项:改 src/tabbar/config.ts 的 customTabbarList(:92-118),把图标类名加进 uno.config.ts:74 的 safelist,然后重启 dev server。

坑与注意 ​

  • 改了 pages.config.ts / manifest.config.ts / src/tabbar/config.ts 不重启 = 不生效。这三个都是构建期求值,热更新覆盖不到。同理,改了 env/.env* 也不生效(loadEnv 只在 vite.config.ts:70 跑一次),机制见环境准备。
  • src/pages.json 是 JSONC:pages 数组与每个分包内部都插了 // GENERATED BY UNI-PAGES, PLATFORM: H5(首个在 :19),用 JSON.parse 直接读会抛 Unexpected token '/'。写巡检/统计脚本时先剥注释。那行注释里的 PLATFORM 还顺带告诉你这份文件是哪次构建生成的——H5 dev 与 mp-weixin dev 生成的内容不同,混用会得出错误结论。
  • src/uni_modules/ 是空的,别以为里面有东西。
  • src/typings.ts:3-6 有个 TestEnum { A = '1', B = '2' },是 unibest 模板的示例枚举,全仓无人引用。同文件里的 IUniUploadFileOptions / RemoveLeadingSlash 是真在用的,删的时候别一起删掉。
  • src/layouts/default.vue 是空壳,公共外壳在 App.ku.vue。
  • 分包总数 16 而非 11:vite.config.ts:104-121 列了 16 个目录,其中 11 个后端无对应模块。判断某个分包能不能用,看的是后端 reactor(ainb/pom.xml:16-20),不是这里的清单。

→ 本节是贴着上下文的提醒;按症状查的移动端全域清单见移动端坑位,跨端清单见坑位索引。

未证实 ​

  • 16 个分包在小程序端的实际分包体积上限:微信小程序单包 2 MB / 总包 30 MB 的限制下,pages-mes(188 页)这类分包能否通过审核未验证。本项目只实测过 H5。
  • UniOptimization 的实际收益::125-132 只对 mp-weixin 开启(注释:「避免支付宝小程序运行报错」),它做的异步分包优化在 H5 上未启用,因此本页所有产物体积数字都是 H5 口径,小程序端不可直接套用。
  • src/types/async-{component,import}.d.ts 的具体内容:由 @uni-ku/bundle-optimizer 生成且入库,但未逐行核对其与当前分包配置是否同步——若它过期,type-check 的结论也会失真。

相关页面 ​