uni-app x工程化实战:从页面堆到可维护架构

本文所有结论均经过运行验证(Web 端全链路),完整模板见文末仓库地址(v1.0.0,MIT 可直接套用)。

导语:uni-app x 的「工程化真空」

uni-app x 是 DCloud 的新一代跨端方案:用 UTS 语言开发,编译期静态模型,一份代码编译为 JS(Web / 小程序)与 Kotlin / Swift 原生代码。性能确实能打------但当你想认真做一个中大型项目时,会撞上一个尴尬的现实:生态默认形态是「页面堆」。

请求写在页面里、状态挂在页面 data、跳转靠裸字符串路径、响应是裸 UTSJSONObject 下标取值。项目一旦超过三个页面,改一个接口要全局搜索。

熟悉标准 Vue3 工程化的同学会想:把那套搬过来不就行了?不行。 uni-app x 的页面是编译期静态模型(多端编译到 JS / Kotlin / Swift),标准 Vue3 的「单路由表、单插件协议」形态在这里有四处根本性差异:

  1. 没有单路由表 ------页面在 pages.json 静态注册,编译期确定,运行时注册不存在;
  2. 插件协议不可靠 ------App 端(Kotlin)app.use(pinia) 实测不生效;
  3. 存储全是异步------没有同步 cookie 可用;
  4. 名义类型系统------Kotlin 端不是结构化类型,类型不兼容不是报错而是 cast 崩溃。

这意味着工程化必须在底座约束下重新设计,而不是搬运。本文就是这套设计的完整记录。

一、先看结论:最终长什么样

1.1 目录结构

bash 复制代码
├── main.uts                  # 唯一装配点:app.use(router) + initRequest() + setupPermission()
├── App.uvue                  # 应用生命周期
│
├── api/                      # 接口层:薄封装(请求转发 + 路径收敛 + DTO 还原)
│   ├── user.uts
│   ├── types.uts             # 域类型聚合出口(具名类型转发)
│   └── types/api.uts         # DTO 唯一事实源(LoginResult / UserInfo)
│
├── stores/                   # 状态层:免挂载,模块级注册表即全局单例
│   ├── index.uts / types.uts / types/
│   └── modules/user.uts      # token/name/avatar + login/getInfo/logout
│
├── composables/              # 组合式函数:函数直接导入,类型走聚合出口
├── components/               # easycom 约定目录,页面免注册使用
│
├── router/                   # 路由层:createRouter + 全局守卫 + 插件生成的路由表
│   ├── index.uts / permission.uts
│   └── routes.gen.uts        # ⚠ vite 插件自动生成,勿手改
│
├── utils/                    # request.uts(网络封装)/ auth.uts(token 读写)
├── config/index.uts          # 全局唯一配置源
└── pages/                    # 页面,pages.json 静态注册

1.2 核心调用链

bash 复制代码
页面(.uvue)
  → composables/useXxx(横切 UI 状态)
  → stores/modules/*(业务状态与动作)
  → api/<domain>(DTO 还原)
  → utils/request(拦截器:token 注入 + 统一错误 toast)
  → mx-request → uni.request

三条硬规则贯穿全程:

  • 页面不直连网络层,不接触裸 JSON;store 不反向引用组件;
  • api 层只做薄封装:请求转发 + 路径收敛 + DTO 还原,禁止写业务逻辑;
  • 装配点唯一 :全局初始化只发生在 main.uts,禁止在模块顶层做副作用。

二、关键设计决策

2.1 三种装配范式并存------不是不统一,是底座如此

uts 复制代码
// main.uts(唯一装配点,保持极薄)
app.use(router)      // ux-router:实现了 Vue install 协议
initRequest()        // mx-request:纯函数库,全局默认实例 + 双拦截器(幂等)
setupPermission()    // 注册 beforeEach + 冷启动补跑守卫链

注意三个插件用了三种不同的接入方式 :router 走 app.use(install 协议在 Kotlin 端可靠),pinia 走免挂载(app.use(pinia) 在原生端实测不可靠,改为模块级注册表实现全局单例),request 走显式初始化函数(避免模块顶层副作用的时序问题)。

这不是设计洁癖问题------是三种机制在原生端的实测结果决定了各自唯一可靠的接入形态。

2.2 鉴权数据流:用内存缓存抹平「异步存储」

Web 时代的经验是 js-cookie 同步读 token,到处直接取。但 uni-app x 的持久化(ux-storage)是异步的,而守卫、请求拦截器都是「同步心智」。直接 async/await 到处传染会污染整条调用链。

解法是模块级内存缓存 + 显式就绪原语:

  • utils/auth.uts 维护 token 的内存缓存:写入时「内存即时生效 + 异步落盘」,读取永远是同步的;
  • ensureAuthReady():冷启动时由守卫链首行 await 调用,从持久层恢复内存缓存。内部 Promise 复用------只读一次持久层,并发调用共享同一 Promise,无竞态。

这样异步被压缩在启动的一瞬间,之后的整条链路(守卫、拦截器、页面)都回到同步心智。

2.3 全局守卫:白名单 + 补跑 + 兜底,三层防漏

守卫逻辑对标经典 admin 场景:

csharp 复制代码
await ensureAuthReady()           // 异步守卫,ux-router 守卫链支持 await Promise
├─ 有 token
│   ├─ 访问登录页 → 重定向首页
│   ├─ 已有用户信息 → 放行
│   └─ 无用户信息 → await getInfoAction
│       ├─ 成功 → 放行
│       └─ 失败 → resetToken + 重定向登录页(带 redirect)
└─ 无 token
    ├─ 白名单(登录页)→ 放行
    └─ 其余 → 重定向登录页(query 带 redirect=path)

两个容易被忽略的坑:

冷启动补跑 。H5 直达 URL 或 deeplink 进入时,页面可能已加载而守卫未执行。解法是启动时 router.isReady().then(() => router.guardRoute()) 显式补跑一遍守卫链,redirect 按 relaunch 执行。

导航兜底 。仅靠规约「页面必须用 useRouter()」挡不住所有场景(第三方组件内部直调 uni.navigateTo 会绕过守卫链)。运行时启用 InterceptorPlugin(interceptUniApi: true)拦截所有原生导航 API 转交守卫链------但规约仍要求统一走 useRouter(),兜底不是拿来依赖的。

另外一个 UTS 特有的细节:重定向指令必须用路由库导出的 RawLocation / NavigationRedirect 显式类型构造,禁止裸对象字面量------后者在 Kotlin 名义类型下会 cast 崩溃(详见第四节)。

2.4 类型体系:类型跟着消费关系走

判据只有一句话:类型放它被消费的地方。

类型性质 位置
跨层共享(api → store → 页面/守卫) <域>/types/ + <域>/types.uts 聚合出口
多文件域的出口形状(如 Store 类型) 同上
单文件模块私有契约 就近定义
第三方库类型 import type 导入,绝不复制

三条强制规则:

规则一:聚合出口只做具名类型转发。

uts 复制代码
// api/types.uts
export type { LoginResult, UserInfo } from './types/api.uts'

禁止 export * from,禁止经转发链导出值(函数/常量)------这两条在 Kotlin 端未实证或已证伪,是实测结论而非风格偏好。

规则二:DTO 还原逐调用点内联,禁止提取公共泛型包装。

uts 复制代码
// api/user.uts
export const login = (username: string, password: string): Promise<LoginResult> => {
  return new Promise((resolve, reject) => {
    request<UTSJSONObject>(/* ... */).then((res) => {
      // 逐调用点内联还原,禁止包装成 parse<T> 公共函数
      const dto = JSON.parse<LoginResult>(JSON.stringify(res.data))
      if (dto === null) {
        reject(new Error('登录响应解析失败'))
        return
      }
      resolve(dto)
    }).catch((err) => {
      reject(err)
    })
  })
}

原因:JSON.parse<T> 的泛型二次传递在 Kotlin 端触发泛型擦除,公共包装函数在 Web 端跑得好好的,上 Android 就是运行时炸点。这是整个体系里最反直觉、也最值得记住的一条。

规则三:禁用 TypeScript 工具类型。 Partial / Record / Pick 一律不存在,需要就手写等效 type。UTS 不是「更严的 TS」,是另一门语言。

uni 原生 showToast 的 position / icon 在各端表现差异很大。方案是统一收敛到 ux-utils 的自绘通道(App 端 UTS 直挂系统窗口 / Web DOM 自绘 / 小程序原生降级),调用侧一套 API 全端一致;退出登录这类破坏性操作统一走 showModalAsync 确认。

三、UTS 踩坑红线清单(不踩会崩)

以下每一条都对应一次真实的编译错误或运行时崩溃,按「坑 → 正确姿势」整理:

# 坑 正确姿势
1 不支持 undefined(UTS110111119) 一切可空用 `null
2 条件语句禁 truthy/falsy(UTS110111120) if (x > 0)、while (isValid),禁止 if (arr)
3 对象字面量只能赋给 type 定义的类型(UTS110111163) 禁 interface 承接字面量;先 type 后赋值
4 泛型二次传递触发 Kotlin 泛型擦除 JSON.parse<T> 只能逐调用点以具体类型内联
5 export * from / 值转发链 Kotlin 端不可靠 聚合出口一律 export type { X } from 具名转发
6 type 的属性方法不能带泛型(uts110111161) 回调签名具体化,或泛型提升到 type 级别
7 enum 不用于跨端传递 字面量联合类型替代
8 函数声明(function)不能作值传递(UTS110111152) 回调一律箭头函数
9 Kotlin 端数组越界抛异常(JS 端返回 undefined) 访问前判界:index >= 0 && index < arr.length
10 Kotlin 名义类型下裸对象 cast 崩溃 跨库对象一律用导出的显式类型构造(如 RawLocation)

这张表值得打印出来贴在显示器旁边------TS 越熟练,越容易在 1、2、4 三条上栽跟头。

四、验证现状与已知问题(截至 v1.0.0)

Web 端已全量验证:冷启动守卫重定向、登录闭环(redirect 回跳)、带 token 冷启动、登出确认弹窗(取消/确定双路径)、DTO 解析链路、easycom 组件渲染、统一错误 toast;mock 服务端日志确认拦截器注入与请求协议正确。

Android(Kotlin)同构代码待真机回归 ,但已知风险点已在代码层面全部规避------上面的红线清单就是规避清单本身:泛型二次传递(DTO 内联还原)、export { x } from 值转发(仅用 export type)、UTSJSONObject cast(重定向用导出类型构造)、app.use(pinia)(免挂载)。

两个已记录的 Web 端已知行为(非错误):

  • redirect query 存在双重编码(%252F)现象,round-trip 正常;
  • relaunch 后控制台打印 NavigationFailure 确认日志,跳转结果正确。

五、如何使用这套模板

bash 复制代码
git clone https://github.com/MengXi-Studio/unix-architecture.git
  1. 改一处配置:config/index.uts 的 baseURL 指向你的后端;
  2. 本地联调可用任意 mock(协议:POST /user/login → {token}、GET /user/info → {name, avatar}、POST /user/logout,CORS 放行 Authorization);
  3. 新增业务功能按固定顺序落码:DTO 类型 → api 薄封装 → store 模块 → 页面,四个环节缺一不可;新页面默认要求登录,免登录页需在守卫白名单登记;
  4. 编译运行(HBuilderX CLI):D:\HBuilderX\cli.exe launch web --project <项目绝对路径>。

新增页面的三个动作:建 .uvue 文件 + pages.json 注册 + 页内 defineUniPage({ name, title }) 编译宏------路由表由 vite 插件自动生成,人手不碰。

六、一点延伸:把架构约束交给 AI 执行

这个模板还内置了一套 AI 协作体系(.trae/ 目录,随仓库公开):rules/ 四件套把分层红线、插件清单、落码顺序写成机器可执行的强制规则,skills/ 提供「新功能六步落码」的标准流程,任务方案与实施记录全部落盘归档。

实际体验下来,最大的收益不是「写得快」,而是红线不漂移------AI 生成代码时被规则约束在分层内,UTS 红线清单直接注入上下文,上面第四节那些坑在生成阶段就被拦住了。人机协作的工程化,也值得像代码一样被认真设计。

写在最后

  • 仓库:github.com/MengXi-Stud... (v1.0.0)
  • 技术栈:uni-app x(HBuilderX 5.26,vapor)· UTS · Vue3 组合式 API
  • 底座插件系:ux-router / ux-pinia / mx-request / ux-storage / ux-utils

后续计划:Android(Kotlin)真机回归后,会再出一篇「Kotlin 端实测报告」------红线清单里每条规避项的实战验证结果。如果这篇文章帮你省下了一些踩坑时间,欢迎去仓库点个 Star。

相关推荐
开开心心_Every1 小时前
免费压缩软件WinRAR无弹窗、广告干扰
java·开发语言·前端·spring·智能手机·word·winrar
特立独行的猫A1 小时前
用仓颉语言写桌面应用:cj-tauri 框架与 AI 笔记软件 cjnote 实战
前端
特立独行的猫A1 小时前
我为什么推荐用仓颉语言的cj-tauri 写桌面应用(附 cjnote 最小的markdown笔记AI软件)
前端
纸片人1 小时前
牛一越界,大屏 3 秒飞到它身边:我用真实卫星数据 + Three.js 做了个 3D 智慧牧场
前端
cc_瀚海知行2 小时前
工具调用 6|助手什么时候该停下来?
java·服务器·前端·人工智能·python·ai
溪语流沙2 小时前
【Web全栈进阶】Nginx反向代理 + HTTPS:正式上线
前端·vue.js·python·nginx·https·fastapi
vivo互联网技术2 小时前
域名埋点零侵入实时校验实践:基于代理转发与规则驱动的工具设计
前端·测试·监控·代理·埋点
喵个咪2 小时前
GoWind ShortPlay 拆解:Flutter 沉浸式 Feed 的播放器工程——生命周期、手势状态机与游客降级
前端·flutter·架构