工程结构与约定式路由
适用端:移动端 | 类型:机制
这一页回答移动端开发里最高频的三个问题:一个新页面该放在哪个目录才会被路由认出来、组件为什么不写 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 的注释里,是顺序敏感的:
:90UniLayouts()、:91UniPlatform()、:92UniManifest()—— 「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.ts | globalStyle + easycom(:14-19)+ tabBar | 合入 src/pages.json |
manifest.config.ts | defineManifestConfig:appid、H5 base、App 权限与图标、小程序 appid | src/manifest.json |
uno.config.ts | UnoCSS: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。没有手写的路由表。
配置三处要看清:
// 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)。
最小写法:
<script setup lang="ts">
definePage({
type: 'home', // 主包首页标记;普通页面用 'page'
style: {
navigationStyle: 'custom',
},
})
</script>生成结果(实测,src/pages.json 是 JSONC,含注释,不能直接 JSON.parse):
| 部分 | 数量 |
|---|---|
主包 pages | 7:pages/index/index(工作台)、pages/index/search/index、pages/index/settings/index、pages/bpm/index(审批)、pages/message/index(消息)、pages/user/index(我的)、pages/contact/index(通讯录,tabbar 已隐藏) |
subPackages | 16 个 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:
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 也能用」的通道,混用会搞不清一个名字从哪来:
| 通道 | 配置位置 | 覆盖范围 | 生成物 |
|---|---|---|---|
| easycom | pages.config.ts:12-19 | 模板里的组件标签(wd-* / yd-* / z-paging*) | 无 |
| UniComponents | vite.config.ts:93-99 | 模板里的本地组件,递归扫 src/components/**(deep: true),加 YudaoUiResolver | src/types/local-components.d.ts(入库) |
| AutoImport | vite.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 承载四件事:
<wd-config-provider :theme-vars="themeStore.themeVars" :theme="themeStore.theme">
<KuRootView />
<FgTabbar v-if="isCurrentPageTabbar" />
<wd-toast />
<wd-dialog />
</wd-config-provider>- 主题:
wd-config-provider注入useThemeStore()的themeVars/theme,全站 wot-ui 组件由此换肤; - 自定义 tabbar:
<FgTabbar>(来自src/tabbar/index.vue),只在当前页是 tabbar 页时渲染; - 全局 toast / dialog:
useToast()/useDialog()之所以在任何页面都能直接调,是因为这两个容器挂在这里; 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 全文只有:
<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 配置管线之外被求值),所以自己写了一个:
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.json | UniPages(+ UniKuRoot 追加) | 忽略(.gitignore:43) | 手改会被下次 dev/build 覆盖;init-baseFiles 会写一个 stub 版 |
src/manifest.json | UniManifest | 忽略(:42) | 同上 |
src/types/uni-pages.d.ts | UniPages | 忽略(:34 通配) | 页面路径类型提示失效,不影响运行 |
src/types/auto-import.d.ts | AutoImport | ✅ 入库 | 不提交 → 同事 type-check 报 ref 未定义 |
src/types/local-components.d.ts | UniComponents | ✅ 入库 | 不提交 → 同事 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 模板原文,第二项是:
{ 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 还是真产物:
grep -c '"path"' src/pages.json # stub = 2,真产物 = 700+怎么扩展
加一个页面(以在 pages-system 下加 foo/index.vue 为例):
- 建文件
src/pages-system/foo/index.vue,写definePage({ style: { navigationBarTitleText: 'Foo' } }); - 确认目录已在
vite.config.ts:104-121的subPackages里(src/pages-system已在); pnpm dev:h5已在跑则热更新即可;改的是pages.config.ts/src/tabbar/config.ts就必须重启;- 跳转用
uni.navigateTo({ url: '/pages-system/foo/index' }),路径以src/pages.json里的root + '/' + path为准; - 若要让它出现在工作台,还要在 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的结论也会失真。
相关页面
- 移动端总览 —— 三仓关系、17 个页面目录的交付状态、与 Web 端的能力差
- 移动端环境准备与启动 ——
init-baseFiles、env 叠加顺序、三条启动路径 - 请求封装与登录态 ——
src/http/与src/store/token.ts的细节 - 移动端页面开发范式 —— 一个列表页的完整最小样例
- 服务端驱动导航:system_nav_node 全链路 —— 页面如何出现在工作台
- 多端构建与发布 —— 各端产物与
manifest.json的实际用途 - 移动端坑位 —— 本端专属的坑
- 前端工程结构 —— Web 端的同类页面,可对照两边的自动导入差异
