首页
直播
壁纸
友链
搜索
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-07-26
uni-app 页面路由与通信机制详解:跳转传参、EventChannel 与全局状态
uni-app 的页面机制和纯 Vue 项目差异很大:页面栈、tabBar 限制、生命周期钩子都带着小程序的影子。很多从 Web 转过来的开发者在"页面间怎么传值"这一步就开始踩坑。本文系统讲清 uni-app 的路由体系与页面通信的全部手段。一、uni-app 的路由本质uni-app 没有 vue-router,页面路由由 pages.json 声明,跳转靠 uni. API:// pages.json { "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/order/list", "style": {} }, { "path": "pages/order/detail", "style": {} } ], "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] } }第一个坑就在这里:tabBar 页面只能用 uni.switchTab 跳转,用 uni.navigateTo 会静默失败,这是新手最常见的"跳转没反应"原因。二、五种跳转 API 与页面栈API行为页面栈变化返回uni.navigateTo保留当前页,跳转新页push可返回uni.redirectTo关闭当前页,跳转新页replace不可返回当前页uni.reLaunch关闭所有页面,跳转清空后 push返回到首页uni.switchTab跳转 tabBar 页清空非 tab 页-uni.navigateBack返回上一页pop-页面栈上限是 10 层。连续 navigateTo 超过 10 层会报错。常见于"查看详情 → 查看用户 → 查看详情"的无限循环场景,解决方案是深链入口用 redirectTo 替代 navigateTo,或在 onShow 里检测栈深度做收敛:onShow() { const pages = getCurrentPages() if (pages.length >= 8) { uni.redirectTo({ url: '/pages/order/detail?id=' + this.id }) } }三、页面传参的三种方式方式一:URL 查询参数(最常用)uni.navigateTo({ url: '/pages/order/detail?id=1001&from=list' })// 目标页面:onLoad 接收 onLoad(options) { // options: { id: '1001', from: 'list' } // 注意:所有值都是 string this.orderId = Number(options.id) }两个注意点:参数会被编码:对象、数组必须先 encodeURIComponent(JSON.stringify(obj)),接收方再解码解析tabBar 页面接收参数:switchTab 不支持 URL 传参,只能走全局状态或事件// 传对象 uni.navigateTo({ url: '/pages/filter/result?query=' + encodeURIComponent(JSON.stringify(filter)) }) // 接收 onLoad(options) { this.query = JSON.parse(decodeURIComponent(options.query)) }方式二:EventChannel 事件通道(双向通信)navigateTo 的 events 与 success 返回的 EventChannel 可以让两个页面直接通信,适合"选择收货地址"这类带回传数据的场景:// 订单页:打开地址选择页,监听选择结果 uni.navigateTo({ url: '/pages/address/select', events: { // 监听地址选择页抛回的事件 selectAddress(address) { this.address = address } }, success: (res) => { // 向地址选择页传递数据 res.eventChannel.emit('init', { selectedId: this.address?.id }) } })// 地址选择页 onLoad(options) { const eventChannel = this.getOpenerEventChannel() // 接收初始数据 eventChannel.on('init', (data) => { this.selectedId = data.selectedId }) this.eventChannel = eventChannel }, methods: { onSelect(address) { // 通知上一页并返回 this.eventChannel.emit('selectAddress', address) uni.navigateBack() } }相比全局状态,EventChannel 作用域仅限两个页面,不污染全局,是临时性页面间通信的最佳选择。方式三:全局状态(跨页面、持久化)跨页面返回时更新上一页数据一个高频场景:列表页 → 编辑页,编辑完成后返回列表页需要刷新。可以用 getCurrentPages() 直接操作上一页实例:// 编辑页:保存成功后 const pages = getCurrentPages() const listPage = pages[pages.length - 2] // 上一页实例 listPage.$vm.refreshList() // 调用上一页的方法 uni.navigateBack()这种写法直接、无依赖,但要克制使用——页面耦合加深后维护成本上升。更规范的做法是全局状态 + 上一页 onShow 时检查脏标记:// store/user.js(Pinia 方案) import { defineStore } from 'pinia' export const useAddressStore = defineStore('address', { state: () => ({ selectedAddress: null, dirty: false }) }) // 编辑页保存成功后 const store = useAddressStore() store.selectedAddress = address store.dirty = true // 列表页 onShow() { const store = useAddressStore() if (store.dirty) { this.refreshList() store.dirty = false } }四、全局状态管理选型uni-app 支持 Vuex 和 Pinia。Vue 3 项目无脑选 Pinia:// main.js import { createSSRApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.use(createPinia()) return { app } }// stores/cart.js import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ items: uni.getStorageSync('cart_items') || [] }), actions: { addItem(goods) { this.items.push(goods) // 小程序没有 localStorage,用 uni API 持久化 uni.setStorageSync('cart_items', this.items) } } })关键差异:uni-app 运行在小程序端时没有 localStorage,必须用 uni.setStorageSync / uni.getStorageSync 做持久化。Pinia 持久化插件默认走 localStorage,小程序端会直接报错,需要自定义 storage 适配器。另外两个轻量方案:globalData:App.vue 的 globalData 字段 + getApp().globalData,适合放极简的配置型数据uni.$emit / uni.$on:全局事件总线,跨页面通知(如登录成功后刷新多个页面),但要在 onUnload 里 $off 解绑,否则内存泄漏// App.vue export default { globalData: { theme: 'light', version: '2.1.0' } } // 任意页面 const app = getApp() console.log(app.globalData.theme)五、页面生命周期速查uni-app 页面生命周期在 Vue 钩子之外扩展了小程序特有的钩子:钩子触发时机典型用途onLoad页面加载,可接收参数解析跳转参数、首次请求onShow页面显示(含返回时)刷新数据、检查登录态onHide页面隐藏暂停定时器onReady初次渲染完成获取节点信息onReachBottom滚动到底部分页加载onPullDownRefresh下拉刷新刷新列表onShareAppMessage分享定义分享内容onLoad vs onShow 的选择:onLoad 只执行一次(携带参数),onShow 每次显示都执行。列表页的数据刷新逻辑放 onShow,参数解析放 onLoad。总结tabBar 页面只能 switchTab,页面栈上限 10 层URL 传参全是 string,对象要 JSON + encodeURIComponentEventChannel 是"选择页回传数据"的标准方案,作用域干净getCurrentPages() 操作上一页实例简单直接,Pinia + 脏标记更规范小程序端持久化必须用 uni.setStorageSync,Pinia 持久化插件需要适配下一篇我们讲网络请求封装——把 uni.request 改造成带拦截器、Token 无感刷新的企业级请求层。
2026年07月26日
5 阅读
0 评论
0 点赞
2026-07-25
uni-app 网络请求封装实战:拦截器、Token 无感刷新与并发控制
uni.request 是个原始 API:没有拦截器、不支持 Promise 链式调用(callback 风格)、错误处理要层层嵌套。任何像样的 uni-app 项目第一步都是封装请求层。本文从零实现一个生产级的 request 模块,覆盖拦截器、Token 无感刷新、并发请求控制与上传下载。一、基础封装:Promise 化 + 统一错误处理// utils/request.js const BASE_URL = 'https://api.example.com' const TIMEOUT = 10000 function request(options = {}) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: options.header || {}, timeout: TIMEOUT, success: (res) => { // HTTP 层成功 if (res.statusCode >= 200 && res.statusCode < 300) { // 业务层判断 const body = res.data if (body.code === 0) { resolve(body.data) } else { handleBizError(body) reject(body) } } else if (res.statusCode === 401) { // 交给 Token 刷新流程处理 refreshTokenAndRetry(options).then(resolve).catch(reject) } else { uni.showToast({ title: `服务异常(${res.statusCode})`, icon: 'none' }) reject(res) } }, fail: (err) => { // 网络层失败:断网、DNS、超时 uni.showToast({ title: err.errMsg.includes('timeout') ? '请求超时' : '网络连接失败', icon: 'none' }) reject(err) } }) }) }这里把三层错误分开处理:网络层(fail 回调)、HTTP 层(statusCode)、业务层(响应体 code),每层给用户不同的提示,排查问题时也能立刻定位层级。二、请求拦截:自动携带 Token// utils/auth.js export function getToken() { return uni.getStorageSync('access_token') || '' } export function setTokens(access, refresh) { uni.setStorageSync('access_token', access) uni.setStorageSync('refresh_token', refresh) } export function clearTokens() { uni.removeStorageSync('access_token') uni.removeStorageSync('refresh_token') }在 request 内统一注入:function request(options = {}) { // 白名单接口不携带 Token const whiteList = ['/auth/login', '/auth/captcha'] const needAuth = !whiteList.includes(options.url) const header = { 'Content-Type': 'application/json', ...(options.header || {}) } if (needAuth && getToken()) { header.Authorization = `Bearer ${getToken()}` } return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, header, // ...同上 }) }) } // 便捷方法 request.get = (url, data) => request({ url, method: 'GET', data }) request.post = (url, data) => request({ url, method: 'POST', data }) request.put = (url, data) => request({ url, method: 'PUT', data }) request.delete = (url, data) => request({ url, method: 'DELETE', data }) export default request业务代码立刻清爽:import request from '@/utils/request' // 页面里 async loadOrders() { this.loading = true try { this.orders = await request.get('/orders', { page: this.page, size: 20 }) } finally { this.loading = false } }三、Token 无感刷新(核心难点)Access Token 过期时,理想体验是用户毫无感知地完成刷新并重放失败请求。难点在于:并发多个请求同时 401 时,只允许刷新一次,其余请求排队等待。// utils/request.js let isRefreshing = false let pendingQueue = [] // 等待刷新完成的请求 function refreshTokenAndRetry(options) { return new Promise((resolve, reject) => { pendingQueue.push({ options, resolve, reject }) if (isRefreshing) return // 已有刷新任务在跑,只入队 isRefreshing = true const refreshToken = uni.getStorageSync('refresh_token') // 用原始 uni.request 刷新,避免递归 uni.request({ url: BASE_URL + '/auth/refresh', method: 'POST', data: { refreshToken }, success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { const { accessToken, refreshToken: newRefresh } = res.data.data setTokens(accessToken, newRefresh) // 重放所有排队请求 pendingQueue.forEach(({ options, resolve, reject }) => { request(options).then(resolve).catch(reject) }) } else { // 刷新失败:登录态彻底失效 clearTokens() pendingQueue.forEach(({ reject }) => reject(new Error('登录已过期'))) uni.showToast({ title: '请重新登录', icon: 'none' }) setTimeout(() => uni.reLaunch({ url: '/pages/login/login' }), 1500) } }, fail: (err) => { pendingQueue.forEach(({ reject }) => reject(err)) }, complete: () => { isRefreshing = false pendingQueue = [] } }) }) }这套机制的关键点:isRefreshing 互斥锁:第一个 401 触发刷新,后续 401 只入队排队重放:刷新成功后遍历队列,用新 Token 重发原请求彻底失败兜底:refresh token 也过期时,清空凭证踢回登录页用原生 uni.request 刷新:避免刷新接口自身 401 造成无限递归四、并发控制与取消请求取消请求uni.request 返回 RequestTask,可以 abort:// 页面搜索场景:新请求发出前取消上一个 export function requestWithAbort(options) { if (requestWithAbort._task) { requestWithAbort._task.abort() } const taskHolder = {} const p = new Promise((resolve, reject) => { taskHolder.task = uni.request({ ...options, success: resolve, fail: reject }) }) requestWithAbort._task = taskHolder.task return p }搜索防抖 + 取消请求组合,才能彻底解决"旧结果覆盖新结果"的竞态问题。并发限制小程序对同时进行的 wx.request 有 10 个上限(微信端),批量任务要做池化控制:async function requestPool(tasks, limit = 8) { const results = [] const executing = [] for (const task of tasks) { const p = task().then(r => ({ status: 'ok', data: r })) .catch(e => ({ status: 'fail', error: e })) results.push(p) if (limit <= tasks.length) { executing.push(p) p.finally(() => executing.splice(executing.indexOf(p), 1)) if (executing.length >= limit) { await Promise.race(executing) } } } return Promise.all(results) } // 批量拉取 50 个商品详情,并发 8 const details = await requestPool( goodsIds.map(id => () => request.get(`/goods/${id}`)), 8 )五、上传与下载文件上传function upload(filePath, formData = {}) { return new Promise((resolve, reject) => { uni.uploadFile({ url: BASE_URL + '/upload', filePath, name: 'file', formData, // 额外表单字段 header: { Authorization: `Bearer ${getToken()}` }, success: (res) => { // 注意:uploadFile 返回的是字符串,要手动 parse const body = JSON.parse(res.data) body.code === 0 ? resolve(body.data) : reject(body) }, fail: reject }) }) }uploadFile 响应体是字符串,不 像 request 自动按 content-type 解析,JSON.parse 是必经步骤,这是极高频的坑。分片上传大文件uni-app 端实现分片需要先读文件(uni.getFileSystemManager,仅小程序端可用),将 Blob 切片后逐片上传,最后通知后端合并:// 伪代码框架 const chunkSize = 1024 * 512 // 512KB const totalChunks = Math.ceil(fileSize / chunkSize) for (let i = 0; i < totalChunks; i++) { await request.post('/upload/chunk', { uploadId, index: i, total: totalChunks, chunk: chunkData }) } // 全部上传完成后合并 await request.post('/upload/merge', { uploadId, filename })六、环境切换与接口配置// config/env.js let baseUrl = '' // #ifdef MP-WEIXIN baseUrl = 'https://mp-api.example.com' // 小程序正式环境 // #endif // #ifdef H5 // H5 开发环境走代理 baseUrl = process.env.NODE_ENV === 'development' ? '/api' : 'https://h5-api.example.com' // #endif // #ifdef APP-PLUS baseUrl = 'https://app-api.example.com' // #endif export { baseUrl }利用条件编译按平台切换域名,比运行时 process.env 判断更干净。总结三层错误模型:网络层 fail、HTTP 层 statusCode、业务层 code 分开处理Token 无感刷新 = 互斥锁 + 等待队列 + 重放,缺一不可竞态问题 = 防抖 + abort 双保险uploadFile 响应是字符串,必须手动 JSON.parse微信端并发请求上限 10,批量任务用请求池一个健壮的请求层是项目的地基,值得花一个下午把它写好。下一篇讲条件编译与多端适配——uni-app "一次开发多端运行"的光鲜与代价。
2026年07月25日
5 阅读
0 评论
0 点赞
2026-07-20
uni-app 条件编译与多端差异化开发:#ifdef 实战指南
"一次开发,多端运行"是 uni-app 的招牌,但真实项目里各平台差异无处不在:微信有开放能力、H5 有跨域、App 有原生权限。条件编译(#ifdef)就是 uni-app 处理这些差异的官方答案。本文讲透条件编译的语法、边界和多端适配的完整套路。一、条件编译基础语法:#ifdef / #ifndef / #endif// #ifdef MP-WEIXIN console.log('只有微信小程序端会执行') // #endif // #ifndef H5 console.log('除了 H5 端,其他端都执行') // #endif // #ifdef MP-WEIXIN || MP-ALIPAY console.log('微信或支付宝小程序') // #endif有效平台标识:标识平台H5Web(含浏览器/移动端浏览器)MP-WEIXIN微信小程序MP-ALIPAY支付宝小程序MP-BAIDU百度小程序MP-TOUTIAO抖音小程序APP-PLUSApp(5+ App / nvue)APP-PLUS-NVUE仅 nvue 页面条件编译可以用在哪里不只是 JS——template、css、json 配置都能用:<template> <view> <!-- #ifdef MP-WEIXIN --> <button open-type="share">分享给好友</button> <!-- #endif --> <!-- #ifdef H5 --> <button @click="copyLink">复制链接</button> <!-- #endif --> </view> </template> <style> /* #ifdef H5 */ .page { cursor: pointer; } /* #endif */ </style>注意 template 中的写法是 HTML 注释形式 <!-- #ifdef -->,CSS 中是块注释 /* #ifdef */,JS 中是行注释 // #ifdef。三种注释形态对应三种文件区域,写错注释类型编译器识别不到。pages.json 里的条件编译连页面注册都能按平台区分:{ "pages": [ { "path": "pages/index/index" }, // #ifdef MP-WEIXIN { "path": "pages/live/live" }, // #endif // #ifdef APP-PLUS { "path": "pages/push/push" }, // #endif { "path": "pages/mine/mine" } ] }微信端才有直播间页,App 端才有推送设置页——页面级的差异化直接在源头解决。二、多端 API 差异处理支付:三端三套流程支付是平台差异最深的领域,三端只能分别实现:// utils/pay.js export function pay(orderId) { return new Promise((resolve, reject) => { // #ifdef MP-WEIXIN uni.requestPayment({ provider: 'wxpay', // 后端统一下单返回的微信支付参数 timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: resolve, fail: reject }) // #endif // #ifdef APP-PLUS uni.requestPayment({ provider: 'alipay', // App 端可自主选择支付宝/微信 orderInfo: payParams.orderInfo, success: resolve, fail: reject }) // #endif // #ifdef H5 // H5 微信支付需要跳转 JSAPI 或唤起微信客户端 location.href = payParams.mwebUrl // #endif }) }登录:获取用户信息的方式差异export function login() { // #ifdef MP-WEIXIN // 小程序:wx.login 换 code const [err, res] = await uni.login({ provider: 'weixin' }) return await request.post('/auth/wx-login', { code: res.code }) // #endif // #ifdef APP-PLUS // App:可以接手机号一键登录 const [loginErr, loginRes] = await uni.login({ provider: 'univerify' }) return await request.post('/auth/phone-login', { access_token: loginRes.authResult.access_token, openid: loginRes.authResult.openid }) // #endif // #ifdef H5 // H5:跳转微信 OAuth 网页授权 const redirectUri = encodeURIComponent(location.origin + '/auth-callback') location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo` // #endif }API 可用性检测:uni.canIUse平台差异不明确时,运行时检测比条件编译更灵活:if (uni.canIUse('getUserProfile')) { uni.getUserProfile({ desc: '用于完善会员资料' }) } else { // 基础库不支持时的降级方案 uni.showModal({ title: '提示', content: '请升级微信版本' }) }三、样式多端适配rpx 的本质与陷阱rpx 以 750 屏宽为基准:750rpx = 屏幕宽度。设计稿 375px 宽的图,标注多少 px 就写多少 × 2 的 rpx,换算自动化。但 rpx 有几个陷阱:H5 端 rpx 基于视口宽,横屏或宽屏设备(iPad)上元素会被拉得离谱/* #ifdef H5 */ /* H5 端用 max-width 约束内容区 */ .page { max-width: 500px; margin: 0 auto; } /* #endif */1rpx 边框在部分设备上不显示:物理像素取整导致,用 1px + transform 或背景图实现细边框更稳字体大小慎用 rpx:大屏上字会跟着放大,正文字号建议用 px安全区适配(刘海屏).safe-bottom { /* 苹果底部安全区 */ padding-bottom: constant(safe-area-inset-bottom); /* iOS < 11.2 */ padding-bottom: env(safe-area-inset-bottom); /* iOS >= 11.2 */ }// pages.json 页面配置 { "path": "pages/index/index", "style": { "navigationStyle": "custom", // 自定义导航栏需要自己处理状态栏高度 "navigationBarTitleText": "首页" } }自定义导航栏时获取状态栏高度:onLoad() { const sysInfo = uni.getSystemInfoSync() this.statusBarHeight = sysInfo.statusBarHeight }四、多端架构组织实践差异代码分层而不是满屏 #ifdef条件编译用多了,代码可读性会急剧恶化。推荐架构:接口统一,实现分平台目录:src/ ├── platform/ │ ├── wx/ │ │ ├── pay.js # 微信小程序支付实现 │ │ └── login.js │ ├── app/ │ │ ├── pay.js # App 支付实现 │ │ └── login.js │ └── h5/ │ ├── pay.js # H5 支付实现 │ └── login.js └── utils/ └── pay.js # 统一入口// utils/pay.js —— 收敛所有条件编译到一个文件 // #ifdef MP-WEIXIN import pay from '@/platform/wx/pay' // #endif // #ifdef APP-PLUS import pay from '@/platform/app/pay' // #endif // #ifdef H5 import pay from '@/platform/h5/pay' // #endif export default pay业务代码只 import 统一入口,完全无感知平台差异。条件编译被压缩在入口文件里,各端实现独立演进互不干扰。何时用条件编译,何时用运行时判断场景方案代码只对某端存在(API、页面、组件)条件编译某端逻辑完全不同条件编译 + 分平台目录只是参数/样式的小差异运行时判断 + CSS 条件编译原则:能在编译期裁剪掉的代码不要留到运行时,包体积是小程序的硬指标。五、调试技巧H5 端开发效率最高,先在 H5 调通逻辑再上小程序真机process.env.UNI_PLATFORM 可在 JS 中判断当前编译平台(vite 版本),用于日志过滤小程序端用微信开发者工具的"编译模式"自定义启动页面和参数差异 bug 高发区:时间日期格式(iOS 不认 2026-08-23 短横线格式,要替换成 /)、position: fixed 在部分安卓机的表现、webview 与原生通信// iOS 日期兼容:经典坑 const date = new Date('2026-08-23 12:00') // iOS 返回 Invalid Date const fixed = new Date('2026-08-23 12:00'.replace(/-/g, '/')) // OK总结条件编译三种注释形态分别对应 JS / template / CSS 区域pages.json 支持条件编译,页面级差异从注册源头解决"接口统一、实现分层"的架构能把 #ifdef 压缩到入口文件能编译期裁剪就不运行时判断,控制包体积高频坑:rpx 横屏、1rpx 边框、iOS 日期格式、uploadFile 字符串响应多端开发的真实成本不在"写代码",而在"记住差异"。把本文的分层架构和坑清单用起来,多端项目就能保持可控。
2026年07月20日
5 阅读
0 评论
0 点赞
2026-07-19
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符合规范后,模板里直接写标签,无需任何注册:<template> <user-card :user="userInfo" @follow="handleFollow" /> </template>自定义 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 写法部分端受限<script setup> import { ref } from 'vue' const formRef = ref(null) // 调用子组件方法 function submit() { formRef.value.validate() } </script>Vue 3 项目在 H5 和 App 端正常,部分小程序端旧版本对 expose 支持不完整,遇到问题优先确认基础库/HBuilderX 版本。差异二:自定义事件在原生组件上的差异比如 input 组件,各端 v-model 的支持程度不同,表单组件建议同时声明 modelValue prop 和 update:modelValue 事件,与 Vue 3 标准对齐:<!-- components/my-input/my-input.vue --> <script setup> const props = defineProps({ modelValue: { type: String, default: '' }, type: { type: String, default: 'text' } }) const emit = defineEmits(['update:modelValue']) function onInput(e) { emit('update:modelValue', e.detail.value) // 注意:小程序事件对象在 detail 里 } </script> <template> <input class="my-input" :type="type" :value="modelValue" @input="onInput" /> </template>关键差异:小程序原生事件的值在 e.detail.value,H5 端在 e.target.value。做跨端组件时这是必踩的坑,要么用 uni 的统一封装,要么条件编译处理。三、实战组件一:UploadImage 图片上传<!-- components/upload-image/upload-image.vue --> <script setup> import { ref, computed } from 'vue' const props = defineProps({ modelValue: { type: Array, default: () => [] }, // 已上传图片 url 数组 maxCount: { type: Number, default: 9 }, sourceType: { type: Array, default: () => ['album', 'camera'] } }) const emit = defineEmits(['update:modelValue', 'change']) const uploading = ref(0) const canAdd = computed(() => props.modelValue.length + uploading.value < props.maxCount ) async function choose() { const remain = props.maxCount - props.modelValue.length if (remain <= 0) return const [err, res] = await uni.chooseImage({ count: remain, sizeType: ['compressed'], // 压缩图,控制体积 sourceType: props.sourceType }) if (err) return uploading.value += res.tempFilePaths.length try { const urls = await Promise.all( res.tempFilePaths.map(path => uploadFile(path)) ) const next = [...props.modelValue, ...urls] emit('update:modelValue', next) emit('change', next) } catch (e) { uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uploading.value -= res.tempFilePaths.length } } function remove(index) { const next = props.modelValue.filter((_, i) => i !== index) emit('update:modelValue', next) emit('change', next) } function preview(index) { uni.previewImage({ urls: props.modelValue, current: index }) } function uploadFile(filePath) { return new Promise((resolve, reject) => { uni.uploadFile({ url: 'https://api.example.com/upload', filePath, name: 'file', success: (res) => { const body = JSON.parse(res.data) // uploadFile 响应是字符串! body.code === 0 ? resolve(body.data.url) : reject(body) }, fail: reject }) }) } </script> <template> <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> </template> <style scoped> .upload-grid { display: flex; flex-wrap: wrap; gap: 16rpx; } .upload-item { position: relative; width: 200rpx; height: 200rpx; } .upload-item image { width: 100%; height: 100%; border-radius: 12rpx; } .upload-delete { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; line-height: 36rpx; text-align: center; background: #f56c6c; color: #fff; border-radius: 50%; font-size: 24rpx; } .upload-add { width: 200rpx; height: 200rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; } </style>使用方式因为 easycom + v-model 而极其简洁:<upload-image v-model="goods.images" :max-count="9" @change="onImagesChange" />四、实战组件二:自定义导航栏小程序原生导航栏定制能力有限,很多设计稿要求沉浸式导航。方案是 navigationStyle: "custom" 后自己实现:<!-- components/nav-bar/nav-bar.vue --> <script setup> import { ref, computed } from 'vue' const props = defineProps({ title: { type: String, default: '' }, backVisible: { type: Boolean, default: true }, background: { type: String, default: '#ffffff' }, color: { type: String, default: '#333333' } }) // 状态栏高度 + 胶囊信息 const statusBarHeight = ref(0) const navHeight = ref(44) // #ifdef MP-WEIXIN const menuButton = uni.getMenuButtonBoundingClientRect() const { statusBarHeight: sbh } = uni.getSystemInfoSync() statusBarHeight.value = sbh navHeight.value = (menuButton.top - sbh) * 2 + menuButton.height // #endif // #ifdef H5 const sys = uni.getSystemInfoSync() statusBarHeight.value = 0 // #endif const totalHeight = computed(() => statusBarHeight.value + navHeight.value) function goBack() { const pages = getCurrentPages() if (pages.length > 1) { uni.navigateBack() } else { // 首个页面无返回栈,回首页 uni.reLaunch({ url: '/pages/index/index' }) } } </script> <template> <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> </template> <style scoped> .nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 999; } .nav-content { display: flex; align-items: center; position: relative; padding: 0 24rpx; } .nav-back { width: 64rpx; height: 64rpx; display: flex; align-items: center; } .back-arrow { font-size: 44rpx; line-height: 1; } .nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 32rpx; font-weight: 500; } .nav-right { margin-left: auto; } </style>两个核心细节:占位 view:固定定位的导航栏会脱离文档流,需要一个等高占位块把页面内容顶下来胶囊按钮对齐:微信端通过 uni.getMenuButtonBoundingClientRect() 拿到右上角胶囊的位置,让自定义导航内容与胶囊垂直居中对齐,这是"看着专业"的关键页面使用:// pages.json 中对应页面 { "path": "pages/order/detail", "style": { "navigationStyle": "custom" } }<nav-bar title="订单详情" background="linear-gradient(#1a73e8, #4a90d9)" color="#fff"> <template #right> <text class="report-btn">举报</text> </template> </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 标准写好这三五个基础组件,项目里的重复代码能砍掉一半。下一篇讲性能优化——分包加载、图片优化与长列表渲染。
2026年07月19日
5 阅读
0 评论
0 点赞
2026-07-17
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 可以把页面骨架生成微信快照。手写方案是在数据未到时渲染结构占位:<template> <view> <template v-if="loaded"> <view v-for="item in list" :key="item.id" class="goods-card"> <image :src="item.cover" /> <text>{{ item.title }}</text> </view> </template> <template v-else> <view v-for="i in 4" :key="i" class="goods-card skeleton"> <view class="skeleton-img" /> <view class="skeleton-line" /> </view> </template> </view> </template> <style> .skeleton-img, .skeleton-line { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; } @keyframes shimmer { to { background-position: -200% 0; } } </style>优化三:初始渲染缓存微信端开启后,第二次打开直接展示上次渲染的快照,再用新数据更新:{ "path": "pages/index/index", "style": { "initialRenderingCache": "static" } }三、运行时性能:setData 与长列表理解 setData 的成本uni-app 编译到小程序后,逻辑层和渲染层分离,每次响应式数据变化都会产生一次 setData 通信。数据量大、频率高时,通信本身成为瓶颈。规则一:不要频繁 setData 小数据// ❌ 倒计时每秒 setData 一次整个对象 this.timer = setInterval(() => { this.leftSeconds-- // 对象级更新 }, 1000) // ✅ 高频更新只改必要字段,或者干脆用 wx 层优化 // 页面数据结构上把高频变化的字段隔离出来规则二:长列表分页 + 局部更新<script> export default { data() { return { list: [], page: 1, finished: false, loading: false } }, onReachBottom() { this.loadMore() }, methods: { async loadMore() { if (this.loading || this.finished) return this.loading = true try { const { records, hasMore } = await this.$api.getOrders({ page: this.page + 1 }) // 追加而不是替换,配合 :key 复用节点 this.list.push(...records) this.page++ this.finished = !hasMore } finally { this.loading = false } } } } </script>长列表的进阶方案是虚拟列表:只渲染可视区域附近的节点。长列表组件(如 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 后动手性能优化做到位的小程序,启动快、滚动顺、耗电少,用户体验评分和留存都会直观反映出来。
2026年07月17日
6 阅读
0 评论
0 点赞
1
2
3
...
5
0:00