首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
74
篇与
» admin
的结果
2026-07-28
uni-app 打包发布与热更新实战:云打包、证书管理、审核与 wgt 热更
开发完成只是半程,把 uni-app 项目发布到微信、H5、App 三个渠道,每条路都有独立的流程和坑:证书管理、各平台审核规则、App 热更新。本文讲透发布链路的完整实操。一、发布全景渠道产物发布方式更新机制微信小程序wxapkg微信后台上传 + 提审冷启动异步更新H5静态文件部署到服务器/CDN刷新即最新App(Android)apk / aab应用市场/官网分发整包更新 + wgt 热更新App(iOS)ipaApp Store + TestFlight整包更新 + wgt(受限)二、微信小程序发布发布流程# 1. HBuilderX 发行 → 小程序-微信,生成编译产物 # 产物在 unpackage/dist/build/mp-weixin # 2. 用微信开发者工具打开该目录 # 3. 工具栏「上传」→ 填版本号和备注 → 上传为开发版本 # 4. 微信公众平台 → 版本管理 → 提交审核 → 审核通过 → 全量发布也可以用 miniprogram-ci 做命令行自动化上传:// scripts/upload-mp.js const ci = require('miniprogram-ci') async function upload() { const project = new ci.Project({ appid: 'wx_xxxxxxxx', type: 'miniProgram', projectPath: './unpackage/dist/build/mp-weixin', privateKeyPath: './private.key' // 微信后台下载的上传密钥 }) await ci.upload({ project, version: require('../package.json').version, desc: 'release: ' + new Date().toLocaleString(), setting: { es6: true, minify: true } }) }配到 package.json scripts 里,CI/CD 流水线一键发布。审核避坑清单微信审核被拒的高频原因,发布前自查:类目与功能不符:有交易功能但类目没选电商;有社区内容但没选社交类目诱导分享:分享得奖励、强制分享解锁功能的文案全部要清掉测试账号:提审备注里必须给一个可登录的测试账号(有登录功能时)隐私接口声明:用了 getLocation、chooseAddress、相机等隐私接口,必须在 app.json 的 requiredPrivateInfos 声明并申请权限虚拟支付:iOS 端虚拟商品(会员、课程)不能直接用微信支付,要走 IAP 或隐藏入口完整可体验:每个 tab 每个入口都要能点,"敬请期待"的空页面是高频被拒项小程序的静默更新机制小程序新版本发布后,已打开的用户用的仍是旧版,下次冷启动时检查更新。强制立即更新的方案:// App.vue const updateManager = uni.getUpdateManager() updateManager.onCheckForUpdate((res) => { console.log('是否有新版本:', res.hasUpdate) }) updateManager.onUpdateReady(() => { uni.showModal({ title: '更新提示', content: '新版本已准备好,是否重启应用?', success(res) { if (res.confirm) { updateManager.applyUpdate() // 立即应用新版本并重启 } } }) }) updateManager.onUpdateFailed(() => { uni.showToast({ title: '更新失败,请删除小程序重新打开', icon: 'none' }) })三、H5 发布构建与部署# HBuilderX 发行 → 网站-H5,或 CLI npx uni build # 产物 unpackage/dist/build/h5部署要点:# Nginx 配置:history 路由需要 fallback server { listen 443 ssl; server_name h5.example.com; root /var/www/h5; index index.html; location / { try_files $uri $uri/ /index.html; } # 静态资源长缓存(文件名带 hash) location /static/ { expires 1y; add_header Cache-Control "public, immutable"; } }跨域处理:H5 调后端接口要么后端开 CORS,要么 Nginx 加反向代理把 /api 转发到服务端,生产环境强烈推荐代理方案,前端请求同源路径,省掉 CORS 一切麻烦。微信 H5 特殊处理在微信内打开的 H5 要做微信 JS-SDK 授权(自定义分享卡片、微信支付):公众号后台配 JS 接口安全域名后端实现签名接口(access_token → jsapi_ticket → signature)前端 wx.config 注入配置后才能调分享等 API四、App 打包:云打包与证书证书体系认知Android:正式签名证书(keystore):自己用 keytool 生成,务必自己保管,丢了就无法给老用户覆盖升级云打包时可以用 DCloud 公共测试证书(仅测试),正式包必须用自己的证书# 生成 Android 证书 keytool -genkey -alias myapp -keyalg RSA -keysize 2048 -validity 36500 \ -keystore myapp.keystoreiOS:需要 Apple 开发者账号(个人版 $99/年)证书(p12)+ 描述文件(mobileprovision)+ Bundle ID 三件套发布 App Store 需要 distribution 证书;真机调试需要 development 证书云打包实操HBuilderX → 发行 → 原生App-云打包 → 选择平台(Android/iOS) → 填证书信息(Android: keystore + 密码 + 别名) → 勾选原生插件(推送、地图、支付等模块) → 打包云打包排队可能要几分钟到半小时。打完的产物:Android:apk 直接可装;上架 Google Play 需要转 aabiOS:ipa 需要 TestFlight 或 XCode 上传(Windows 用户的经典痛点,需要 Mac 或云打包的"越狱包/测试包"过渡)Android 各应用市场分发国内市场(华为、小米、OPPO、vivo、应用宝)每个都要单独注册开发者、上传软著或承诺函、单独提审。实际操作建议:官网直接放 apk 下载(最快触达存量用户)主流市场覆盖上架(获取新用户的主要渠道)各市场对隐私政策、权限用途说明的审查口径不同,逐个过隐私合规(上架必过项)国内 App 上架的硬性要求:首次启动弹《隐私政策》,用户同意前不得初始化任何采集 SDK(uni-app 里注意 App.vue onLaunch 的时机,把需要合规前置的 SDK init 放到同意回调之后)申请权限(定位、相册、相机)必须先弹用途说明在应用市场后台填写隐私采集清单五、wgt 热更新:不发整包的升级App 发版要走市场审核,周期长。纯前端代码(页面、JS、样式)的修改可以打 wgt 包热更新,只有原生层变更(新增原生插件、修改 SDK 配置)才必须整包升级。服务端版本接口// GET /app/version?platform=android&version=1.2.0 { "code": 0, "data": { "forceUpdate": false, "wgtUrl": "https://cdn.example.com/app/1.2.1.wgt", "apkUrl": "https://cdn.example.com/app/app-1.3.0.apk", "updateLog": "1. 修复已知问题\n2. 优化性能" } }wgtUrl 有值走热更,只有 apkUrl 走整包(判断逻辑:版本号第一位变化 → 整包,否则 wgt)。客户端热更新实现// App.vue export default { onLaunch() { this.checkUpdate() }, methods: { async checkUpdate() { // #ifdef APP-PLUS const { version, platform } = await this.getAppInfo() try { const res = await request.get('/app/version', { platform: platform === 'android' ? 'android' : 'ios', version }) if (this.compareVersion(res.wgtVersion, version) > 0) { this.doWgtUpdate(res) } else if (this.compareVersion(res.version, version) > 0) { this.doApkUpdate(res) // 大版本整包 } } catch (e) { // 静默失败,不影响启动 } // #endif }, getAppInfo() { return new Promise((resolve) => { plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => { resolve({ version: widgetInfo.version, // wgt 版本 platform: uni.getSystemInfoSync().platform }) }) }) }, compareVersion(v1, v2) { const a = v1.split('.').map(Number) const b = v2.split('.').map(Number) for (let i = 0; i < 3; i++) { if (a[i] > b[i]) return 1 if (a[i] < b[i]) return -1 } return 0 }, doWgtUpdate({ wgtUrl, updateLog, forceUpdate }) { uni.showModal({ title: `发现新版本`, content: updateLog, showCancel: !forceUpdate, success: (res) => { if (!res.confirm) return uni.showLoading({ title: '更新中...' }) uni.downloadFile({ url: wgtUrl, success: (downloadRes) => { if (downloadRes.statusCode !== 200) return plus.runtime.install( downloadRes.tempFilePath, { force: false }, () => { uni.hideLoading() uni.showModal({ title: '更新完成', content: '重启应用后生效', showCancel: false, success: () => plus.runtime.restart() }) }, (e) => { uni.hideLoading() uni.showToast({ title: '安装失败', icon: 'none' }) } ) }, fail: () => { uni.hideLoading() uni.showToast({ title: '下载失败', icon: 'none' }) } }) } }) } } }wgt 包的生成与发布HBuilderX → 发行 → 原生App-制作应用wgt包生成 .wgt 文件上传到 CDN,更新服务端版本接口的 wgtVersion 字段即可。wgt 热更的纪律:原生层改动绝不发 wgt:新增了原生插件、改了 manifest 的 SDK 配置,wgt 升级后必崩wgt 版本号独立递增:和应用整包版本分开管理,plus.runtime.getProperty 拿到的是 wgt 版本iOS 慎用:Apple 对热更新口径收紧,wgt 仅限 JS 资源修复,且不保证过审安全;能走 App Store 就走 App Store六、版本管理建议package.json 的 version 作为唯一真实源,小程序、App 版本号统一从这读取云打包产物按 app-android-v1.2.1.apk 命名归档到对象存储,出问题可回滚服务端保留接口灰度能力:wgtUrl 按用户 ID 取模灰度放量,热更出问题影响面可控小程序保留"版本回退"操作权限:微信后台可在 24 小时内回退到上一版总结小程序发布链路:上传 → 提审 → 全量,miniprogram-ci 可自动化;静默更新用 UpdateManager 主动提示Android 证书自己生成自己保管,丢了等于丢掉全部老用户;iOS 三件套缺一不可合规是上架硬门槛:隐私弹窗前不初始化任何采集 SDKwgt 热更新只适用于前端层变更,原生变更必须整包版本号统一管理 + CDN 归档 + 灰度放量,是热更安全的最后防线发布链路琐碎且各平台规则持续变动,本文流程可作为 checklist,每次发版对照走一遍。
2026年07月28日
5 阅读
0 评论
0 点赞
2026-07-27
uni-app 登录授权全流程实战:微信登录、手机号验证码与头像昵称填写
微信登录体系这几年持续收紧:getUserProfile 收回了、头像昵称获取改填写了、手机号要企业认证了。网上大量教程已经过时。本文基于当前有效的接口体系,实现一套完整的登录授权方案,并覆盖 H5 与 App 端的差异。一、当前微信登录接口的正确认知先纠正几个过时认知:接口现状wx.getUserInfo已回收,返回匿名数据wx.getUserProfile已于 2022 年后收紧,新版本基本不可用头像昵称官方推荐用「头像昵称填写能力」(open-type 按钮 + input type=nickname)getPhoneNumber可用,但需要企业主体小程序,且按次收费wx.login 换 openid正常可用,这是静默登录的基础结论:openid 换 token 做静默登录 + 头像昵称用户主动填 + 手机号授权按钮,是当前唯一合规的组合。二、静默登录:openid 链路用户打开小程序不需要任何操作就完成注册/登录:前端 wx.login 获取 code → POST /auth/silent-login { code } → 后端 code2session 换 openid + session_key → 后端按 openid 查/建用户 → 签发 token → 前端存 token,静默登录完成// stores/user.js async silentLogin() { const [err, res] = await uni.login({ provider: 'weixin' }) if (err) return const data = await request.post('/auth/silent-login', { code: res.code }) this.token = data.accessToken this.isNewUser = data.isNewUser // 后端标记:openid 没绑定过手机号 }后端 code2session 注意点:code 只能用一次、5 分钟有效;session_key 千万不能下发到前端(安全隐患,微信明确禁止);unionid 需要绑定开放平台才能拿到,多端账号打通靠它。三、头像昵称填写:官方推荐方案微信现在要求用户"主动填写"头像和昵称,配套了两个专用 UI 能力:头像:button open-type="chooseAvatar"<template> <button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar"> <image class="avatar" :src="avatarUrl || '/static/default-avatar.png'" mode="aspectFill" /> <text class="avatar-tip">点击选择头像</text> </button> </template> <script setup> import { ref } from 'vue' const avatarUrl = ref('') function onChooseAvatar(e) { // 临时文件路径,需要上传到自己的服务器换取永久 URL avatarUrl.value = e.detail.avatarUrl } </script>关键:e.detail.avatarUrl 是临时路径,小程序重启即失效,必须立刻上传:async function onChooseAvatar(e) { const tempPath = e.detail.avatarUrl uni.showLoading({ title: '上传中' }) try { const url = await uploadFile(tempPath) // 走自己的上传接口 avatarUrl.value = url } finally { uni.hideLoading() } }昵称:input type="nickname"<template> <view class="form-item"> <text class="label">昵称</text> <input v-model="nickname" type="nickname" placeholder="请输入昵称" @blur="onNicknameBlur" /> </view> </template> <script setup> import { ref } from 'vue' const nickname = ref('') function onNicknameBlur(e) { // type=nickname 的 input 在部分机型 v-model 同步不及时,blur 时兜底读取 nickname.value = e.detail.value } </script>type="nickname" 会唤起微信官方的昵称快捷填写键盘(自动带入微信昵称),这是目前唯一合规的获取昵称方式。提交完善资料async function saveProfile() { if (!avatarUrl.value) return uni.showToast({ title: '请选择头像', icon: 'none' }) if (!nickname.value.trim()) return uni.showToast({ title: '请填写昵称', icon: 'none' }) await request.post('/me/profile', { avatar: avatarUrl.value, nickname: nickname.value }) userStore.fetchProfile() uni.showToast({ title: '保存成功' }) setTimeout(() => uni.navigateBack(), 800) }四、手机号授权:企业认证方案getPhoneNumber 需要 button 触发,且小程序必须完成微信认证(企业主体):<template> <button class="phone-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone" > 授权手机号登录 </button> </template> <script setup> async function onGetPhone(e) { const detail = e.detail // 用户拒绝授权 if (!detail.code) { return uni.showToast({ title: '您取消了授权', icon: 'none' }) } // 新版接口:detail.code 交给后端,后端调 getuserphonenumber 换手机号 const data = await request.post('/auth/bind-phone', { code: detail.code }) userStore.userInfo = data.user uni.showToast({ title: '登录成功' }) } </script>当前流程(2023 之后的版本):用户点击授权按钮 → e.detail.code(动态令牌) → POST /auth/bind-phone { code } → 后端用 code + access_token 调微信接口换真实手机号 → 绑定用户,返回更新后的用户信息注意事项:个人主体小程序用不了这个能力,认证费用 300 元/年,手机号验证按次计费(约 0.03 元/次)旧版 encryptedData + iv 解密方案还能用但不推荐,code 方案更安全且免维护密钥计费压力大的场景可以改做短信验证码登录(自建),绕开微信计费短信验证码登录(自建方案)<template> <view class="sms-login"> <view class="input-row"> <input v-model="phone" type="number" maxlength="11" placeholder="手机号" /> </view> <view class="input-row"> <input v-model="smsCode" type="number" maxlength="6" placeholder="验证码" /> <button class="sms-btn" :disabled="countdown > 0" @click="sendSms"> {{ countdown > 0 ? `${countdown}s后重试` : '获取验证码' }} </button> </view> <button class="login-btn" @click="loginBySms">登录</button> </view> </template> <script setup> import { ref, onUnmounted } from 'vue' const phone = ref('') const smsCode = ref('') const countdown = ref(0) let timer = null async function sendSms() { if (!/^1[3-9]\d{9}$/.test(phone.value)) { return uni.showToast({ title: '手机号格式错误', icon: 'none' }) } await request.post('/auth/sms/send', { phone: phone.value }) countdown.value = 60 timer = setInterval(() => { if (--countdown.value <= 0) clearInterval(timer) }, 1000) } async function loginBySms() { const data = await request.post('/auth/sms/login', { phone: phone.value, code: smsCode.value }) userStore.token = data.accessToken userStore.userInfo = data.user uni.reLaunch({ url: '/pages/index/index' }) } onUnmounted(() => timer && clearInterval(timer)) </script>后端要点:验证码 5 分钟有效、同一手机号 60 秒内不可重发、验证失败 5 次锁定、按手机号 + IP 双维度限流防刷。五、多端登录差异// utils/auth.js —— 收敛各端登录入口 export async function doLogin() { // #ifdef MP-WEIXIN const [err, res] = await uni.login({ provider: 'weixin' }) return request.post('/auth/wx-login', { code: res.code }) // #endif // #ifdef APP-PLUS // App 端:一键登录(运营商授权) const [loginErr, loginRes] = await uni.login({ provider: 'univerify' }) return request.post('/auth/univerify-login', { accessToken: loginRes.authResult.access_token, openid: loginRes.authResult.openid }) // #endif // #ifdef H5 // H5 端:微信公众号网页授权 const appId = 'wx_xxx' const redirect = encodeURIComponent(location.href) location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}` + `&redirect_uri=${redirect}&response_type=code&scope=snsapi_userinfo#wechat_redirect` // #endif }H5 回调页解析 code 换 token:// H5 授权回调页面 onLoad() { const code = new URLSearchParams(location.search).get('code') if (code) { const data = await request.post('/auth/h5-wx-login', { code }) userStore.token = data.accessToken } }六、登录拦截的优雅实现全局拦截而非每页手写判断。方案是封装路由跳转 + 页面 meta 声明:// pages.json 页面需要登录的加 custom 字段(或维护一个白名单数组) const LOGIN_REQUIRED = ['pages/cart/cart', 'pages/order/list'] // 重写跳转方法统一拦截 const originalNavigateTo = uni.navigateTo uni.navigateTo = function(options) { const path = options.url.split('?')[0].replace(/^\//, '') const userStore = useUserStore() if (LOGIN_REQUIRED.includes(path) && !userStore.isLoggedIn) { return originalNavigateTo({ url: `/pages/login/login?redirect=${encodeURIComponent(options.url)}` }) } return originalNavigateTo(options) }登录成功后回跳:async function handleLoginSuccess() { const redirect = decodeURIComponent( new URLSearchParams(location.search).get('redirect') || getCurrentPagesArgs('redirect') || '' ) uni.reLaunch({ url: redirect || '/pages/index/index' }) }总结静默登录靠 wx.login + code2session,session_key 留在服务端头像用 open-type="chooseAvatar",昵称用 type="nickname",临时文件必须立即上传手机号授权需要企业认证 + 计费,自建短信验证码是省钱替代登录入口用条件编译分端收敛,重写 navigateTo 做全局登录拦截常见过时方案自查:getUserProfile、encryptedData 解密、无企业认证却调 getPhoneNumber登录授权是合规重灾区,本文方案基于当前有效接口,建议每半年对照微信官方文档核对一次。
2026年07月27日
6 阅读
0 评论
0 点赞
2026-07-26
uni-app 页面路由与通信机制详解:跳转传参、EventChannel 与全局状态
uni-app 的页面机制和纯 Vue 项目差异很大:页面栈、tabBar 限制、生命周期钩子都带着小程序的影子。很多从 Web 转过来的开发者在"页面间怎么传值"这一步就开始踩坑。本文系统讲清 uni-app 的路由体系与页面通信的全部手段。一、uni-app 的路由本质uni-app 没有 vue-router,页面路由由 pages.json 声明,跳转靠 uni. API:// pages.json { "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/order/list", "style": {} }, { "path": "pages/order/detail", "style": {} } ], "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] } }第一个坑就在这里:tabBar 页面只能用 uni.switchTab 跳转,用 uni.navigateTo 会静默失败,这是新手最常见的"跳转没反应"原因。二、五种跳转 API 与页面栈API行为页面栈变化返回uni.navigateTo保留当前页,跳转新页push可返回uni.redirectTo关闭当前页,跳转新页replace不可返回当前页uni.reLaunch关闭所有页面,跳转清空后 push返回到首页uni.switchTab跳转 tabBar 页清空非 tab 页-uni.navigateBack返回上一页pop-页面栈上限是 10 层。连续 navigateTo 超过 10 层会报错。常见于"查看详情 → 查看用户 → 查看详情"的无限循环场景,解决方案是深链入口用 redirectTo 替代 navigateTo,或在 onShow 里检测栈深度做收敛:onShow() { const pages = getCurrentPages() if (pages.length >= 8) { uni.redirectTo({ url: '/pages/order/detail?id=' + this.id }) } }三、页面传参的三种方式方式一:URL 查询参数(最常用)uni.navigateTo({ url: '/pages/order/detail?id=1001&from=list' })// 目标页面:onLoad 接收 onLoad(options) { // options: { id: '1001', from: 'list' } // 注意:所有值都是 string this.orderId = Number(options.id) }两个注意点:参数会被编码:对象、数组必须先 encodeURIComponent(JSON.stringify(obj)),接收方再解码解析tabBar 页面接收参数:switchTab 不支持 URL 传参,只能走全局状态或事件// 传对象 uni.navigateTo({ url: '/pages/filter/result?query=' + encodeURIComponent(JSON.stringify(filter)) }) // 接收 onLoad(options) { this.query = JSON.parse(decodeURIComponent(options.query)) }方式二:EventChannel 事件通道(双向通信)navigateTo 的 events 与 success 返回的 EventChannel 可以让两个页面直接通信,适合"选择收货地址"这类带回传数据的场景:// 订单页:打开地址选择页,监听选择结果 uni.navigateTo({ url: '/pages/address/select', events: { // 监听地址选择页抛回的事件 selectAddress(address) { this.address = address } }, success: (res) => { // 向地址选择页传递数据 res.eventChannel.emit('init', { selectedId: this.address?.id }) } })// 地址选择页 onLoad(options) { const eventChannel = this.getOpenerEventChannel() // 接收初始数据 eventChannel.on('init', (data) => { this.selectedId = data.selectedId }) this.eventChannel = eventChannel }, methods: { onSelect(address) { // 通知上一页并返回 this.eventChannel.emit('selectAddress', address) uni.navigateBack() } }相比全局状态,EventChannel 作用域仅限两个页面,不污染全局,是临时性页面间通信的最佳选择。方式三:全局状态(跨页面、持久化)跨页面返回时更新上一页数据一个高频场景:列表页 → 编辑页,编辑完成后返回列表页需要刷新。可以用 getCurrentPages() 直接操作上一页实例:// 编辑页:保存成功后 const pages = getCurrentPages() const listPage = pages[pages.length - 2] // 上一页实例 listPage.$vm.refreshList() // 调用上一页的方法 uni.navigateBack()这种写法直接、无依赖,但要克制使用——页面耦合加深后维护成本上升。更规范的做法是全局状态 + 上一页 onShow 时检查脏标记:// store/user.js(Pinia 方案) import { defineStore } from 'pinia' export const useAddressStore = defineStore('address', { state: () => ({ selectedAddress: null, dirty: false }) }) // 编辑页保存成功后 const store = useAddressStore() store.selectedAddress = address store.dirty = true // 列表页 onShow() { const store = useAddressStore() if (store.dirty) { this.refreshList() store.dirty = false } }四、全局状态管理选型uni-app 支持 Vuex 和 Pinia。Vue 3 项目无脑选 Pinia:// main.js import { createSSRApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.use(createPinia()) return { app } }// stores/cart.js import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ items: uni.getStorageSync('cart_items') || [] }), actions: { addItem(goods) { this.items.push(goods) // 小程序没有 localStorage,用 uni API 持久化 uni.setStorageSync('cart_items', this.items) } } })关键差异:uni-app 运行在小程序端时没有 localStorage,必须用 uni.setStorageSync / uni.getStorageSync 做持久化。Pinia 持久化插件默认走 localStorage,小程序端会直接报错,需要自定义 storage 适配器。另外两个轻量方案:globalData:App.vue 的 globalData 字段 + getApp().globalData,适合放极简的配置型数据uni.$emit / uni.$on:全局事件总线,跨页面通知(如登录成功后刷新多个页面),但要在 onUnload 里 $off 解绑,否则内存泄漏// App.vue export default { globalData: { theme: 'light', version: '2.1.0' } } // 任意页面 const app = getApp() console.log(app.globalData.theme)五、页面生命周期速查uni-app 页面生命周期在 Vue 钩子之外扩展了小程序特有的钩子:钩子触发时机典型用途onLoad页面加载,可接收参数解析跳转参数、首次请求onShow页面显示(含返回时)刷新数据、检查登录态onHide页面隐藏暂停定时器onReady初次渲染完成获取节点信息onReachBottom滚动到底部分页加载onPullDownRefresh下拉刷新刷新列表onShareAppMessage分享定义分享内容onLoad vs onShow 的选择:onLoad 只执行一次(携带参数),onShow 每次显示都执行。列表页的数据刷新逻辑放 onShow,参数解析放 onLoad。总结tabBar 页面只能 switchTab,页面栈上限 10 层URL 传参全是 string,对象要 JSON + encodeURIComponentEventChannel 是"选择页回传数据"的标准方案,作用域干净getCurrentPages() 操作上一页实例简单直接,Pinia + 脏标记更规范小程序端持久化必须用 uni.setStorageSync,Pinia 持久化插件需要适配下一篇我们讲网络请求封装——把 uni.request 改造成带拦截器、Token 无感刷新的企业级请求层。
2026年07月26日
5 阅读
0 评论
0 点赞
2026-07-25
uni-app 网络请求封装实战:拦截器、Token 无感刷新与并发控制
uni.request 是个原始 API:没有拦截器、不支持 Promise 链式调用(callback 风格)、错误处理要层层嵌套。任何像样的 uni-app 项目第一步都是封装请求层。本文从零实现一个生产级的 request 模块,覆盖拦截器、Token 无感刷新、并发请求控制与上传下载。一、基础封装:Promise 化 + 统一错误处理// utils/request.js const BASE_URL = 'https://api.example.com' const TIMEOUT = 10000 function request(options = {}) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: options.header || {}, timeout: TIMEOUT, success: (res) => { // HTTP 层成功 if (res.statusCode >= 200 && res.statusCode < 300) { // 业务层判断 const body = res.data if (body.code === 0) { resolve(body.data) } else { handleBizError(body) reject(body) } } else if (res.statusCode === 401) { // 交给 Token 刷新流程处理 refreshTokenAndRetry(options).then(resolve).catch(reject) } else { uni.showToast({ title: `服务异常(${res.statusCode})`, icon: 'none' }) reject(res) } }, fail: (err) => { // 网络层失败:断网、DNS、超时 uni.showToast({ title: err.errMsg.includes('timeout') ? '请求超时' : '网络连接失败', icon: 'none' }) reject(err) } }) }) }这里把三层错误分开处理:网络层(fail 回调)、HTTP 层(statusCode)、业务层(响应体 code),每层给用户不同的提示,排查问题时也能立刻定位层级。二、请求拦截:自动携带 Token// utils/auth.js export function getToken() { return uni.getStorageSync('access_token') || '' } export function setTokens(access, refresh) { uni.setStorageSync('access_token', access) uni.setStorageSync('refresh_token', refresh) } export function clearTokens() { uni.removeStorageSync('access_token') uni.removeStorageSync('refresh_token') }在 request 内统一注入:function request(options = {}) { // 白名单接口不携带 Token const whiteList = ['/auth/login', '/auth/captcha'] const needAuth = !whiteList.includes(options.url) const header = { 'Content-Type': 'application/json', ...(options.header || {}) } if (needAuth && getToken()) { header.Authorization = `Bearer ${getToken()}` } return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, header, // ...同上 }) }) } // 便捷方法 request.get = (url, data) => request({ url, method: 'GET', data }) request.post = (url, data) => request({ url, method: 'POST', data }) request.put = (url, data) => request({ url, method: 'PUT', data }) request.delete = (url, data) => request({ url, method: 'DELETE', data }) export default request业务代码立刻清爽:import request from '@/utils/request' // 页面里 async loadOrders() { this.loading = true try { this.orders = await request.get('/orders', { page: this.page, size: 20 }) } finally { this.loading = false } }三、Token 无感刷新(核心难点)Access Token 过期时,理想体验是用户毫无感知地完成刷新并重放失败请求。难点在于:并发多个请求同时 401 时,只允许刷新一次,其余请求排队等待。// utils/request.js let isRefreshing = false let pendingQueue = [] // 等待刷新完成的请求 function refreshTokenAndRetry(options) { return new Promise((resolve, reject) => { pendingQueue.push({ options, resolve, reject }) if (isRefreshing) return // 已有刷新任务在跑,只入队 isRefreshing = true const refreshToken = uni.getStorageSync('refresh_token') // 用原始 uni.request 刷新,避免递归 uni.request({ url: BASE_URL + '/auth/refresh', method: 'POST', data: { refreshToken }, success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { const { accessToken, refreshToken: newRefresh } = res.data.data setTokens(accessToken, newRefresh) // 重放所有排队请求 pendingQueue.forEach(({ options, resolve, reject }) => { request(options).then(resolve).catch(reject) }) } else { // 刷新失败:登录态彻底失效 clearTokens() pendingQueue.forEach(({ reject }) => reject(new Error('登录已过期'))) uni.showToast({ title: '请重新登录', icon: 'none' }) setTimeout(() => uni.reLaunch({ url: '/pages/login/login' }), 1500) } }, fail: (err) => { pendingQueue.forEach(({ reject }) => reject(err)) }, complete: () => { isRefreshing = false pendingQueue = [] } }) }) }这套机制的关键点:isRefreshing 互斥锁:第一个 401 触发刷新,后续 401 只入队排队重放:刷新成功后遍历队列,用新 Token 重发原请求彻底失败兜底:refresh token 也过期时,清空凭证踢回登录页用原生 uni.request 刷新:避免刷新接口自身 401 造成无限递归四、并发控制与取消请求取消请求uni.request 返回 RequestTask,可以 abort:// 页面搜索场景:新请求发出前取消上一个 export function requestWithAbort(options) { if (requestWithAbort._task) { requestWithAbort._task.abort() } const taskHolder = {} const p = new Promise((resolve, reject) => { taskHolder.task = uni.request({ ...options, success: resolve, fail: reject }) }) requestWithAbort._task = taskHolder.task return p }搜索防抖 + 取消请求组合,才能彻底解决"旧结果覆盖新结果"的竞态问题。并发限制小程序对同时进行的 wx.request 有 10 个上限(微信端),批量任务要做池化控制:async function requestPool(tasks, limit = 8) { const results = [] const executing = [] for (const task of tasks) { const p = task().then(r => ({ status: 'ok', data: r })) .catch(e => ({ status: 'fail', error: e })) results.push(p) if (limit <= tasks.length) { executing.push(p) p.finally(() => executing.splice(executing.indexOf(p), 1)) if (executing.length >= limit) { await Promise.race(executing) } } } return Promise.all(results) } // 批量拉取 50 个商品详情,并发 8 const details = await requestPool( goodsIds.map(id => () => request.get(`/goods/${id}`)), 8 )五、上传与下载文件上传function upload(filePath, formData = {}) { return new Promise((resolve, reject) => { uni.uploadFile({ url: BASE_URL + '/upload', filePath, name: 'file', formData, // 额外表单字段 header: { Authorization: `Bearer ${getToken()}` }, success: (res) => { // 注意:uploadFile 返回的是字符串,要手动 parse const body = JSON.parse(res.data) body.code === 0 ? resolve(body.data) : reject(body) }, fail: reject }) }) }uploadFile 响应体是字符串,不 像 request 自动按 content-type 解析,JSON.parse 是必经步骤,这是极高频的坑。分片上传大文件uni-app 端实现分片需要先读文件(uni.getFileSystemManager,仅小程序端可用),将 Blob 切片后逐片上传,最后通知后端合并:// 伪代码框架 const chunkSize = 1024 * 512 // 512KB const totalChunks = Math.ceil(fileSize / chunkSize) for (let i = 0; i < totalChunks; i++) { await request.post('/upload/chunk', { uploadId, index: i, total: totalChunks, chunk: chunkData }) } // 全部上传完成后合并 await request.post('/upload/merge', { uploadId, filename })六、环境切换与接口配置// config/env.js let baseUrl = '' // #ifdef MP-WEIXIN baseUrl = 'https://mp-api.example.com' // 小程序正式环境 // #endif // #ifdef H5 // H5 开发环境走代理 baseUrl = process.env.NODE_ENV === 'development' ? '/api' : 'https://h5-api.example.com' // #endif // #ifdef APP-PLUS baseUrl = 'https://app-api.example.com' // #endif export { baseUrl }利用条件编译按平台切换域名,比运行时 process.env 判断更干净。总结三层错误模型:网络层 fail、HTTP 层 statusCode、业务层 code 分开处理Token 无感刷新 = 互斥锁 + 等待队列 + 重放,缺一不可竞态问题 = 防抖 + abort 双保险uploadFile 响应是字符串,必须手动 JSON.parse微信端并发请求上限 10,批量任务用请求池一个健壮的请求层是项目的地基,值得花一个下午把它写好。下一篇讲条件编译与多端适配——uni-app "一次开发多端运行"的光鲜与代价。
2026年07月25日
5 阅读
0 评论
0 点赞
2026-07-20
uni-app 条件编译与多端差异化开发:#ifdef 实战指南
"一次开发,多端运行"是 uni-app 的招牌,但真实项目里各平台差异无处不在:微信有开放能力、H5 有跨域、App 有原生权限。条件编译(#ifdef)就是 uni-app 处理这些差异的官方答案。本文讲透条件编译的语法、边界和多端适配的完整套路。一、条件编译基础语法:#ifdef / #ifndef / #endif// #ifdef MP-WEIXIN console.log('只有微信小程序端会执行') // #endif // #ifndef H5 console.log('除了 H5 端,其他端都执行') // #endif // #ifdef MP-WEIXIN || MP-ALIPAY console.log('微信或支付宝小程序') // #endif有效平台标识:标识平台H5Web(含浏览器/移动端浏览器)MP-WEIXIN微信小程序MP-ALIPAY支付宝小程序MP-BAIDU百度小程序MP-TOUTIAO抖音小程序APP-PLUSApp(5+ App / nvue)APP-PLUS-NVUE仅 nvue 页面条件编译可以用在哪里不只是 JS——template、css、json 配置都能用:<template> <view> <!-- #ifdef MP-WEIXIN --> <button open-type="share">分享给好友</button> <!-- #endif --> <!-- #ifdef H5 --> <button @click="copyLink">复制链接</button> <!-- #endif --> </view> </template> <style> /* #ifdef H5 */ .page { cursor: pointer; } /* #endif */ </style>注意 template 中的写法是 HTML 注释形式 <!-- #ifdef -->,CSS 中是块注释 /* #ifdef */,JS 中是行注释 // #ifdef。三种注释形态对应三种文件区域,写错注释类型编译器识别不到。pages.json 里的条件编译连页面注册都能按平台区分:{ "pages": [ { "path": "pages/index/index" }, // #ifdef MP-WEIXIN { "path": "pages/live/live" }, // #endif // #ifdef APP-PLUS { "path": "pages/push/push" }, // #endif { "path": "pages/mine/mine" } ] }微信端才有直播间页,App 端才有推送设置页——页面级的差异化直接在源头解决。二、多端 API 差异处理支付:三端三套流程支付是平台差异最深的领域,三端只能分别实现:// utils/pay.js export function pay(orderId) { return new Promise((resolve, reject) => { // #ifdef MP-WEIXIN uni.requestPayment({ provider: 'wxpay', // 后端统一下单返回的微信支付参数 timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: resolve, fail: reject }) // #endif // #ifdef APP-PLUS uni.requestPayment({ provider: 'alipay', // App 端可自主选择支付宝/微信 orderInfo: payParams.orderInfo, success: resolve, fail: reject }) // #endif // #ifdef H5 // H5 微信支付需要跳转 JSAPI 或唤起微信客户端 location.href = payParams.mwebUrl // #endif }) }登录:获取用户信息的方式差异export function login() { // #ifdef MP-WEIXIN // 小程序:wx.login 换 code const [err, res] = await uni.login({ provider: 'weixin' }) return await request.post('/auth/wx-login', { code: res.code }) // #endif // #ifdef APP-PLUS // App:可以接手机号一键登录 const [loginErr, loginRes] = await uni.login({ provider: 'univerify' }) return await request.post('/auth/phone-login', { access_token: loginRes.authResult.access_token, openid: loginRes.authResult.openid }) // #endif // #ifdef H5 // H5:跳转微信 OAuth 网页授权 const redirectUri = encodeURIComponent(location.origin + '/auth-callback') location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo` // #endif }API 可用性检测:uni.canIUse平台差异不明确时,运行时检测比条件编译更灵活:if (uni.canIUse('getUserProfile')) { uni.getUserProfile({ desc: '用于完善会员资料' }) } else { // 基础库不支持时的降级方案 uni.showModal({ title: '提示', content: '请升级微信版本' }) }三、样式多端适配rpx 的本质与陷阱rpx 以 750 屏宽为基准:750rpx = 屏幕宽度。设计稿 375px 宽的图,标注多少 px 就写多少 × 2 的 rpx,换算自动化。但 rpx 有几个陷阱:H5 端 rpx 基于视口宽,横屏或宽屏设备(iPad)上元素会被拉得离谱/* #ifdef H5 */ /* H5 端用 max-width 约束内容区 */ .page { max-width: 500px; margin: 0 auto; } /* #endif */1rpx 边框在部分设备上不显示:物理像素取整导致,用 1px + transform 或背景图实现细边框更稳字体大小慎用 rpx:大屏上字会跟着放大,正文字号建议用 px安全区适配(刘海屏).safe-bottom { /* 苹果底部安全区 */ padding-bottom: constant(safe-area-inset-bottom); /* iOS < 11.2 */ padding-bottom: env(safe-area-inset-bottom); /* iOS >= 11.2 */ }// pages.json 页面配置 { "path": "pages/index/index", "style": { "navigationStyle": "custom", // 自定义导航栏需要自己处理状态栏高度 "navigationBarTitleText": "首页" } }自定义导航栏时获取状态栏高度:onLoad() { const sysInfo = uni.getSystemInfoSync() this.statusBarHeight = sysInfo.statusBarHeight }四、多端架构组织实践差异代码分层而不是满屏 #ifdef条件编译用多了,代码可读性会急剧恶化。推荐架构:接口统一,实现分平台目录:src/ ├── platform/ │ ├── wx/ │ │ ├── pay.js # 微信小程序支付实现 │ │ └── login.js │ ├── app/ │ │ ├── pay.js # App 支付实现 │ │ └── login.js │ └── h5/ │ ├── pay.js # H5 支付实现 │ └── login.js └── utils/ └── pay.js # 统一入口// utils/pay.js —— 收敛所有条件编译到一个文件 // #ifdef MP-WEIXIN import pay from '@/platform/wx/pay' // #endif // #ifdef APP-PLUS import pay from '@/platform/app/pay' // #endif // #ifdef H5 import pay from '@/platform/h5/pay' // #endif export default pay业务代码只 import 统一入口,完全无感知平台差异。条件编译被压缩在入口文件里,各端实现独立演进互不干扰。何时用条件编译,何时用运行时判断场景方案代码只对某端存在(API、页面、组件)条件编译某端逻辑完全不同条件编译 + 分平台目录只是参数/样式的小差异运行时判断 + CSS 条件编译原则:能在编译期裁剪掉的代码不要留到运行时,包体积是小程序的硬指标。五、调试技巧H5 端开发效率最高,先在 H5 调通逻辑再上小程序真机process.env.UNI_PLATFORM 可在 JS 中判断当前编译平台(vite 版本),用于日志过滤小程序端用微信开发者工具的"编译模式"自定义启动页面和参数差异 bug 高发区:时间日期格式(iOS 不认 2026-08-23 短横线格式,要替换成 /)、position: fixed 在部分安卓机的表现、webview 与原生通信// iOS 日期兼容:经典坑 const date = new Date('2026-08-23 12:00') // iOS 返回 Invalid Date const fixed = new Date('2026-08-23 12:00'.replace(/-/g, '/')) // OK总结条件编译三种注释形态分别对应 JS / template / CSS 区域pages.json 支持条件编译,页面级差异从注册源头解决"接口统一、实现分层"的架构能把 #ifdef 压缩到入口文件能编译期裁剪就不运行时判断,控制包体积高频坑:rpx 横屏、1rpx 边框、iOS 日期格式、uploadFile 字符串响应多端开发的真实成本不在"写代码",而在"记住差异"。把本文的分层架构和坑清单用起来,多端项目就能保持可控。
2026年07月20日
5 阅读
0 评论
0 点赞
1
2
3
4
...
15
0:00