使用 uni-app 开发微信小程序,最大的吸引力就是可以通过 Vue 的开发方式编写跨平台应用,一套代码能够适配微信小程序、H5,甚至 App。但真正开始做项目后会发现,跨平台开发并不意味着所有平台的行为完全一致。页面生命周期、路由方式、样式布局、登录授权、网络请求和构建流程,都有各自需要注意的地方。
尤其是使用 Vue 3、Pinia 和微信小程序原生能力开发业务应用时,很多问题并不是代码写错了,而是对运行环境的理解不够准确。
这篇文章结合实际开发经验,整理 uni-app 开发微信小程序时常见的问题、解决思路以及一些值得提前规划的工程实践。
一、先理解 uni-app 与微信小程序的关系
uni-app 使用 Vue 风格的开发方式,将页面、组件和业务代码编译成不同平台所需的产物。开发微信小程序时,最终运行的并不是一个普通浏览器中的 Vue 应用,而是微信小程序运行环境中的页面和组件。
这意味着,不能简单地把 H5 开发经验完全照搬过来。
例如,H5 可以直接使用浏览器的 window、document 和 DOM 操作,而微信小程序没有完整的浏览器 DOM 环境。某些 npm 包即使可以正常安装,也不一定能够在小程序中运行。
开发时需要明确三个边界:
- Vue 层:负责组件、响应式状态、计算属性和业务交互。
- uni-app 层:负责跨平台 API、页面导航、条件编译和统一开发体验。
- 微信小程序层:负责小程序页面运行机制、授权能力、分包、审核及平台特有限制。
遇到问题时,先判断属于哪一层,再决定应该修改业务代码、uni-app 配置,还是微信小程序平台配置,通常比盲目修改代码更高效。
二、条件编译:跨平台代码最容易失控的地方
uni-app 的一个重要特性是条件编译。开发者可以在同一份代码中,为不同平台保留不同的实现。
例如,某段代码只在微信小程序中执行:
<script setup lang="ts">
const login = async () => {
// #ifdef MP-WEIXIN
const result = await uni.login({
provider: 'weixin',
})
console.log(result.code)
// #endif
}
</script>
这里的 MP-WEIXIN 表示微信小程序平台。编译到其他平台时,对应代码块会根据条件编译规则进行处理。
常见的平台标识包括:
| 平台 | 条件编译标识 |
|---|---|
| 微信小程序 | MP-WEIXIN |
| 支付宝小程序 | MP-ALIPAY |
| H5 | H5 |
| App | APP-PLUS |
具体支持的平台和标识应以所使用的 uni-app 版本及编译模式为准。
踩坑一:条件编译不只是 JavaScript 的事情
条件编译不仅可以用于 JavaScript 或 TypeScript,也可以用于模板、样式和部分配置文件。
例如,不同平台使用不同的按钮:
<template>
<!-- #ifdef MP-WEIXIN -->
<button @tap="handleLogin">微信登录</button>
<!-- #endif -->
<!-- #ifdef H5 -->
<button @click="handleLogin">网页登录</button>
<!-- #endif -->
</template>
但是,不建议在项目中到处散落条件编译。平台差异过多时,业务逻辑会变得难以阅读,也容易出现某个平台编译成功、另一个平台却缺少函数或组件的情况。
更好的做法是将平台差异封装到独立模块中:
src/
├── api/
├── components/
├── composables/
├── stores/
├── utils/
│ ├── platform.ts
│ └── storage.ts
└── pages/
如果只是底层 API 不同,可以通过统一的工具函数封装;如果整套交互方式都不同,再考虑使用平台专属组件或条件编译。
实践建议:把条件编译控制在平台适配层,而不是让业务页面充满平台判断。
三、生命周期:页面显示不等于页面重新创建
Vue 3 组件使用 onMounted、onUnmounted 等生命周期,而 uni-app 还提供了 onLoad、onShow、onHide 等页面生命周期。
这两套生命周期解决的问题并不完全相同。
| 生命周期 | 主要用途 |
|---|---|
onLoad | 页面加载时获取路由参数、初始化页面级数据 |
onShow | 页面显示时刷新需要更新的数据 |
onReady | 页面首次渲染完成 |
onHide | 页面隐藏时暂停某些任务或清理临时状态 |
onUnload | 页面卸载时清理资源 |
onMounted | Vue 组件挂载完成 |
例如,从首页进入详情页,再返回首页时,首页通常可能只是重新显示,而不是重新创建。因此,不能把所有刷新逻辑都写在 onMounted 中。
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
const detailId = ref('')
const list = ref<any[]>([])
onLoad((options) => {
detailId.value = String(options?.id ?? '')
})
onShow(async () => {
await refreshList()
})
async function refreshList() {
// 重新获取需要刷新的数据
}
</script>
这里的思路是:路由参数在页面加载时解析,而列表刷新放在页面显示时处理。
踩坑二:返回上一页后,数据没有更新
典型场景是:首页进入编辑页,修改内容后返回首页,但首页显示的还是旧数据。
如果首页没有被销毁,onMounted 就不会再次执行。此时可以根据业务需要,在 onShow 中重新获取数据,或者在编辑成功后更新 Pinia 中的共享状态。
两种方案的取舍:
- 数据变化频繁、需要与服务器保持一致:返回页面时重新请求。
- 修改结果明确、希望减少请求:在编辑成功后更新共享状态。
- 数据量较大、页面恢复成本高:考虑缓存数据,同时用版本号或更新时间判断是否需要刷新。
需要注意,onShow 可能被多次触发,不应该在里面无条件执行大量请求。可以使用缓存时间、请求去重或者状态标记,避免重复加载。
四、页面路由:不能把微信小程序当成普通 Vue Router 项目
在常规 Vue Web 项目中,我们经常使用 Vue Router 的 router.push() 跳转页面。而 uni-app 的页面导航通常使用 uni.navigateTo、uni.redirectTo、uni.switchTab 等 API。
例如:
uni.navigateTo({
url: '/pages/detail/index?id=123',
})
接收参数:
import { onLoad } from '@dcloudio/uni-app'
onLoad((options) => {
console.log(options?.id)
})
需要注意,页面路径必须与项目页面配置相匹配。页面未正确注册、路径拼写错误或者把普通页面当作 TabBar 页面跳转,都可能导致导航失败。
踩坑三:TabBar 页面跳转失败
微信小程序中,TabBar 页面和普通页面的导航行为不同。
常用导航 API 的区别如下:
| API | 适用场景 |
|---|---|
uni.navigateTo | 打开普通页面,保留当前页面 |
uni.redirectTo | 关闭当前页面,跳转到普通页面 |
uni.switchTab | 切换到已配置的 TabBar 页面 |
uni.navigateBack | 返回上一页面 |
uni.reLaunch | 关闭页面栈并重新打开指定页面 |
例如,首页是 TabBar 页面时,应该使用:
uni.switchTab({
url: '/pages/index/index',
})
而不是直接使用 navigateTo。
此外,switchTab 不适合用于携带普通 URL 查询参数。若需要向 TabBar 页面传递共享状态,可以通过 Pinia、全局状态或其他适合当前项目的状态传递方式实现。
踩坑四:页面参数不要直接当作可信数据
页面参数通常来自 URL 或用户操作,类型可能是字符串,也可能缺失。不要假设参数一定存在,更不要直接将未经校验的参数用于敏感业务操作。
例如:
const id = String(options?.id ?? '')
if (!id) {
uni.showToast({
title: '参数错误',
icon: 'none',
})
return
}
客户端参数校验主要改善交互体验,真正涉及权限、用户身份和数据归属的检查仍然必须由服务端完成。
五、请求封装:不要让每个页面都直接调用接口
项目刚开始时,很多人会直接在页面中调用 uni.request。页面少时问题不大,但随着业务增加,很容易出现以下情况:
- 每个页面都重复拼接 API 地址。
- 登录令牌的读取和注入逻辑分散。
- HTTP 状态码与业务错误码处理不一致。
- 网络异常提示重复出现。
- 后端字段调整后,需要修改大量页面。
更合理的做法是统一封装请求层。
下面是一个简化的 TypeScript 示例:
// src/utils/request.ts
const BASE_URL = 'https://api.example.com'
interface RequestOptions {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, any>
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) => {
uni.request({
url: `${BASE_URL}${options.url}`,
method: options.method ?? 'GET',
data: options.data,
header: {
'content-type': 'application/json',
...options.header,
},
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP 错误:${res.statusCode}`))
return
}
const body = res.data as ApiResponse<T>
if (body.code !== 0) {
reject(new Error(body.message || '业务请求失败'))
return
}
resolve(body.data)
},
fail: (error) => {
reject(error)
},
})
})
}
这个封装假设后端统一返回 { code, message, data },并约定 code === 0 表示业务成功。实际项目必须根据自己的后端协议调整,不能直接套用这个约定。
业务层就可以变得更加简单:
// src/api/user.ts
import { request } from '@/utils/request'
export function getUserInfo() {
return request<{
id: string
nickname: string
}>({
url: '/user/profile',
method: 'GET',
})
}
页面只负责展示数据,而不是重复处理请求细节。
正式项目还需要继续完善令牌注入、令牌过期处理、请求取消或去重、超时策略以及错误提示。尤其是登录失效处理,应该避免多个并发请求同时触发重复跳转。
踩坑五:小程序请求域名必须符合平台要求
在开发者工具中,某些项目可以通过关闭域名校验进行调试,但这不代表上线后也能访问同样的接口。
微信小程序正式环境通常要求网络请求使用已配置的合法域名,并满足相应的 HTTPS 等平台要求。具体规则应以微信公众平台当前的网络配置规范为准。
部署前至少检查:
- API 域名是否配置到对应的合法域名列表。
- HTTPS 证书是否有效。
- 服务端是否正确处理跨域或来源相关限制(如果同时提供 H5 服务)。
- 文件上传、下载和 WebSocket 是否分别配置了对应域名。
- 正式环境是否误用了
localhost、局域网 IP 或开发测试地址。
特别注意:小程序中的 localhost 指向当前运行环境,而不是你开发电脑上的后端服务器。真机调试时,这类问题尤其常见。
六、微信登录:前端拿到 code,不代表用户已经登录
微信小程序登录是另一个容易产生误解的地方。
uni.login() 的主要作用是获取登录凭证 code。它并不直接返回可以长期使用的业务登录令牌,也不意味着服务端已经完成了用户身份认证。
常见的登录流程是:
小程序前端
│
│ uni.login()
▼
获取临时 code
│
▼
发送 code 到业务后端
│
▼
后端调用微信服务端接口
│
▼
验证并识别微信用户
│
▼
建立业务会话并返回业务 token
│
▼
前端保存 token
前端示例:
export async function wxLogin() {
const loginResult = await uni.login({
provider: 'weixin',
})
if (!loginResult.code) {
throw new Error('获取微信登录凭证失败')
}
return request<{ token: string }>({
url: '/auth/wechat/login',
method: 'POST',
data: {
code: loginResult.code,
},
})
}
这里的 request 指前面定义的统一请求函数。
服务端应当使用自己的安全凭据向微信服务端验证登录凭证,再按照业务规则建立用户身份与会话。微信应用密钥等敏感信息不能放在小程序前端代码中。
另外,code 是临时凭证,不应该作为长期登录令牌保存。业务 token 的有效期、刷新机制、退出登录和服务端会话失效策略,都应该由业务系统明确设计。
踩坑六:登录和用户资料授权不是一回事
获取登录凭证、判断用户是否登录,以及获取用户资料,是不同的业务环节。
不要在应用启动时就强制要求用户授权所有资料。对于昵称、头像等非必要信息,应当结合实际功能,在用户明确需要相应能力时再发起交互。
对于手机号等敏感能力,也应遵循微信平台当前的接口要求和用户授权流程。前端拿到相关凭证后,仍需要后端按照官方流程完成处理。
合理的登录体系通常还需要区分:
- 未登录用户。
- 已登录但尚未完善资料的用户。
- 已完成资料填写的用户。
- 登录状态过期的用户。
通过 Pinia 管理用户状态,可以让多个页面共享登录信息,但不能把 Pinia 中的状态当成服务端授权的依据。
七、Pinia 状态管理:页面刷新和应用重启要区别对待
在 Vue 3 项目中,Pinia 是常见的状态管理方案。对于小程序,适合使用 Pinia 管理用户信息、应用设置、购物车数据以及跨页面共享的状态。
例如:
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref<{
id: string
nickname: string
} | null>(null)
function setToken(value: string) {
token.value = value
}
function clearUser() {
token.value = ''
userInfo.value = null
}
return {
token,
userInfo,
setToken,
clearUser,
}
})
这个 Store 负责管理运行时状态,但需要注意:Pinia 状态并不天然等于持久化数据。
如果应用重启,内存中的状态可能重新初始化。因此,需要跨启动保存的数据,应根据实际需求同步到本地存储,并在启动时恢复。
例如:
const TOKEN_KEY = 'access_token'
export function saveToken(token: string) {
uni.setStorageSync(TOKEN_KEY, token)
}
export function loadToken() {
return uni.getStorageSync(TOKEN_KEY) || ''
}
export function clearToken() {
uni.removeStorageSync(TOKEN_KEY)
}
登录成功后,需要同步更新 Pinia 和本地存储;退出登录时,则应同时清理两者。
但是,不建议将所有 Store 数据一股脑写入本地存储。缓存的数据越多,越容易出现过期状态、数据结构升级和不同页面状态不一致的问题。
实践中可以区分:
- 只需要在当前运行期间存在的状态:保留在 Pinia。
- 需要跨页面共享且可以重新获取的数据:优先使用 Store 管理。
- 需要跨启动保存的少量设置:使用本地存储。
- 关键业务数据:以服务端为准,不能依赖客户端缓存保证正确性。
八、样式兼容:H5 正常不代表小程序正常
uni-app 使用 Vue 风格的模板和 CSS,但不同平台的布局和组件实现存在差异。
开发中经常遇到的问题包括:
position: fixed在特定页面结构中表现不符合预期。- 页面高度与安全区域计算错误。
- 长文本导致 Flex 布局溢出。
- 某些 CSS 属性或选择器在目标平台不受支持。
- H5 使用的 DOM 结构和浏览器 API 无法在小程序中复用。
踩坑七:页面高度与安全区域
手机屏幕并不等于页面可用区域。顶部状态栏、胶囊按钮和底部安全区域,都可能影响布局。
不建议把所有设备的顶部导航高度写死为同一个数值。对于自定义导航栏,可以根据 uni.getSystemInfoSync() 或适用的系统信息 API 获取设备信息,并结合 uni.getMenuButtonBoundingClientRect() 计算胶囊按钮的位置。
底部固定按钮则要考虑安全区域:
.bottom-action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #ffffff;
}
这里的 env(safe-area-inset-bottom) 是否生效,需要在目标平台和真机上验证。如果项目采用自定义底部导航,还应避免与系统 TabBar 或页面滚动区域相互覆盖。
踩坑八:Flex 子元素为什么会被撑开
有时一个卡片内部的长标题会把布局挤坏,即使父元素已经设置了 display: flex。
这往往与 Flex 子元素默认的最小尺寸有关。可以尝试给需要收缩的子元素设置:
.card-content {
flex: 1;
min-width: 0;
}
.card-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
对于多行文本,还需要根据目标平台验证多行省略方案是否兼容。不要只在浏览器里测试后就认为所有小程序设备表现一致。
实践建议:布局类问题优先在微信开发者工具和真实手机上验证,尤其是自定义导航、底部固定按钮、长列表和复杂弹窗。
九、组件通信与弹窗:不要把所有逻辑都堆进页面
当页面功能越来越多时,常见做法是将弹窗、提示框、选择器和业务卡片拆分成独立组件。
例如,一个通用弹窗可以通过 props 控制显示状态,通过事件通知父组件用户操作:
<script setup lang="ts">
defineProps<{
visible: boolean
title: string
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
</script>
<template>
<view v-if="visible" class="dialog-mask">
<view class="dialog">
<text>{{ title }}</text>
<view class="dialog-actions">
<button @tap="emit('close')">取消</button>
<button @tap="emit('confirm')">确定</button>
</view>
</view>
</view>
</template>
这个示例展示的是组件事件通信的基本思路。实际使用时还需要补充样式、遮罩点击规则、键盘处理和无障碍体验。
踩坑九:弹窗关闭了,但内部状态没有重置
如果组件通过 v-if 创建和销毁,内部状态通常会随组件实例销毁而重置;如果通过 v-show 仅切换显示状态,组件实例会保留,内部表单和临时数据也可能继续存在。
因此,需要根据业务决定使用哪种方式。
- 表单弹窗:关闭时明确决定是否保留输入内容。
- 图片预览:重新打开时应更新图片地址和当前索引。
- 全局提示弹窗:避免多个业务页面同时触发导致状态覆盖。
- 异步操作弹窗:在关闭或页面卸载时,妥善处理未完成的任务。
如果项目已经使用 Pinia 管理全局弹窗,可以把弹窗队列、确认回调和显示状态统一管理,但不要让全局弹窗组件直接依赖某一个具体业务页面的数据结构。
十、分包与性能优化:不要等到体积超限才处理
小程序有主包体积和分包加载方面的限制,具体限制会随着平台规则变化,应以微信官方当前文档为准。
项目早期页面少,所有页面都放在主包中很方便;随着业务增长,首页、活动页、管理页面和复杂组件都可能进入主包,导致包体积增长、首屏加载变慢。
可以通过分包将低频访问的页面拆分出去。例如:
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页"
}
}
],
"subPackages": [
{
"root": "pages-user",
"pages": [
{
"path": "profile/index",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "settings/index",
"style": {
"navigationBarTitleText": "设置"
}
}
]
}
]
}
这只是微信小程序分包配置结构的示例。实际 uni-app 项目应按所用版本和配置方式放置页面,并确认编译后的 app.json 中分包路径正确。
分包时应注意:
- 首页及启动必需的资源尽量留在主包。
- 低频访问的业务页面可以放入分包。
- 复用组件、公共代码和静态资源要考虑主包与分包之间的依赖关系。
- 不要把大量图片直接打包进主包。
- 构建后检查实际产物,而不是仅凭源代码目录判断包体积。
性能优化也不仅仅是分包。对于列表页面,还应注意分页加载、图片尺寸、重复请求、长列表渲染以及不必要的响应式数据。
例如,列表页可以使用分页请求,而不是一次性获取全部记录:
const page = ref(1)
const pageSize = 20
const list = ref<any[]>([])
const loading = ref(false)
const finished = ref(false)
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
const result = await fetchList({
page: page.value,
pageSize,
})
list.value.push(...result.items)
page.value += 1
if (result.items.length < pageSize) {
finished.value = true
}
} finally {
loading.value = false
}
}
这里的 fetchList 是项目自己的接口函数。生产环境还应处理请求异常、下拉刷新、并发加载以及页面离开后的状态管理,避免加载失败后无法重试。
十一、开发、测试与发布:工具链问题也属于开发的一部分
uni-app 项目常见的开发方式包括 HBuilderX 和 CLI。不同项目的插件、构建配置和依赖管理方式可能不一样,尤其在 Vue 3、TypeScript 和第三方组件库混合使用时,版本兼容性值得提前确认。
实际开发中建议注意以下几点:
第一,固定并记录工具链版本。 对于团队项目,应明确 Node.js、包管理器、uni-app 相关依赖及构建工具的版本,避免因为某个人升级环境导致项目无法复现构建。
第二,区分开发环境和正式环境。 API 地址、日志级别和调试开关应集中管理。不要依赖手工修改代码切换环境,否则很容易把测试地址打进正式包。
第三,至少完成一次真机测试。 开发者工具可以帮助调试,但网络请求、授权流程、键盘、安全区域和部分原生能力仍可能在真实设备上出现差异。
第四,发布前检查微信公众平台配置。 包括合法请求域名、业务域名(如果业务使用)、隐私保护相关配置、用户授权说明和实际使用到的微信开放能力。
第五,处理错误和空状态。 接口超时、无网络、权限拒绝、数据为空、重复点击和登录过期,都应有明确的交互反馈。
第六,检查编译产物。 尤其是分包、条件编译、静态资源路径和第三方库兼容性,不能只因为开发环境运行正常就认定发布包没有问题。
十二、总结
uni-app 的优势在于跨平台开发效率,但真正稳定的小程序项目,仍然需要理解微信小程序自身的运行机制。
从实践角度来看,最值得提前处理的并不是某个具体的 API,而是整个项目的工程结构:
- 使用条件编译隔离平台差异。
- 正确区分 Vue 生命周期与 uni-app 页面生命周期。
- 统一处理页面导航、请求、登录和状态管理。
- 将持久化状态与运行时状态分开。
- 在真实设备上验证样式和授权行为。
- 通过分包、分页和资源优化控制性能。
- 在发布前检查网络域名、权限配置和构建产物。
对于使用 Vue 3、Pinia 开发微信小程序的项目来说,越早建立清晰的模块边界,后续新增业务和适配其他平台时就越不容易陷入重复代码与平台兼容问题。
最后记住一点:uni-app 解决的是跨平台开发的问题,不是消除平台差异的问题。 真正高效的开发方式,是把差异识别出来、封装起来,并通过持续的真机测试验证它们。
