首页
直播
壁纸
友链
搜索
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
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
21
篇与
» 小程序
的结果
2026-08-04
uni-app 状态管理实战:Pinia 集成、持久化适配与登录态设计
页面通信篇里简单提过 Pinia,但真实项目的状态管理远不止"装个库"。多端 storage 差异、持久化插件适配、登录态设计、模块划分,每一步都有坑。本文把 uni-app 状态管理的完整方案一次性讲透。一、为什么 uni-app 项目需要状态管理先看反例——不用状态管理时的典型代码:// 每个页面都要重复拉用户信息 onShow() { const userId = uni.getStorageSync('user_id') const res = await request.get(`/users/${userId}`) this.user = res }问题:重复请求、数据不同步(A 页改了昵称 B 页还是旧的、storage 读写散落各处。状态管理的本质是把跨页面共享的响应式数据收口到一个地方。哪些数据该进全局 store:数据类型示例方案登录态token、用户信息store + 持久化业务共享状态购物车、选中的收货地址store页面间临时传值列表页→详情页参数URL / EventChannel单页面私有状态表单数据data / ref判断标准:两个以上无父子关系的页面需要读同一份数据 → 上 store;只在跳转链上传递 → 用页面通信方案。不要为了"显得规范"把所有东西塞进 store。二、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/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null }), getters: { isLoggedIn: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || '未登录' }, actions: { async login(code) { const res = await request.post('/auth/wx-login', { code }) this.token = res.accessToken this.userInfo = res.user }, logout() { this.token = '' this.userInfo = null uni.reLaunch({ url: '/pages/login/login' }) } } })// 任意页面使用 import { useUserStore } from '@/stores/user' const userStore = useUserStore() userStore.isLoggedIn // getter,响应式 await userStore.login(code) // action三、持久化:storage 适配是关键直接持久化的问题小程序端没有 localStorage,Pinia 官方的 pinia-plugin-persistedstate 默认走 localStorage,在小程序端直接报错。两个解决路径:方案一:手写持久化(推荐,无依赖)// utils/persist.js import { watch, toRaw } from 'vue' /** * 将 store state 持久化到 uni storage * @param {Store} store pinia store 实例 * @param {string} key 存储键名 */ export function persistStore(store, key) { // 启动时恢复 const saved = uni.getStorageSync(key) if (saved) { store.$patch(JSON.parse(saved)) } // 变更时保存(deep watch) store.$subscribe((mutation, state) => { try { uni.setStorageSync(key, JSON.stringify(toRaw(state))) } catch (e) { console.warn('持久化失败', e) } }) }// stores/index.js —— 统一初始化 import { createPinia } from 'pinia' import { persistStore } from '@/utils/persist' import { useUserStore } from './user' import { useCartStore } from './cart' const pinia = createPinia() // 在 App.vue onLaunch 里调用,确保 uni storage 可用 export function initStores() { persistStore(useUserStore(), 'app:user') persistStore(useCartStore(), 'app:cart') } export default pinia// App.vue export default { onLaunch() { initStores() } }$subscribe 是 Pinia 内置的订阅机制,任何 mutation 都会触发,比手动 watch 更可靠。方案二:persist 插件 + 自定义 storage如果坚持用 pinia-plugin-persistedstate,给它传入适配 uni storage 的 driver:// main.js import { createPinia } from 'pinia' import { createPersistedState } from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(createPersistedState({ storage: { getItem: (key) => uni.getStorageSync(key) || null, setItem: (key, value) => uni.setStorageSync(key, value), // uni storage 没有.removeItem 语义?有:uni.removeStorageSync } })) export function createApp() { const app = createSSRApp(App) app.use(pinia) return { app } }持久化的粒度控制不是所有 state 都值得持久化。用户偏好(主题、字体大小)全量持久化;购物车持久化但要注意登录后与服务器合并;临时 UI 状态(弹窗开关)绝不持久化。用插件的话按 store 配置 paths 字段挑选。四、登录态设计:完整方案登录态是小程序里最重要的全局状态,完整链路:静默登录 → 检查有效期 → 请求拦截 → 失效处理。// stores/user.js 完整版 import { defineStore } from 'pinia' import request from '@/utils/request' export const useUserStore = defineStore('user', { state: () => ({ token: '', refreshToken: '', userInfo: null, wxSessionKey: '' }), getters: { isLoggedIn: (state) => !!state.token }, actions: { /** * 静默登录:App 启动即调用,用户无感知 * 小程序 wx.login 换 code → 后端换 openid → 绑定/注册用户 → 发 token */ async silentLogin() { if (this.token) return try { 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.refreshToken = data.refreshToken } catch (e) { // 静默失败不弹窗,需要用户信息时再引导 console.warn('静默登录失败', e) } }, /** 强制登录:访问需要身份的页面时调用 */ async ensureLogin() { if (this.isLoggedIn) return true await this.silentLogin() if (this.isLoggedIn) return true // 静默失败(未注册),跳登录页 const pages = getCurrentPages() const current = pages[pages.length - 1] uni.navigateTo({ url: `/pages/login/login?redirect=/${current.route}` }) return false }, async fetchProfile() { this.userInfo = await request.get('/me') }, logout() { this.$reset() // 重置到初始 state uni.reLaunch({ url: '/pages/login/login' }) } } })// App.vue export default { onLaunch() { initStores() useUserStore().silentLogin() } }五、store 模块划分中大型项目的目录建议:stores/ ├── index.js # pinia 实例 + initStores ├── user.js # 登录态、用户信息 ├── cart.js # 购物车 ├── app.js # 全局配置:主题、系统信息、定位城市 └── address.js # 收货地址列表划分原则:按业务域拆,不按页面拆(订单相关的状态跟订单 store,即使被 5 个页面用)store 之间可以互相引用:useCartStore 内部调用 useUserStore 检查登录态,Pinia 支持这种组合action 里做业务编排:加入购物车 = 检查登录 → 调接口 → 更新 state → toast 提示,这一串逻辑写进 action 而不是页面里,页面只管调用// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' import request from '@/utils/request' export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), actions: { async add(goods, count = 1) { const userStore = useUserStore() // store 组合 const ok = await userStore.ensureLogin() if (!ok) return // 本地乐观更新 + 服务端同步 const local = this.items.find(i => i.goodsId === goods.id) if (local) { local.count += count } else { this.items.push({ ...goods, goodsId: goods.id, count }) } try { await request.post('/cart/add', { goodsId: goods.id, count }) } catch (e) { uni.showToast({ title: '同步失败', icon: 'none' }) } } } })六、多端差异注意点storage 大小限制:单 key 上限 1MB,总上限 10MB(微信)。购物车这类大对象要控制规模,必要时只存 ID 列表,详情进 store 后从接口拉App 端可以直用 localStorage:但为了代码统一,全端都用 uni.setStorageSyncstorage 是同步 API:批量写入会阻塞逻辑层,高频写入(如实时输入内容)做节流// 写入节流 let saveTimer = null store.$subscribe(() => { clearTimeout(saveTimer) saveTimer = setTimeout(() => { uni.setStorageSync(key, JSON.stringify(toRaw(store.$state))) }, 300) })总结跨页面共享的响应式数据进 store,跳转传参用页面通信,不要过度集中小程序端持久化必须适配 uni storage:手写 $subscribe 方案零依赖最稳登录态标准链路:onLaunch 静默登录 → ensureLogin 强制拦截 → action 内编排业务store 按业务域划分,action 做业务编排,store 之间可组合storage 有 1MB 单 key 限制,大对象持久化要做节流和规模控制下一篇讲登录授权的"最后一公里"——手机号验证码、头像昵称填写这些微信持续收紧的接口怎么优雅应对。
2026年08月04日
5 阅读
0 评论
0 点赞
2026-08-01
uni-app Canvas 海报生成实战:绘制、二维码、保存相册与分享
生成推广海报是营销类小程序的高频需求:商品详情页"分享海报"、邀请好友"专属邀请码"。要在 Canvas 上绘制图片、文字、二维码,再保存到相册或转发,每一步都有跨端陷阱。本文完整实现一个生产级海报生成方案。一、需求拆解一张典型推广海报包含四层:背景图(设计师出图,固定模板)商品图/用户头像(网络图片,动态)文案文字(标题、价格、邀请语)小程序码/二维码(后端生成或前端绘制)┌─────────────────┐ │ 背景图模板 │ │ ┌───────┐ │ │ │ 商品图 │ │ │ └───────┘ │ │ 商品标题 │ │ ¥99.00 │ │ ┌────┐ 长按识别 │ │ │二维码│ │ │ └────┘ │ └─────────────────┘二、踩坑第一关:网络图片必须先下载Canvas 的 drawImage 在小程序端不能直接绘制网络图片——必须先 uni.getImageInfo 下载到本地临时路径:async function loadImages(urls) { const promises = urls.map(url => new Promise((resolve, reject) => { uni.getImageInfo({ src: url, success: resolve, fail: reject }) }) ) return Promise.all(promises) }两个前置条件:图片域名要加入 downloadFile 合法域名(小程序后台配置),否则真机直接失败开发者工具勾选"不校验合法域名"只在开发阶段有效,上线前必须配置好三、Canvas 绘制核心代码模板与画布初始化<template> <view class="poster-mask" v-if="visible" @click="visible = false"> <view class="poster-wrap" @click.stop> <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" /> <view class="poster-actions"> <button class="action-btn" @click="saveToAlbum">保存到相册</button> </view> </view> </view> </template> <script setup> import { ref } from 'vue' const visible = ref(false) // 以 750x1200 设计稿为例,画布用 2 倍图保证清晰度 const DESIGN_WIDTH = 750 const DESIGN_HEIGHT = 1200 const canvasWidth = ref(0) const canvasHeight = ref(0) async function show(posterData) { visible.value = true // 按屏幕宽度等比缩放画布,但内部坐标系按设计稿 const { windowWidth } = uni.getSystemInfoSync() canvasWidth.value = windowWidth * 0.9 canvasHeight.value = canvasWidth.value * (DESIGN_HEIGHT / DESIGN_WIDTH) await draw(posterData) } defineExpose({ show }) </script>绘制函数async function draw(data) { const scale = canvasWidth.value / DESIGN_WIDTH // 微信新版 canvas 2d 接口更清晰,旧 canvas-id 接口在新基础库中表现不稳 const ctx = uni.createCanvasContext('posterCanvas') // 1. 预下载所有网络图片 const [bg, goodsImg, avatar, qrCode] = await loadImages([ data.bgUrl, data.goodsUrl, data.avatarUrl, data.qrUrl ]) ctx.drawImage(bg.path, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT) // 2. 商品图(圆角矩形裁剪) drawRoundImage(ctx, goodsImg.path, 40, 200, 670, 500, 16) // 3. 标题:手动换行 drawWrappedText(ctx, data.title, { x: 40, y: 740, maxWidth: 670, lineHeight: 44, maxLines: 2, fontSize: 34, color: '#333' }) // 4. 价格 ctx.setFillStyle('#e93b3d') ctx.setFontSize(28) ctx.fillText('¥', 40, 860) ctx.setFontSize(44) ctx.fillText(data.price, 70, 860) // 5. 头像 + 昵称 drawRoundImage(ctx, avatar.path, 40, 1000, 72, 72, 36) ctx.setFillStyle('#666') ctx.setFontSize(26) ctx.fillText(`${data.nickname} 邀请你`, 130, 1045) // 6. 二维码 ctx.drawImage(qrCode.path, 560, 980, 160, 160) ctx.setFillStyle('#999') ctx.setFontSize(22) ctx.fillText('长按识别', 570, 1175) // 7. 绘制(scale 到实际画布大小) ctx.scale(scale, scale) ctx.draw(false, () => { // 绘制完成回调,可以导出了 posterReady.value = true }) }圆角图片裁剪Canvas 1.0 接口没有原生圆角,用路径裁剪实现:function drawRoundImage(ctx, path, x, y, w, h, r) { ctx.save() ctx.beginPath() // 圆角矩形路径 ctx.moveTo(x + r, y) ctx.arcTo(x + w, y, x + w, y + h, r) ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r) ctx.arcTo(x, y, x + w, y, r) ctx.closePath() ctx.clip() // 裁剪 ctx.drawImage(path, x, y, w, h) ctx.restore() }文字自动换行Canvas 1.0 的 fillText 不换行,手动按宽度切分:function drawWrappedText(ctx, text, { x, y, maxWidth, lineHeight, maxLines, fontSize, color }) { ctx.setFillStyle(color) ctx.setFontSize(fontSize) const chars = text.split('') let line = '' let lines = [] for (const char of chars) { const test = line + char if (ctx.measureText(test).width > maxWidth) { lines.push(line) line = char if (lines.length === maxLines) break } else { line = test } } if (lines.length < maxLines && line) lines.push(line) // 最后一行超限时加省略号 if (lines.length === maxLines && line) { let last = lines[maxLines - 1] while (ctx.measureText(last + '...').width > maxWidth) { last = last.slice(0, -1) } lines[maxLines - 1] = last + '...' } lines.forEach((l, i) => { ctx.fillText(l, x, y + i * lineHeight) }) }四、导出与保存相册async function saveToAlbum() { if (!posterReady.value) { return uni.showToast({ title: '海报生成中', icon: 'none' }) } // 1. 导出画布为图片 const [err, res] = await new Promise((resolve) => { uni.canvasToTempFilePath({ canvasId: 'posterCanvas', // 重要:destWidth/Height 用设计稿尺寸,解决导出模糊问题 destWidth: DESIGN_WIDTH * 2, destHeight: DESIGN_HEIGHT * 2, success: resolve, fail: resolve }) }) if (err) { return uni.showToast({ title: '导出失败', icon: 'none' }) } // 2. 保存到相册(需要授权) const [saveErr] = await new Promise((resolve) => { uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => resolve([null]), fail: (e) => resolve([e]) }) }) if (saveErr) { // 授权被拒的处理 if (saveErr.errMsg.includes('auth deny') || saveErr.errMsg.includes('authorize')) { showModalThenOpenSetting() } else { uni.showToast({ title: '保存失败', icon: 'none' }) } return } uni.showToast({ title: '已保存到相册', icon: 'success' }) } function showModalThenOpenSetting() { uni.showModal({ title: '提示', content: '需要相册权限才能保存海报,请在设置中开启', success: (res) => { if (res.confirm) { uni.openSetting({}) } } }) }保存相册授权被拒是必踩的坑:第一次拒绝后,后续调用 saveImageToPhotosAlbum 会直接 fail,必须引导用户去 openSetting 手动开启。这段兜底代码一定要有。五、小程序码的两种来源方式一:后端生成(推荐)带参小程序码必须由后端调 wxacode.getUnlimited 接口生成:// 前端只要传参 const qrUrl = await request.post('/qrcode/generate', { scene: `uid=${userStore.userInfo.id}`, // 最大 32 字符 page: 'pages/goods/detail' })scene 参数会出现在 onLoad 的 options.query 里,注意 32 字符上限和仅支持数字/字母/部分符号。方式二:前端绘制普通二维码不需要小程序码(H5 分享场景)时,可以纯前端生成。引一个小巧的 QR 库(如 weapp-qrcode),把矩阵点画到 canvas 上即可,零网络请求。六、新版 Canvas 2D 接口(进阶)微信基础库 2.9+ 提供 Canvas 2D 接口,类型对齐 Web 标准,清晰度控制更好:async function drawWithCanvas2d(data) { // 获取 canvas 节点 const query = uni.createSelectorQuery() query.select('#posterCanvas').fields({ node: true, size: true }) const { node, width } = await new Promise(r => query.exec(res => r(res[0]))) const dpr = uni.getSystemInfoSync().pixelRatio node.width = width * dpr // 物理像素 node.height = canvasHeight.value * dpr const ctx = node.getContext('2d') ctx.scale(dpr, dpr) // 之后就是标准 Web Canvas API:roundRect、drawImage(Image 实例)... const img = node.createImage() img.src = bgTempPath await new Promise(r => { img.onload = r }) ctx.drawImage(img, 0, 0, width, canvasHeight.value) }新版接口的图片要用 node.createImage() 创建 Image 实例加载,支持 ctx.roundRect() 原生圆角,长望建议迁移。旧接口(canvas-id + createCanvasContext)目前仍广泛使用,本文两种都给了方案。七、完整流程串联用户点击"生成海报" → show(posterData) 打开弹层 → loadImages 预下载(失败 toast + 埋点) → ctx 绘制各图层 → draw 回调标记 ready → 用户点"保存" → canvasToTempFilePath 导出(destWidth 2倍防模糊) → saveImageToPhotosAlbum(授权被拒 → openSetting 引导) → toast 成功总结网络图片绘制前必须 getImageInfo 下载,域名要配白名单导出防模糊:destWidth/destHeight 设为逻辑尺寸 × 2Canvas 1.0 圆角靠 clip 裁剪,文字换行靠 measureText 手动切保存相册被拒授权后必须 openSetting 引导,这是审核与差评高发点带参小程序码后端生成,scene 限 32 字符新项目直接上 Canvas 2D 接口,对齐 Web 标准海报功能链路长、坑密集,把本文的流程清单存好,下次实现能省一半时间。
2026年08月01日
5 阅读
0 评论
0 点赞
2026-07-31
uni-app WebSocket 即时通信实战:心跳保活、断线重连与消息列表
客服聊天、订单状态推送、实时协作——WebSocket 是小程序实时通信的主力方案。但 uni.connectSocket 只是一根裸管道:弱网下说断就断、切后台被系统杀掉、消息去重和顺序全要自己做。本文实现一套健壮的 Socket 管理层,并以聊天消息列表为落地场景。一、原生 API 的局限// 裸用 uni.connectSocket 的典型代码 uni.connectSocket({ url: 'wss://api.example.com/ws' }) uni.onSocketMessage((res) => { console.log(res.data) // 然后呢? })生产环境马上会遇到的问题:连接会静默断开:网络抖动、服务器超时、手机切后台,断了没人通知业务层没有重连机制:断开后必须用户手动刷新页面消息没有可靠性保障:发送方不知道对方收没收到,重复消息无法识别所以要封装的核心能力:心跳检测 + 指数退避重连 + 消息 ACK。二、Socket 管理类实现// utils/socket.js class SocketManager { constructor(options = {}) { this.url = options.url this.heartbeatInterval = options.heartbeatInterval || 30000 this.reconnectMaxDelay = options.reconnectMaxDelay || 30000 this.task = null // SocketTask this.isConnected = false this.manualClosed = false // 主动关闭标记,区分意外断开 this.heartbeatTimer = null this.reconnectTimer = null this.reconnectCount = 0 // 重连次数(用于退避) this.messageHandlers = new Map() // 按消息类型分发 this.pendingQueue = [] // 未连接时的待发送队列 this.seq = 0 // 消息序号,用于 ACK this.pendingAcks = new Map() // seq -> { resolve, timer } } // ========== 连接管理 ========== connect() { if (this.isConnected) return this.manualClosed = false // uni-app 返回 SocketTask,用任务级监听而非全局监听(支持多连接) this.task = uni.connectSocket({ url: this.url, success: () => {}, fail: () => this.scheduleReconnect() }) this.task.onOpen(() => { this.isConnected = true this.reconnectCount = 0 this.startHeartbeat() // 重连成功后:重新拉取离线消息(关键!) this.emit('reconnected') // 冲积积压消息 while (this.pendingQueue.length) { const payload = this.pendingQueue.shift() this.task.send({ data: JSON.stringify(payload) }) } }) this.task.onMessage((res) => this.handleMessage(res)) this.task.onClose(() => { this.isConnected = false this.stopHeartbeat() if (!this.manualClosed) { this.scheduleReconnect() // 意外断开才重连 } }) this.task.onError(() => { this.isConnected = false this.stopHeartbeat() if (!this.manualClosed) { this.scheduleReconnect() } }) } close() { this.manualClosed = true this.stopHeartbeat() clearTimeout(this.reconnectTimer) this.task && this.task.close({}) } // ========== 指数退避重连 ========== scheduleReconnect() { if (this.manualClosed) return if (this.reconnectTimer) return // 已有重连任务 this.reconnectCount++ if (this.reconnectCount > 10) { this.emit('dead') // 放弃重连,通知上层 return } // 指数退避:1s 2s 4s 8s...封顶 30s,加随机抖动防止雪崩 const delay = Math.min( 1000 * Math.pow(2, this.reconnectCount - 1), this.reconnectMaxDelay ) + Math.random() * 1000 this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.connect() }, delay) } // ========== 心跳保活 ========== startHeartbeat() { this.stopHeartbeat() this.heartbeatTimer = setInterval(() => { // 期望 5 秒内收到 pong,超时视为假死 this.sendWithTimeout({ type: 'ping' }, 5000) .catch(() => { // pong 没回来:连接假死,强制重建 this.task && this.task.close({}) this.isConnected = false this.scheduleReconnect() }) }, this.heartbeatInterval) } stopHeartbeat() { clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } // ========== 消息处理 ========== handleMessage(res) { let msg try { msg = JSON.parse(res.data) } catch (e) { return } // 心跳响应单独处理 if (msg.type === 'pong') { this.emit('_pong', msg) return } // ACK 响应:匹配待确认消息 if (msg.type === 'ack' && this.pendingAcks.has(msg.ackFor)) { const pending = this.pendingAcks.get(msg.ackFor) clearTimeout(pending.timer) this.pendingAcks.delete(msg.ackFor) pending.resolve() return } // 业务消息按类型分发 this.emit(msg.type, msg) } // ========== 发送(带 ACK 确认)========== send(data, { ack = true } = {}) { if (!this.isConnected) { // 未连接先入队,连接成功后冲积 this.pendingQueue.push(data) return Promise.resolve() } if (!ack) { return new Promise((resolve, reject) => { this.task.send({ data: JSON.stringify(data), success: resolve, fail: reject }) }) } // 带 seq 的可靠发送:超时未 ACK 视为失败 this.seq++ const payload = { ...data, seq: this.seq } return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingAcks.delete(this.seq) reject(new Error('消息未确认')) }, 10000) this.pendingAcks.set(this.seq, { resolve, timer }) this.task.send({ data: JSON.stringify(payload) }) }) } sendWithTimeout(data, timeout) { return Promise.race([ this.send(data), new Promise((_, reject) => setTimeout(reject, timeout)) ]) } // ========== 事件订阅 ========== on(event, handler) { if (!this.messageHandlers.has(event)) { this.messageHandlers.set(event, new Set()) } this.messageHandlers.get(event).add(handler) return () => this.off(event, handler) // 返回取消函数 } off(event, handler) { this.messageHandlers.get(event)?.delete(handler) } emit(event, payload) { this.messageHandlers.get(event)?.forEach(fn => fn(payload)) } } export default SocketManager三、与生命周期联动Socket 生命周期必须挂到 App 和页面上:// App.vue import SocketManager from '@/utils/socket' import { useUserStore } from '@/stores/user' let socket = null export default { onLaunch() { socket = new SocketManager({ url: `wss://api.example.com/ws?token=${useUserStore().token}` }) uni.$socket = socket }, onShow() { // 从后台回前台:直接重连(后台时连接多半已被杀) uni.$socket.connect() }, onHide() { // 切后台:主动断开省电省流量,回前台再连 uni.$socket.close() } }切后台策略:小程序切后台后 WebSocket 会在数秒内被系统挂起,与其等它假死,不如 onHide 主动 close、onShow 立即重连,配合服务端的离线消息补拉,体验反而更好。四、实战:聊天消息列表<script setup> import { ref, nextTick } from 'vue' const messages = ref([]) const inputText = ref('') const scrollTo = ref('') let offHandlers = [] onLoad() { const socket = uni.$socket // 订阅新消息 offHandlers.push( socket.on('chat:message', (msg) => { // 去重:服务端消息 ID if (messages.value.some(m => m.id === msg.id)) return messages.value.push({ id: msg.id, content: msg.content, fromMe: false, time: msg.time }) scrollToBottom() }), // 重连后补拉离线消息 socket.on('reconnected', async () => { const lastId = messages.value.length ? messages.value[messages.value.length - 1].id : null const offline = await request.get('/chat/messages', { afterId: lastId, limit: 50 }) // 按时间合并去重 const existIds = new Set(messages.value.map(m => m.id)) offline.filter(m => !existIds.has(m.id)) .forEach(m => messages.value.push({ id: m.id, content: m.content, fromMe: false, time: m.time })) scrollToBottom() }) ) } onUnload() { // 页面销毁必须解绑,否则消息处理器泄漏 offHandlers.forEach(off => off()) } async function send() { const text = inputText.value.trim() if (!text) return inputText.value = '' // 乐观更新:先上屏,发送失败再标记 const tempId = `temp_${Date.now()}` messages.value.push({ id: tempId, content: text, fromMe: true, time: Date.now(), sending: true }) scrollToBottom() try { await uni.$socket.send({ type: 'chat:send', content: text }) const msg = messages.value.find(m => m.id === tempId) if (msg) msg.sending = false } catch (e) { const msg = messages.value.find(m => m.id === tempId) if (msg) { msg.sending = false msg.failed = true // 标记失败,支持点击重发 } } } function scrollToBottom() { nextTick(() => { scrollTo.value = `msg-${messages.value.length - 1}` }) } </script> <template> <scroll-view scroll-y class="chat-list" :scroll-into-view="scrollTo"> <view v-for="(msg, index) in messages" :key="msg.id" :id="`msg-${index}`" class="msg-row" :class="{ mine: msg.fromMe }" > <view class="bubble"> <text>{{ msg.content }}</text> <text v-if="msg.sending" class="status">…</text> <text v-else-if="msg.failed" class="status failed" @click="resend(msg)">!</text> </view> </view> </scroll-view> <view class="input-bar"> <input v-model="inputText" confirm-type="send" @confirm="send" /> <button size="mini" @click="send">发送</button> </view> </template>几个体验细节:乐观更新 + 状态标记:消息立即上屏,sending 三点、failed 感叹号可点重发scroll-into-view 滚到底:比手动计算 scroll-top 简单可靠onUnload 解绑:on() 返回的取消函数必须调用,否则页面销毁后 handler 仍在跑五、服务端配合要点前端这层机制需要后端按约定配合,联调前对齐协议:// 客户端 ping → 服务端 pong { "type": "ping" } { "type": "pong" } // 客户端带 seq 发送 → 服务端回 ack { "type": "chat:send", "content": "hello", "seq": 1001 } { "type": "ack", "ackFor": 1001 } // 服务端推送(带全局唯一 id,客户端用于去重) { "type": "chat:message", "id": "srv_888888", "content": "hi", "time": 1724360000000 }服务端还需要:消息落库 + 离线消息接口(客户端重连后按 afterId 补拉)、单连接的 token 校验(URL 带 token 或首条消息鉴权)、同一账号多端登录的踢下线策略。六、小程序特殊限制wss 必须:正式环境只能连 wss,且域名要配到 socket 合法域名并发连接数:微信小程序同时最多 2 个 WebSocket 连接,多路复用靠消息 type 分发而不是开多条连接后台挂起:切后台约 5 秒后收不到消息,所以离线补拉机制不是可选项而是必选项App 端差异:App 端没有 2 连接限制,但同样受省电策略影响,onHide/onShow 的断连重连逻辑全端通用总结SocketManager 四件套:心跳保活、指数退避重连(带随机抖动)、发送队列、ACK 确认onHide 主动断、onShow 立即连、reconnected 事件触发离线补拉消息三要素:全局唯一 ID 去重、乐观更新上屏、失败可重发小程序最多 2 条并发连接,靠 type 分发复用单连接与后端先对齐协议(ping/ack/id)再动工,能省掉大量联调返工这套 Socket 层写一次可以用在所有项目里,聊天、推送、协作编辑都只是消息 type 的差异。
2026年07月31日
4 阅读
0 评论
0 点赞
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 点赞
1
2
...
5
0:00