其实早在立项之前,我就明确知道要做一套基于微信小程序的家具商城系统,技术栈直接锁定 UniApp。这不是拍脑袋的决定,而是对比过原生小程序、Taro、Flutter 小程序容器后做的取舍。UniApp 的跨端能力、Vue 开发体验、生态里成熟的组件库,让我在不牺牲性能的前提下,能把大部分时间花在业务逻辑上,而不是跟不同平台的 API 差异死磕。
这篇内容不是一份泛泛的教程,而是我实际开发“家具商城”小程序过程中的完整复盘:从首页信息架构怎么搭,到商品列表的加载更多怎么做,再到微信登录、支付、坐标定位这些硬骨头怎么啃,最后把那些你百度半天都找不到答案的坑——比如包体积超 2MB、日志不打印、导航栏适配、跨域调试——一次性讲透。如果你正准备用 UniApp 做商城类小程序,或者已经在开发中反复踩坑,这篇文章应该能帮你省下至少两个星期的调试时间。
1. 项目概述与整体设计
1.1 技术选型背后的真实考量
先说结论:微信小程序 + UniApp + Vue 3 + Pinia + uView Plus,这是我能找到的组合里,兼顾开发效率和最终体验的最优解。
为什么不用原生小程序?家具商城这种项目,页面形态复杂,商品卡片、筛选面板、sku 弹层、订单状态流,每一套 UI 在原生小程序里都要重写一遍。更麻烦的是,如果后续想上支付宝小程序或 H5 端,原生代码几乎全部推倒重来。UniApp 编译到微信小程序端,运行时的渲染层还是原生组件,只是逻辑层多了一层 Vue 的响应式封装,这一点对用户来说是透明的,实际体验跟原生没有本质区别。
为什么不用 Taro?Taro 的 React 生态我很熟,但家具商城这种强表单、多状态、需要大量弹层交互的项目,Vue 的响应式心智模型写起来更顺手,尤其是购物车、订单回显这类父子组件通信的场景,Composition API 的复用性明显更好。加上 uView Plus 这套基于 uni-app 的组件库,表单、弹层、表格、日历这些高频组件都是现成的,能省下大量造轮子的时间。
目录结构上,我用了最稳妥的分层方式,把页面、组件、接口、状态、工具完全拆开:
src/ ├── pages/ # 页面层,每个模块一个文件夹 │ ├── index/ # 首页 │ ├── category/ # 分类页 │ ├── cart/ # 购物车 │ ├── order/ # 订单列表、订单详情 │ ├── goods/ # 商品详情、商品列表 │ └── user/ # 个人中心 ├── components/ # 通用组件 ├── api/ # 接口请求层,按模块拆分 ├── stores/ # Pinia 状态管理 ├── utils/ # 工具函数 └── static/ # 静态资源每个页面文件夹里再放一个config.js用来集中管理页面级别的配置,比如下拉刷新开关、分享配置、导航栏标题。这样做的目的很直接:后续要加新页面,复制文件夹改配置就能跑起来,不用动之前的代码。
1.2 页面架构与信息流设计
家具商城的信息架构,我参考的是主流电商的成熟模型,但没有完全照搬,因为家具类目的决策成本高,用户不会像买零食那样冲动下单。首页必须解决三个问题:让用户快速找到想要的品类、看到有信任感的场景图、以及快速获取活动信息。
所以首页我分了四个模块:
- 搜索栏 + 分类快捷入口:搜索是电商的刚需,分类入口要露出核心品类(沙发、床、餐桌、柜类),每个入口配一张实景图,比纯文字点按率高很多。
- Banner 轮播:新品首发、以旧换新、满减活动,每张 banner 上都标了对应的落地页路由。
- 场景化推荐位:这是家具商城的特色,比如“小户型神器”“奶油风搭配”“实木专场”,每个推荐位背后都对应一个后台配置的商品集合。用场景来引导用户,比单纯罗列商品更贴合家具的消费心理。
- 瀑布流商品列表:展示最近浏览、猜你喜欢、热销榜。这一部分复用了商品列表页的
GoodsCard组件,只是数据源不同。
页面之间的跳转关系,我用一张路由脑图理清楚了,从首页可以进分类、搜索、商品详情,从商品详情可以进购物车、结算、客服,所有核心转化路径不超过两步。这一点对电商来说很重要,路径短意味着跳出率低。
1.3 状态管理与本地缓存规划
商城类小程序的状态管理,核心就两件事:用户信息和购物车。我用 Pinia 拆了两个 store:userStore管登录态、用户资料、收货地址;cartStore管购物车列表、选中状态、结算价格汇总。
这里有一个非常关键的决策:购物车数据要双写。前端状态存 Pinia,同时把精简版同步到uni.setStorageSync。原因是购物车是用户高频操作,如果用户加到购物车后没有立刻结算,突然杀进程或者网络抖动,纯服务端存储会导致丢数据;而本地存储只是备胎,服务端数据是权威,登录后要拉取服务端购物车和服务端合并。
缓存方面,我把小程序 storage 当做一个简易的二级缓存用:商品详情这种高频读取的数据,缓存 5 分钟,过期重新拉取;分类树这种很少变的数据,缓存 24 小时;首页推荐位的数据,每次冷启动拉新的。
2. 核心功能模块拆解
2.1 登录流程:wx.login 与服务端 code2Session
微信小程序的登录逻辑和传统网站完全不同:没有账号密码,也没有输入框,它是通过wx.login拿到临时 code,然后服务端拿 code 去微信的接口换 openid 和 session_key。
我的实现思路是四步走:
// 前端登录模块 utils/login.js export function uniLogin() { return new Promise((resolve, reject) => { uni.login({ provider: 'weixin', success: async (loginRes) => { // 1. 拿到 wx.login 返回的 code const code = loginRes.code // 2. 把 code 发给自己的服务端 const res = await fetch('/api/user/login', { method: 'POST', data: { code } }) // 3. 服务端返回自定义 token + 用户信息 if (res.code === 200) { const { token, userInfo } = res.data uni.setStorageSync('token', token) uni.setStorageSync('userInfo', userInfo) resolve(userInfo) } }, fail: reject }) }) }服务端的逻辑更关键:拿 code 调https://api.weixin.qq.com/sns/jscode2session,参数是appid + secret + js_code + grant_type=authorization_code,返回openid和session_key。这里有一个安全上的坑:session_key 绝对不能下发到前端,更不能写进 token 里。前端只需要拿到服务端签发的自定义 token,后续所有请求通过这个 token 来鉴权,openid 只存在服务端。
登录时机也很讲究。我没有在冷启动就让用户强制登录,而是采用了“静默登录 + 按需授权”的策略。进入小程序先尝试用uni.login换 token,如果用户之前授权过,直接跳到首页;只有到下单、查看订单这类需要手机号的节点,才调uni.getUserProfile或手机号快捷登录。这套策略能让首屏加载时间减少 30% 左右,体验提升非常明显。
2.2 商品列表与“加载更多”的正确姿势
热搜词里“微信小程序页面列表加载更多”是高频问题,我在这里吃过不少亏,值得单独说。
先说列表加载的两种模式:翻页模式和触底加载模式。翻页模式是传统 web 的做法,每页固定数量,上一页/下一页按钮切换;触底加载则是移动端的标配,滚动到底部时自动加载下一页。我采用的是后者,因为场景更自然。
实现的核心是监听onReachBottom页面生命周期:
// pages/goods/list.vue <script setup> import { ref } from 'vue' import { getGoodsList } from '@/api/goods' const goodsList = ref([]) const page = ref(1) const pageSize = 10 const hasMore = ref(true) const loading = ref(false) async function loadMore() { if (loading.value || !hasMore.value) return loading.value = true const res = await getGoodsList({ page: page.value, pageSize, categoryId: currentCategoryId }) const list = res.data.list goodsList.value = [...goodsList.value, ...list] // 判断是否还有下一页 hasMore.value = list.length === pageSize page.value++ loading.value = false } onReachBottom(() => { loadMore() }) </script>这里有几个特别容易出问题的细节:
- loading 状态必须加:不加
loading判断的话,用户快速滚动会同时触发多次 onReachBottom,导致重复请求和重复数据。 - 判断还有没有下一页的标准:如果返回的数据长度等于 pageSize,就认为还有下一页;小于 pageSize 说明到末尾了。这个逻辑比根据 total 总数判断更实用,因为总数会变,而且服务端往往不返回总数。
- 数组追加 vs 赋值:新数据一定要
[...oldList, ...newList]追加,而不是覆盖,否则用户滚到第二页之后往回滚,第一页数据就丢了。
除了触底加载,还有一个体验细节容易被忽视:加载失误的兜底。如果网络请求失败,不能只console.log一下就完事,要在列表底部渲染一个“加载失败,点击重试”的区块。这个区块绑定的是重新拉取当前页数据的方法。
2.3 商品详情与 SKU 选择的实现
商品详情页是一个“重灾区”,信息密度大,交互层级深,这里我拆成了四块:头部的图片轮播 + 视频、中间的规格参数区、底部的图文详情、以及悬浮的操作栏(客服、购物车、立即购买)。
sku 选择弹层是整个详情页最复杂的部分。一个家具商品往往有多个规格维度:颜色(胡桃木色、原木色、黑胡桃色)、尺寸(1.8 米/1.5 米)、材质(头层牛皮、科技布),三个维度组合起来就是几十个 sku。我的做法是前端一次性拿到所有 sku 的规格数组和价格、库存信息,在弹层里通过组合匹配来展示当前可选的规格组合。
核心交互是:用户点击某个规格值时,其他维度的可选项要根据库存和组合关系做置灰处理。这里需要维护一个 sku 的维度索引结构,前端每次点击都重算哪些选项可用。这个逻辑听起来复杂,但理解透了本质就是:遍历所有 sku,找出包含当前选中组合的 sku,再把它们的其他维度的取值合并成“可用集合”,不在集合里的就置灰。
详情页还有一个坑:图片裁切和预加载。家具商城的详情图通常很长,一张图就几百 KB,如果一次性全部渲染,页面会卡到没办法看。我做了两件事:图片用loading="lazy"懒加载,滚动到可视区域才发起请求;详情区域拆成上下两段,先渲染首屏的商品实拍图和核心规格,用户往下滑到对应区块时再续传详情长图。
2.4 购物车与订单流转
购物车模块我单独拿出来说,是因为它涉及一个最容易忽略的多端同步问题。
购物车的本地数据结构长这样:
const cartItem = { skuId: '12345', goodsId: '888', goodsName: '北欧风实木沙发', specText: '1.8米/胡桃木色', price: 3999, quantity: 1, selected: true, coverImage: 'https://cdn.example.com/xxx.jpg' }价格展示有一个原则:页面展示价格只做展示,结算价格以服务端返回为准。前端如果信任本地购物车价格,用户改价格或者参与满减活动,结算页跟购物车对不上,最终会引发大量客诉。所以我的购物车接口请求的是服务端最新的价格列表,前端只负责把数量和选中状态发过去。
订单流转也很典型:确认订单 → 提交订单 → 支付 → 支付成功回调 → 订单详情。这里最有争议的是“重新计算价格”:客户端提交订单前,最好让服务端把购物车里的商品价格重新算一遍返回。我遇到过的情况是,用户在购物车里加了商品,后台改价后没有同步到前端,用户提交订单后支付金额跟页面显示不一致,这个问题严重起来就是客诉。
3. 关键实现细节与踩坑实录
3.1 顶部导航栏高度计算与自定义导航
“微信小程序顶部导航栏高度”能上热搜,说明这是很多人的共性问题。微信小程序的导航栏分成两部分:状态栏(手机顶部显示时间、电量的那条)和导航栏(小程序自己的标题栏)。要适配不同机型的刘海屏、挖孔屏,必须动态计算高度。
我在项目里封装了一个useNavBar组合函数:
// utils/navbar.js export function getNavBarHeight() { const systemInfo = uni.getSystemInfoSync() // 状态栏高度,单位 px const statusBarHeight = systemInfo.statusBarHeight || 20 // 导航栏内容高度,不同平台不太一样,微信小程序通常是 44px const navBarContentHeight = systemInfo.platform === 'ios' ? 44 : 48 // 胶囊按钮的位置信息,可以用来计算导航栏的真实高度 const menuButton = uni.getMenuButtonBoundingClientRect() // 适配逻辑:小程序的胶囊按钮垂直居中,那么导航栏总高度 // 是胶囊按钮的高度加上下留白的 2 倍 + 状态栏高度 const navBarHeight = menuButton ? (menuButton.top - statusBarHeight) * 2 + menuButton.height : navBarContentHeight return { statusBarHeight, navBarHeight, totalHeight: statusBarHeight + navBarHeight } }这段代码背后的逻辑是:微信提供了uni.getMenuButtonBoundingClientRect()来获取胶囊按钮的几何信息,胶囊按钮的 top 值就是状态栏下沿到它的距离,所以导航栏的完整高度 =(menuButton.top - statusBarHeight) * 2 + menuButton.height。这是最精确、最能适配不同机型的方案。
如果你的导航栏用的是自定义组件,记得给页面的占位容器留够高度:padding-top: totalHeight + 'px',否则内容会被导航栏盖住。这个适配问题只有真机才能看出来,模拟器上是正常的,别问我怎么知道的。
3.2 分包加载与 2MB 包体上限
“source size 2612kb exceed max limit 2mb”,这应该是即将上线的同学都会遇到的一个坎。微信小程序主包上限是 2MB,超了就直接编译失败。你有三个选择:压缩图片、删除无用组件、分包。
我的策略是主包只保留 Tabbar 页面和公共组件 / 工具函数,所有二级页面全部拆进分包。具体来说:
pages/ ├── index/ # 首页(主包) ├── category/ # 分类页(主包) ├── cart/ # 购物车(主包) ├── user/ # 个人中心(主包) └── subGoods/ # 商品详情、商品列表分包 └── subOrder/ # 订单流程分包在pages.json里这样配置:
{ "pages": [ "pages/index/index", "pages/category/category", "pages/cart/cart", "pages/user/user" ], "subPackages": [ { "root": "subGoods", "pages": [ "goods/detail", "goods/list", "search/index" ] }, { "root": "subOrder", "pages": [ "order/confirm", "order/list", "order/detail" ] } ] }但要注意,分包有一个隐藏很深的地雷:分包之间的公共代码不能共享主包里的不可用部分。比如subGoods里用到了一个组件,而这个组件又依赖了pages/user里的函数,编译时会报错。所以分包后一定要做一次全面的编译检查,尤其是那些代码中写死的相对路径引用。
还有一个很常见的包体积超限原因,是uView Plus 组件库全量引入了。如果用的是 easycom 自动按需引入方式,这个问题不太存在,但如果你在main.js里做app.use(uviewPlus)全量注册,体积会膨胀得离谱。建议改成easycom自动引入,配合uni_modules的按需机制,包体积能降 30% 以上。
3.3 跨域调试与本地环境配置
“uniapp如何配置跨域”也是一个高频问题。说实话,小程序不像浏览器有跨域限制,但它在开发工具有一个合法域名的校验机制。默认情况下,uni.request请求的域名必须在小程序后台配置过合法域名,否则请求直接被拦截。
开发阶段的解法有两层:
- 最快捷的做法:在微信开发者工具右上角“详情 → 本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。这样本地就能请求任何 HTTPS 接口。但这个方法只在开发工具里有效,真机调试还是会拦。
- 标准做法:在小程序后台的“开发管理 → 开发设置 → 服务器域名”里,把 request 合法域名配置成你的后端域名。注意:必须是 HTTPS,且不能带路径,不能有 IP(除非是 localhost)。
如果后端同时要支持 H5 端,那就要在开发时配置 devServer 代理。我用的是 HBuilderX,manifest.json里配一个h5选项的devServer.proxy:
{ "h5": { "devServer": { "proxy": { "/api": { "target": "http://localhost:8080", "changeOrigin": true } } } } }这里再提醒一句:开发工具勾选了“不校验合法域名”后,真机预览还是经常请求失败。原因是真机跑的是正式逻辑,不走开发工具的豁免。所以最稳妥的路径是:尽早把后端域名申请成 HTTPS,并且在微信后台配好合法域名,然后真机调试直接用正式域名。本地开发环境如果需要请求内网测试域名,可以用 vConsole 看报错信息来判断是跨域拦截还是服务端 5xx。
3.4 日志不打印与调试技巧
“uniapp 不打印日志信息”,这个问题我排查过整整一个下午。最终原因分三类:
- console.log 被生产环境过滤掉:如果代码里用了
if (process.env.NODE_ENV === 'production')包裹日志,或者项目里配置了 eslint 的 no-console 规则并且编译插件是生产模式,日志就不会输出。解决办法是封装一个logger.js,只在开发环境打印,生产环境用上报替代。 - 小程序开发者工具的 vConsole 没开:真机调试时,手机上需要打开右下角的 vConsole 气泡才能看到 console。有时候你手机上没有 vConsole 按钮,是因为小程序基础库版本低或者开启了“不显示 vConsole”开关。
- 日志被用户等级过滤:微信开发者工具的 console 面板默认会有等级过滤,如果你点的是 Error,而你的 console.log 是 Log,自然看不到。
我最终的方案是:开发环境统一用封装的 logger,带上模块前缀,比如[cart] xxx,这样在开发者工具的控制台里能通过关键词筛出自己的日志。生产环境则把关键路径(登录、下单、支付回调)的日志上报到服务端,用于排查真实用户的问题。
4. 常见问题与排查技巧实录
4.1 uView Plus 插件市场导入失败
uView Plus 是 UniApp 生态里最火的组件库,但插件市场导入失败的问题非常普遍。我遇到过两种情况:
- 版本兼容问题:uView Plus 有两个主版本,分别适配 Vue2 和 Vue3。如果你项目是 Vue3 + Vite,必须装
uview-plus并且要手动在main.js引入样式文件import 'uview-plus/index.scss'。这个样式文件漏引入了,组件会渲染出一堆无样式的裸 DOM,看起来就像组件库没生效。 - easycom 规则冲突:项目如果手动配置了
easycom,要保证uview-plus的组件命名空间没被覆盖。检查pages.json里是否有多余的 easycom 配置,或uni.scss里是否有同名 class。
我的建议是:不要从插件市场直接下载依赖包到项目里,而是通过 HBuilderX 的“插件市场 → 导入插件”方式安装,并且严格核对版本描述。如果是 CLI 创建的 uni-app 项目,则用 npm 安装uview-plus,然后在pages.json配置 easycom。
4.2 打包上架与隐私权限
“uniapp上架安卓应用市场”这个热搜词说明很多人卡在了上架环节。实际上微信小程序和安卓应用市场是两回事:小程序上架在微信后台,不需要签名;如果是 App 端打包上架各大安卓市场,则需要处理权限申请、隐私声明和签名文件。
先说小程序端:提交审核时,微信会检查你有未使用的隐私接口。如果你代码里声明了scope.userLocation定位权限,但实际没用定位功能,会被拒绝。解决方案是:检查pages.json里的permission配置,以及manifest.json里 App 模块配置,只保留真正用到的权限。
另外一个高发的点是:测试号发布审核时无法使用真机支付。微信支付的审核要求“具备支付功能的小程序必须完成微信认证”,如果主体没认证,支付功能在审核环境会报invalid pay params。很多人第一次提审都被这个坑过,解决方案是提前完成微信认证,并在小程序后台配置号支付商户号。
4.3 Tabbar 被输入法顶起
“uniapp tabbar输入法顶起”是个比较冷门的问题。出现场景是:用户在进行搜索时,键盘弹起将页面底部的内容往上顶,包括 tabbar。在微信小程序里,输入框聚焦时,键盘默认会挤压页面,tabbar 有时也会跟着被顶起来。
解决方案是在pages.json对应的搜索页里设置自定义 tabbar,或者给页面开启"disableScroll": true,然后改用uni.pageScrollTo来做滚动。
其实我最终是这么解决的:把搜索这个动作放到一个单独的页面,这个页面不需要关心 tabbar,直接用一个绝对定位的底部搜索按钮,而不是挂在全局 tabbar 上。这样既符合交互习惯,又彻底避开了输入法顶起 tabbar 的问题。
4.4 页面生命周期与 onShow 的坑
页面列表的“加载更多”如果没有处理好,还有另一个隐藏坑:从商品详情返回列表页时,列表的状态被重置了。因为页面在onUnload时会销毁,onHide时不会销毁,但你如果用<script setup>里的普通变量存列表数据,返回时组件会被重新初始化。
这时候正确的做法是:列表数据用页面级全局变量跨生命周期存储,或者使用 Pinia / 页面栈缓存。我采用了 Pinia 存商品列表的原始数据,再在onShow里判断当前是“首次进入”还是“从详情返回”,如果是后者,直接展示缓存数据而不重新请求。
这样做的最终效果是:用户从列表点了某个商品,看了详情,返回列表时,列表停留位置和滚动条高度不会丢失,而且不会触发重新加载。在电商场景里,这个体验细节对留存数据的影响很直接。
5. 经验总结与后续扩展
整个项目做下来,我最大的感受是:小程序商城的技术难度往往不在“写代码”本身,而在“边界处理”。边界指的是:不同机型的适配、网络异常的状态恢复、支付结果的确认、登录态的刷新。这些场景的代码量不大,但如果没有提前设计好,后期会消耗大量的修复时间。
有几个具体建议,送给准备动手的同学:
- 登录态一定要设计成可刷新的,token 过期不能只清缓存,要静默重新走一遍 code2session 流程。
- 购物车和订单的状态变化不能只靠前端,要以服务端返回为准,同时做一个本地补偿机制。
- 包体积是最容易被忽视的拦路虎,建项目第一天就要把图片压缩、未使用组件的排查、分包规划定成规范,而不是最后几天才做。
- 德克蓝牙定位、视频播放、息屏播报这些非核心需求,不要自己做,优先找成熟的 uni_modules 插件,哪怕付费也是划算的,因为它帮你省的是最不该浪费的业务时间。
如果这个项目还要继续做下去,我的优先级排序是:先接入埋点统计,把用户从首页到下单的全链路数据打通;然后做消息订阅,在订单状态变更时给用户推送通知;最后才是上新促销活动,因为有了数据底座,活动带来的效果才能被量化。
最后再分享一个我从这个项目里提炼出来的小技巧:每个页面都要在onUnload里取消未完成的网络请求。用uni.request包一层请求管理器,页面销毁时统一abort。这个习惯能显著减少页面切换时的卡顿,尤其是微信小程序这种单页容器,页面残留请求多了,内存涨得飞快,最终用户感受到的就是卡顿和杀进程。小细节做到位了,整个项目的质感就上来了。