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 限制，大对象持久化要做节流和规模控制下一篇讲登录授权的"最后一公里"——手机号验证码、头像昵称填写这些微信持续收紧的接口怎么优雅应对。
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 绘制核心代码模板与画布初始化 <view class="poster-mask" v-if="visible" @click="visible = false"> <view class="poster-wrap" @click.stop> <view class="poster-actions"> 保存到相册 </view> </view> </view> 绘制函数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 标准海报功能链路长、坑密集，把本文的流程清单存好，下次实现能省一半时间。
客服聊天、订单状态推送、实时协作——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 立即重连，配合服务端的离线消息补拉，体验反而更好。四、实战：聊天消息列表 <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"> 发送 </view> 几个体验细节：乐观更新 + 状态标记：消息立即上屏，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 的差异。
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，每次发版对照走一遍。
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" <image class="avatar" :src="avatarUrl || '/static/default-avatar.png'" mode="aspectFill" /> <text class="avatar-tip">点击选择头像</text> 关键：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" <view class="form-item"> <text class="label">昵称</text> </view> 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 触发，且小程序必须完成微信认证（企业主体）： 授权手机号登录 当前流程（2023 之后的版本）：用户点击授权按钮 → e.detail.code（动态令牌） → POST /auth/bind-phone { code } → 后端用 code + access_token 调微信接口换真实手机号 → 绑定用户，返回更新后的用户信息注意事项：个人主体小程序用不了这个能力，认证费用 300 元/年，手机号验证按次计费（约 0.03 元/次）旧版 encryptedData + iv 解密方案还能用但不推荐，code 方案更安全且免维护密钥计费压力大的场景可以改做短信验证码登录（自建），绕开微信计费短信验证码登录（自建方案） <view class="sms-login"> <view class="input-row"> </view> <view class="input-row"> 0" @click="sendSms"> {{ countdown > 0 ? `${countdown}s后重试` : '获取验证码' }} </view> 登录 </view> 后端要点：验证码 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登录授权是合规重灾区，本文方案基于当前有效接口，建议每半年对照微信官方文档核对一次。
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 无感刷新的企业级请求层。
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 "一次开发多端运行"的光鲜与代价。
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 配置都能用： <view> 分享给好友 复制链接 </view> 注意 template 中的写法是 HTML 注释形式 ，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 字符串响应多端开发的真实成本不在"写代码"，而在"记住差异"。把本文的分层架构和坑清单用起来，多端项目就能保持可控。
uni-app 自定义组件开发实战：easycom 规范、组件通信与自定义导航栏
uni-app 的组件体系继承了 Vue 语法，但运行环境横跨小程序和 H5，组件的注册方式、通信限制、样式隔离都有平台特色。本文讲透 uni-app 组件开发的规范与实战，最后以一个自定义导航栏组件收官。一、easycom：不用 import 的组件注册传统 Vue 组件需要 import + components 注册，uni-app 提供了 easycom 规范：组件路径符合约定，即可直接使用。规范约定组件路径：components/组件名/组件名.vuesrc/ └── components/ ├── user-card/user-card.vue ├── empty-state/empty-state.vue └── upload-image/upload-image.vue符合规范后，模板里直接写标签，无需任何注册： <user-card :user="userInfo" @follow="handleFollow" /> 自定义 easycom 规则组件库或目录结构特殊时，在 pages.json 里扩展规则：{ "easycom": { "autoscan": true, "custom": { "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue", "^my-(.*)": "@/components/$1/index.vue" } } }<uni-icons>、<my-search> 都能自动解析。这就是 uni-ui 等组件库"引入即用"的原理。注意：easycom 只解决注册问题，组件内部的通信、传值仍然遵循 Vue 规范。二、组件通信的平台差异uni-app 组件通信支持 props / emit，这一点与 Vue 相同。但有几个平台差异必须注意：差异一：vue2 语法下 this.$refs 可用，vue3 组合式 API 写法部分端受限 Vue 3 项目在 H5 和 App 端正常，部分小程序端旧版本对 expose 支持不完整，遇到问题优先确认基础库/HBuilderX 版本。差异二：自定义事件在原生组件上的差异比如 input 组件，各端 v-model 的支持程度不同，表单组件建议同时声明 modelValue prop 和 update:modelValue 事件，与 Vue 3 标准对齐： 关键差异：小程序原生事件的值在 e.detail.value，H5 端在 e.target.value。做跨端组件时这是必踩的坑，要么用 uni 的统一封装，要么条件编译处理。三、实战组件一：UploadImage 图片上传 <view class="upload-grid"> <view v-for="(url, index) in modelValue" :key="url" class="upload-item"> <image :src="url" mode="aspectFill" @click="preview(index)" /> <view class="upload-delete" @click="remove(index)">×</view> </view> <view v-if="canAdd" class="upload-add" @click="choose"> <text v-if="uploading" class="add-text">{{ uploading }}张上传中</text> <text v-else class="add-icon">+</text> </view> </view> 使用方式因为 easycom + v-model 而极其简洁：<upload-image v-model="goods.images" :max-count="9" @change="onImagesChange" />四、实战组件二：自定义导航栏小程序原生导航栏定制能力有限，很多设计稿要求沉浸式导航。方案是 navigationStyle: "custom" 后自己实现： <view> <view :style="{ height: totalHeight + 'px' }" /> <view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px', height: totalHeight + 'px', background, color }" > <view class="nav-content" :style="{ height: navHeight + 'px' }"> <view v-if="backVisible" class="nav-back" @click="goBack"> <text class="back-arrow">‹</text> </view> <view class="nav-title">{{ title }}</view> <view class="nav-right"><slot name="right" /></view> </view> </view> </view> 两个核心细节：占位 view：固定定位的导航栏会脱离文档流，需要一个等高占位块把页面内容顶下来胶囊按钮对齐：微信端通过 uni.getMenuButtonBoundingClientRect() 拿到右上角胶囊的位置，让自定义导航内容与胶囊垂直居中对齐，这是"看着专业"的关键页面使用：// pages.json 中对应页面 { "path": "pages/order/detail", "style": { "navigationStyle": "custom" } }<nav-bar title="订单详情" background="linear-gradient(#1a73e8, #4a90d9)" color="#fff"> <text class="report-btn">举报</text> </nav-bar>五、组件库生态不想重复造轮子时的选择：库特点uni-uiDCloud 官方，easycom 无缝集成，稳定uview-plusuview 的 vue3 版，组件数量最多，社区活跃TuniaoUI设计感强，适合 toC 产品wot-design-units 编写，暗黑模式支持好组件库选型建议：组件数量和颜值之外，重点看 issues 里平台兼容性问题的响应速度。多端项目里组件库的兼容 bug 会消耗大量时间。总结easycom 靠目录约定免注册，自定义规则支持任意目录映射小程序事件对象的值在 e.detail.value，与 H5 的 e.target.value 不同uploadFile 响应体是字符串，二次封装时必须 JSON.parse自定义导航栏 = 状态栏高度 + 胶囊对齐 + 占位块三件套优先用 v-model + update:modelValue 让组件 API 对齐 Vue 3 标准写好这三五个基础组件，项目里的重复代码能砍掉一半。下一篇讲性能优化——分包加载、图片优化与长列表渲染。
uni-app 性能优化实战：分包加载、首屏提速与长列表渲染
小程序的性能红线比 Web 苛刻得多：主包 2MB 上限、启动加载有评分、setData 频繁会掉帧。本文围绕包体积、启动速度、运行时性能三个维度，给出可直接落地的优化清单。一、包体积优化：分包是第一要务为什么必须分包微信小程序限制：主包 ≤ 2MB，单个分包 ≤ 2MB，总包 ≤ 30MB（普通分包）。业务做多了，主包必然爆。分包加载（subPackages）把非首屏页面拆出去，用户进入对应模块时才下载。分包配置// pages.json { "pages": [ "pages/index/index", "pages/order/list" ], "subPackages": [ { "root": "subpkg-order", "pages": [ "pages/detail/detail", "pages/refund/refund", "pages/invoice/invoice" ] }, { "root": "subpkg-member", "pages": [ "pages/coupon/coupon", "pages/points/points" ] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["subpkg-order"] }, "pages/order/list": { "network": "wifi", "packages": ["subpkg-member"] } } }目录结构相应调整：src/ ├── pages/ # 主包：只放首屏路径 ├── subpkg-order/ │ └── pages/ │ ├── detail/ │ ├── refund/ │ └── invoice/ ├── subpkg-member/ │ └── pages/ ├── static/ # 只放主包用的静态资源 └── components/分包的两个关键规则：分包页面跳转用完整路径：uni.navigateTo({ url: '/subpkg-order/pages/detail/detail?id=1' })分包可以引用主包的组件/工具，反之不行（主包不能 import 分包内资源，否则等于没拆）preloadRule：分包预下载上面配置里用户在首页时，预下载订单分包（全网络），进入订单列表时预下载会员分包（仅 WiFi）。配置得当能把"进入子模块的白屏时间"压到无感。静态资源治理主包 2MB 里最容易被图片吃掉：图片 CDN 化：static 目录里只留 tab 图标等必须本地的资源，其余全部走远程 URL压缩：tinypng 压一遍，通常能砍 60%+按需引入组件库：uni-ui 等支持 easycom 按需引入，但要确认 tree-shaking 生效；uview-plus 建议用 its 按需加载配置// vite.config.js 分析包体积 import { visualizer } from 'rollup-plugin-visualizer' export default { plugins: [visualizer({ filename: 'stats.html' })] }构建后打开 stats.html，哪些模块占了体积一目了然。二、启动性能：首屏体验启动流程与耗时构成小程序启动 = 下载代码包 → 初始化 → 页面首次渲染。开发者能控制的是初始化阶段的同步任务量和首屏依赖的数据请求。优化一：App.onLaunch 里别做重活// App.vue export default { onLaunch() { // ❌ 常见错误：启动时同步拉一堆配置 // this.initConfig() // 200ms+ 的同步阻塞 // ✅ 延迟到首页渲染后再做 // this.getConfigId = setTimeout(() => this.initConfig(), 0) } }启动阶段每个同步 API 调用、每个 await 都直接推迟首屏。原则：onLaunch 只做必须的（登录态检查），其余全部后置。优化二：骨架屏// pages.json { "path": "pages/index/index", "style": { "navigationStyle": "custom" } }HBuilderX 可以把页面骨架生成微信快照。手写方案是在数据未到时渲染结构占位： <view> <view v-for="item in list" :key="item.id" class="goods-card"> <image :src="item.cover" /> <text>{{ item.title }}</text> </view> <view v-for="i in 4" :key="i" class="goods-card skeleton"> <view class="skeleton-img" /> <view class="skeleton-line" /> </view> </view> 优化三：初始渲染缓存微信端开启后，第二次打开直接展示上次渲染的快照，再用新数据更新：{ "path": "pages/index/index", "style": { "initialRenderingCache": "static" } }三、运行时性能：setData 与长列表理解 setData 的成本uni-app 编译到小程序后，逻辑层和渲染层分离，每次响应式数据变化都会产生一次 setData 通信。数据量大、频率高时，通信本身成为瓶颈。规则一：不要频繁 setData 小数据// ❌ 倒计时每秒 setData 一次整个对象 this.timer = setInterval(() => { this.leftSeconds-- // 对象级更新 }, 1000) // ✅ 高频更新只改必要字段，或者干脆用 wx 层优化 // 页面数据结构上把高频变化的字段隔离出来规则二：长列表分页 + 局部更新 长列表的进阶方案是虚拟列表：只渲染可视区域附近的节点。长列表组件（如 z-paging、uview 的 u-list）内置了该能力：<z-paging v-model="list" @query="queryList" :default-page-size="20" use-virtual-list use-inner-scroll > <view v-for="(item, index) in list" :key="item.id"> <text>{{ item.title }}</text> </view> </z-paging>上千条数据滚动如丝般顺滑的核心：滚动容器内只保留视口 ± 缓冲区内的真实节点，用撑高的占位元素维持滚动条。规则三：图片懒加载<image :src="item.cover" lazy-load mode="aspectFill" />原生 image 组件的 lazy-load 让图片进入视口前后才加载，列表页流量和渲染压力立减。规则四：避免大对象进 data不变的数据（城市列表、配置表）放 this 挂载而非 data，避免进入响应式系统产生 setData 开销：// ❌ 一万条城市数据放 data，每次任意 setData 都可能全量传输 // ✅ 非渲染数据脱离响应式 created() { this.allCities = cityData // 不进 data，只做查找用 }四、优化检查清单上线前对照过一遍：包体积[ ] 主包 < 1.5MB（留余量）[ ] 非首屏页面全部拆分包[ ] preloadRule 覆盖高频跳转路径[ ] static 目录无超过 50KB 的图片[ ] stats.html 确认无意外的大依赖启动[ ] onLaunch 只留登录态检查[ ] 首页有骨架屏或初始渲染缓存[ ] 首屏接口合并，首屏数据 < 3 个请求运行时[ ] 长列表分页 + 虚拟列表[ ] image 全部 lazy-load + 合适的 mode[ ] 高频更新字段做隔离[ ] 定时器在 onHide 暂停、onUnload 清除[ ] 真机（低端安卓）过一遍核心页面流畅度五、工具微信开发者工具 → 审计：代码依赖分析、包体积构成体验评分：自动检测常见性能问题真机调试 Performance 面板：setData 频率和耗时可视化优化永远要以数据为依据：先跑一遍体验评分和真机 profile，找到真实瓶颈再动手，而不是凭感觉瞎改。总结分包是包体积问题的根本解，preloadRule 消除分包切换白屏启动性能核心是砍掉 onLaunch 的同步任务，骨架屏改善体感setData 通信是小程序性能的第一杀手：合并更新、隔离高频字段、大对象不进 data千级长列表必须上虚拟列表一切优化先 profile 后动手性能优化做到位的小程序，启动快、滚动顺、耗电少，用户体验评分和留存都会直观反映出来。