ARTICLE · 1280337
uni-app 开发小程序:项目架构以及经验分享
uni-app 开发小程序:项目架构与实战经验分享
使用 uni-app 开发微信小程序、支付宝小程序以及 H5 等多端应用时,真正影响项目质量的,不仅是页面开发速度,更重要的是项目架构设计、业务模块划分、状态管理、接口封装、性能优化,以及团队协作规范。
本文从实际项目开发的角度,分享一套适合中小型项目、也便于扩展到大型业务的 uni-app 项目架构,并结合常见开发问题总结一些实战经验。
一、uni-app 项目整体架构
1. 技术选型
一个比较实用的技术栈如下:
选型建议:
需要注意,uni-app 的传统 Vue CLI 项目与基于 Vite 的项目在工程配置上有所区别;具体采用哪种方式,应以项目创建方式及相关依赖的兼容性为准。
2. 推荐的项目目录结构
建议采用公共基础设施 + 业务模块 + 页面分包的组织方式,而不是把所有工具函数、接口和业务逻辑都堆在一个目录里。
uniapp-project/
├── api/ # 接口层
│ ├── modules/
│ │ ├── user.ts # 用户接口
│ │ ├── order.ts # 订单接口
│ │ └── product.ts # 商品接口
│ └── request.ts # 统一请求封装
│
├── components/ # 全局公共组件
│ ├── AppButton.vue
│ ├── AppEmpty.vue
│ └── AppLoading.vue
│
├── pages/ # 主包页面
│ ├── index/index.vue # 首页
│ ├── login/login.vue # 登录页
│ └── user/user.vue # 个人中心
│
├── pages-sub/ # 业务分包页面
│ ├── order/
│ │ ├── list.vue
│ │ └── detail.vue
│ ├── product/
│ │ ├── list.vue
│ │ └── detail.vue
│ └── address/
│ └── list.vue
│
├── stores/ # Pinia 状态管理
│ ├── user.ts
│ └── cart.ts
│
├── composables/ # 组合式函数
│ ├── usePagination.ts
│ └── useLogin.ts
│
├── utils/ # 通用工具函数
│ ├── storage.ts
│ ├── validate.ts
│ └── format.ts
│
├── constants/ # 常量与枚举
│ ├── api.ts
│ └── business.ts
│
├── types/ # TypeScript 类型定义
│ ├── user.ts
│ └── order.ts
│
├── static/ # 静态资源
├── App.vue
├── main.ts
├── manifest.json
├── pages.json
├── uni.scss
└── package.json
这是一种推荐的组织方案,并不是 uni-app 强制要求的目录结构。项目实际页面路径必须与 pages.json 中的配置保持一致,分包根目录也应按所采用的构建方案配置。官方对标准工程目录的说明可参考 uni-app 工程结构文档。
3. 各层职责如何划分?
一个容易维护的项目,可以划分为以下几层:
页面层 Pages
│
▼
业务逻辑层 Composables
│
▼
接口层 API
│
▼
请求基础设施 Request
│
▼
后端服务 Backend
全局状态 Stores ──► 页面层 / 业务逻辑层
通用组件 Components ──► 页面层
通用工具 Utils ──► 各业务模块
各层的职责如下:
架构设计的核心原则:让变化尽可能局限在自己的模块内。
例如,修改订单接口时,不应该需要同时修改首页、个人中心和商品详情页中的多份重复请求代码。
二、网络请求封装:项目中最值得提前规划的部分
实际开发中,网络请求往往涉及登录态、Token 过期、统一错误提示、接口超时、重复提交等问题。
如果每个页面都直接调用 uni.request(),项目规模增大后会出现大量重复代码。
建议采用三层结构:
request.ts:统一请求基础设施。api/modules/*.ts:定义各业务模块接口。1. 封装统一请求方法
下面给出一个适用于 Vue 3 + TypeScript 项目的基础示例。
api/request.ts:
const BASE_URL = 'https://api.example.com'
interface RequestOptions {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, unknown>
header?: Record<string, string>
}
interface ApiResponse<T> {
code: number
message: string
data: T
}
export function request<T>(
options: RequestOptions
): Promise<T> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token
? { Authorization: `Bearer ${token}` }
: {}),
...options.header
},
timeout: 15000,
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP 错误:${res.statusCode}`))
return
}
const result = res.data as ApiResponse<T>
if (result.code !== 0) {
reject(new Error(result.message || '请求失败'))
return
}
resolve(result.data)
},
fail: (error) => {
reject(error)
}
})
})
}
注意:这是一份基础示例,需要根据后端约定调整。 例如,有些后端使用 code === 200 表示成功,有些接口直接返回业务数据,也有些接口需要特殊处理 HTTP 状态码。
另外,BASE_URL 应根据开发、测试、生产环境分别配置,不应将示例域名直接用于生产。
2. 按业务模块封装 API
api/modules/user.ts:
import { request } from '../request'
export interface UserInfo {
id: number
nickname: string
avatar: string
}
export function getUserInfo() {
return request<UserInfo>({
url: '/user/info',
method: 'GET'
})
}
export function updateUserInfo(
data: Pick<UserInfo, 'nickname' | 'avatar'>
) {
return request<UserInfo>({
url: '/user/info',
method: 'PUT',
data
})
}
这样,页面不必关心接口地址、Token 拼接和底层请求细节。
3. 页面中调用接口
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getUserInfo } from '@/api/modules/user'
import type { UserInfo } from '@/api/modules/user'
const userInfo = ref<UserInfo | null>(null)
const loading = ref(false)
const errorMessage = ref('')
async function loadUserInfo() {
loading.value = true
errorMessage.value = ''
try {
userInfo.value = await getUserInfo()
} catch (error) {
errorMessage.value =
error instanceof Error
? error.message
: '加载失败'
} finally {
loading.value = false
}
}
onMounted(() => {
loadUserInfo()
})
</script>
<template>
<view class="user-page">
<view v-if="loading">加载中...</view>
<view v-else-if="errorMessage">
{{ errorMessage }}
</view>
<view v-else-if="userInfo">
<image
class="avatar"
:src="userInfo.avatar"
mode="aspectFill"
/>
<text>{{ userInfo.nickname }}</text>
</view>
</view>
</template>
这套方式有几个好处:
进一步优化建议: 生产项目还应统一处理未登录状态、Token 刷新、业务错误码、请求取消或去重、文件上传下载,以及错误日志。不要在每个页面里重复编写这些逻辑。
三、页面路由与分包:小程序架构设计的关键
uni-app 的页面路由主要通过 pages.json 配置。
随着业务页面增多,合理划分主包和分包,有助于控制主包体积,改善小程序首次启动体验。具体行为和限制应以目标平台的官方规范为准。uni-app
1. 主包应该放什么?
通常建议主包放置:
分包则可以放置:
这里有一个容易忽略的问题:不是页面越多,越应该拆分分包,而是应该根据业务边界、依赖关系和加载频率进行拆分。
2. 分包配置示例
假设项目包含首页、个人中心、订单管理和商品详情。
可以在 pages.json 中进行如下配置:
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/user/user",
"style": {
"navigationBarTitleText": "个人中心"
}
}
],
"subPackages": [
{
"root": "pages-sub/order",
"pages": [
{
"path": "list",
"style": {
"navigationBarTitleText": "我的订单"
}
},
{
"path": "detail",
"style": {
"navigationBarTitleText": "订单详情"
}
}
]
},
{
"root": "pages-sub/product",
"pages": [
{
"path": "detail",
"style": {
"navigationBarTitleText": "商品详情"
}
}
]
}
],
"tabBar": {
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/user/user",
"text": "我的"
}
]
}
}
以上示例展示了配置思路。实际项目还需要根据目标平台配置必要的页面样式、图标及其他选项。
3. 分包设计中的三个经验
经验一:按照业务模块拆分,而不是机械地平均分配。
例如:
主包
├── 首页
├── 登录
└── 个人中心
订单分包
├── 订单列表
├── 订单详情
└── 物流详情
商品分包
├── 商品列表
├── 商品详情
└── 商品评价
营销分包
├── 优惠券
├── 活动详情
└── 积分商城
这样做的好处是业务边界清晰,后续增加页面时也容易找到合适的位置。
经验二:注意公共代码的依赖。
如果多个分包都依赖同一份工具代码或组件,这些依赖的实际打包位置会受到构建配置和平台分包优化规则影响。
因此,不能仅凭源码目录判断最终包体积,应当检查实际编译产物。
经验三:不要把所有公共组件都放进主包。
有些组件只被某个分包使用,例如复杂的订单物流组件,可以考虑放在对应业务模块中,避免不必要的公共依赖。
分包配置与优化细节可以参考官方文档:uni-app 页面路由与分包配置。
四、状态管理:什么时候使用 Pinia?
在 uni-app Vue 3 项目中,Pinia 是常用的状态管理方案,官方也提供了相应的集成说明。Uni App
但实际开发中,并不是所有数据都需要放到全局状态里。
1. 哪些数据适合全局管理?
判断标准很简单:
如果数据需要跨页面共享,或者需要集中管理业务状态,可以考虑使用 Pinia;如果只是当前页面临时使用的数据,优先使用组件自身的响应式状态。
2. 用户状态管理示例
stores/user.ts:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface UserInfo {
id: number
nickname: string
avatar: string
}
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref<UserInfo | null>(null)
const isLogin = computed(() => Boolean(token.value))
function setToken(value: string) {
token.value = value
uni.setStorageSync('token', value)
}
function setUserInfo(value: UserInfo | null) {
userInfo.value = value
}
function logout() {
token.value = ''
userInfo.value = null
uni.removeStorageSync('token')
}
function restoreToken() {
token.value = uni.getStorageSync('token') || ''
}
return {
token,
userInfo,
isLogin,
setToken,
setUserInfo,
logout,
restoreToken
}
})
在页面中使用:
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
console.log(userStore.isLogin)
console.log(userStore.userInfo)
需要注意:这里的 restoreToken() 需要在合适的应用初始化流程中调用。
另外,生产项目不能仅凭本地 Token 判断用户一定有效。服务端仍需验证身份和权限;退出登录时,也要考虑清理用户缓存、购物车等可能存在的用户隔离数据。
一个常见错误:同时维护 Pinia 中的 Token 和多个页面中的 Token 副本。 这会导致登录状态更新后,各处数据不一致。建议明确唯一的数据来源,并统一登录、登出及 Token 刷新流程。
五、跨端开发:如何减少平台兼容问题?
uni-app 的跨端能力依赖编译器和各平台运行时,因此一套代码可以运行在多个平台,但并不意味着所有 API、组件和样式都完全一致。uni-app
1. 平台差异尽量集中处理
假设微信小程序需要调用特定的登录 API,而 H5 使用不同的认证流程。
不建议在很多页面里散落平台判断:
// 不建议在各个页面重复编写类似判断
// #ifdef MP-WEIXIN
// 微信平台逻辑
// #endif
// #ifdef H5
// H5 平台逻辑
// #endif
更好的方式是将平台差异收敛到专门的模块中。
例如:
services/
├── auth.ts
└── platform/
├── auth.mp-weixin.ts
└── auth.h5.ts
这是一种组织思路。具体文件如何参与编译,需要根据 uni-app 支持的条件编译规则和项目构建配置实现。
2. 常见的平台兼容问题
3. 使用条件编译的注意事项
条件编译适合解决确实存在的平台差异,但不应该成为所有业务逻辑的默认写法。
建议遵循以下原则:
官方跨端开发说明:uni-app 跨端原理与开发规范。
六、性能优化:从启动速度到页面体验
小程序性能优化,不应该只盯着某一个页面的加载时间,而应关注启动、网络请求、页面渲染、资源加载和用户交互的整体体验。
1. 控制主包体积
建议优先检查:
特别注意,源码看起来很精简,不代表编译后的代码一定很小。
应该以实际构建产物和目标平台的开发者工具检查结果为准。
2. 减少不必要的请求
例如,个人中心页面通常需要用户信息,但如果用户在首页已经获取过相关信息,就可以根据数据时效性和一致性要求,考虑复用缓存数据。
不过,缓存并不是越多越好。
建议明确不同数据的策略:
3. 避免列表渲染卡顿
长列表是小程序中很常见的性能问题。
可以从以下方面优化:
对于大部分业务列表,先做好分页、数据结构和组件渲染优化,通常比一开始就引入复杂方案更容易取得稳定收益。
4. 图片资源优化
图片是影响页面体验的重要因素之一。
建议:
七、实战经验:项目中最容易踩的 8 个坑
这一部分比单纯介绍技术栈更重要。很多项目后期难维护,并不是因为技术选型错误,而是因为早期没有建立明确的开发规范。
坑一:把所有逻辑写在页面里
不推荐:
order-detail.vue
├── 页面布局
├── 接口请求
├── 订单状态判断
├── 支付逻辑
├── 物流逻辑
└── 数据格式化
随着业务增加,页面会越来越复杂。
建议将页面 UI、业务逻辑、接口请求和通用工具分开,让每个模块有明确的职责。
坑二:公共组件与业务组件混在一起
例如,按钮、空状态、加载提示适合放在公共组件目录。
而订单状态卡片、商品促销标签等具有明显业务属性的组件,更适合放在相应业务模块中。
不要为了复用而复用。 如果两个组件只有表面相似,但业务规则完全不同,强行合并反而会增加维护成本。
坑三:所有页面都直接使用本地缓存
uni.setStorageSync() 和 uni.getStorageSync() 适合部分本地持久化场景,但不应该被当成全局状态管理的替代品。
推荐区分:
这样可以减少数据不一致问题。
坑四:忽略异常状态
一个完整的页面,不应该只有成功状态。
建议至少考虑:
页面状态
├── 加载中
├── 加载成功
├── 空数据
├── 请求失败
├── 未登录
└── 无权限
对于分页列表,还要考虑加载更多、没有更多数据和重复请求等情况。
坑五:没有统一的环境配置
开发、测试和生产环境的 API 地址、应用标识等配置应当明确区分。
建议:
需要特别强调:小程序客户端代码最终可以被获取,因此不能把真正的服务端密钥当成普通前端配置。
坑六:忽略请求竞态问题
例如,用户快速切换商品分类时,先发出的请求可能晚于后发出的请求返回,导致旧数据覆盖新数据。
处理方式包括:
坑七:缺少统一的代码规范
项目初期只有一两个人时,代码规范看起来不重要。
但当多人协作时,命名、目录结构、错误处理和代码风格不一致,会显著增加维护成本。
建议统一:
坑八:只在开发工具中测试
上线前至少要验证:
对于跨端项目,还需要在实际计划发布的平台上进行测试。
八、推荐的团队开发流程
如果是多人协作项目,可以采用下面的开发流程。
需求评审
↓
页面与接口设计
↓
模块划分
↓
基础架构搭建
↓
组件与接口开发
↓
业务页面开发
↓
联调与测试
↓
性能检查
↓
代码审查
↓
构建与发布
一个实用的开发顺序
第一阶段:建立基础设施
完成项目初始化、环境配置、路由、请求封装、状态管理和基础组件。
第二阶段:确定业务规范
统一接口返回结构、错误处理、页面状态、组件命名及代码规范。
第三阶段:按模块开发
例如,订单模块由固定成员负责接口、页面和相关业务组件,避免多人同时修改大量共享文件。
第四阶段:持续集成与测试
在每次提交或合并代码时,执行必要的静态检查、构建和测试。
第五阶段:发布前检查
检查构建产物、分包体积、环境配置、接口权限和核心业务流程。
对于较大的团队,可以进一步建立自动化测试、代码审查清单和版本发布流程。
九、项目架构设计的几个最终建议
如果从零开始搭建一个 uni-app 小程序项目,我会优先关注以下五件事:
我的建议是:不要一开始就追求复杂的架构,而是优先解决真实业务中的重复、耦合和维护问题。
对于中小型项目,清晰的模块划分、稳定的请求封装和合理的分包设计,往往比引入大量抽象层更有价值。
随着项目规模扩大,再逐步引入更完善的类型约束、业务模块隔离、自动化测试和持续集成机制。
官方文档参考
如果要把这套架构真正用于项目开发,我建议下一步直接确定项目类型。 不同类型的小程序,目录结构和业务模块设计会有明显区别:
确定项目类型后,就可以进一步设计一套更贴近实际开发的目录结构,并补全 pages.json、Pinia、API 请求封装和业务模块的可运行示例。
















































































