沿途的风景 - 小程序
type: channel
link: https://inspirecl.asia/index.php/tag/%E5%B0%8F%E7%A8%8B%E5%BA%8F/
items: uni-app 状态管理实战：Pinia 集成、持久化适配与登录态设计
items: uni-app Canvas 海报生成实战：绘制、二维码、保存相册与分享
items: uni-app WebSocket 即时通信实战：心跳保活、断线重连与消息列表
items: uni-app 打包发布与热更新实战：云打包、证书管理、审核与 wgt 热更
items: uni-app 登录授权全流程实战：微信登录、手机号验证码与头像昵称填写
items: uni-app 页面路由与通信机制详解：跳转传参、EventChannel 与全局状态
items: uni-app 网络请求封装实战：拦截器、Token 无感刷新与并发控制
items: uni-app 条件编译与多端差异化开发：#ifdef 实战指南
items: uni-app 自定义组件开发实战：easycom 规范、组件通信与自定义导航栏
items: uni-app 性能优化实战：分包加载、首屏提速与长列表渲染

uni-app 状态管理实战：Pinia 集成、持久化适配与登录态设计
type: item
date: 2026-08-04T02:50:27+08:00
description: 页面通信篇里简单提过 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))) } …

uni-app Canvas 海报生成实战：绘制、二维码、保存相册与分享
type: item
date: 2026-08-01T07:50:04+08:00
description: 生成推广海报是营销类小程序的高频需求：商品详情页"分享海报"、邀请好友"专属邀请码"。要在 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, () => { // 绘制完成回调，可以导出了 po…

uni-app WebSocket 即时通信实战：心跳保活、断线重连与消息列表
type: item
date: 2026-07-31T11:33:22+08:00
description: 客服聊天、订单状态推送、实时协作——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({}) } // ===…

uni-app 打包发布与热更新实战：云打包、证书管理、审核与 wgt 热更
type: item
date: 2026-07-28T15:33:17+08:00
description: 开发完成只是半程，把 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-Con…

uni-app 登录授权全流程实战：微信登录、手机号验证码与头像昵称填写
type: item
date: 2026-07-27T01:15:02+08:00
description: 微信登录体系这几年持续收紧：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 触发，且小程序必须完成微…

uni-app 页面路由与通信机制详解：跳转传参、EventChannel 与全局状态
type: item
date: 2026-07-26T10:24:39+08:00
description: 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.getOpenerEventChann…

uni-app 网络请求封装实战：拦截器、Token 无感刷新与并发控制
type: item
date: 2026-07-25T09:02:37+08:00
description: 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.…

uni-app 条件编译与多端差异化开发：#ifdef 实战指南
type: item
date: 2026-07-20T17:39:30+08:00
description: "一次开发，多端运行"是 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: loginRe…

uni-app 自定义组件开发实战：easycom 规范、组件通信与自定义导航栏
type: item
date: 2026-07-19T18:44:24+08:00
description: 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"…

uni-app 性能优化实战：分包加载、首屏提速与长列表渲染
type: item
date: 2026-07-17T00:25:43+08:00
description: 小程序的性能红线比 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…