简介:面向移动电商开发者的 Uniapp 购物商城移动端源码,基于 lilishop 电商系统构建,可编译打包为 iOS、Android 及各主流小程序,适合需要快速搭建商城前端、学习跨端开发流程或二次开发的中高级开发者。压缩包共 1591 个文件,约 35.89MB,以 Java 后端源码为主(1500 个),辅以 JPG/PNG 素材、XML/YML 配置、SQL 数据库脚本及 Dockerfile、Shell 脚本等部署文件,整体覆盖商城业务后端、接口配置与部署运维所需内容。预览中可见订单服务、商品索引、支付宝支付等核心模块,可帮助理解电商订单流转、商品搜索引擎索引构建和支付对接实现。已有 507 人学习下载,目录结构清晰,适合对照 lilishop 开源体系梳理由 Uniapp 移动端到后端服务的调用链路,作为电商项目实战参考。
1. 一套 uniapp 商城源码同时交付小程序、H5 和 App,值得吗
当电商项目的排期表上同时出现微信小程序、H5 商城、iOS/Android App 三个入口,而后端接口还没完全定稿时,很多团队的习惯是“先写小程序,后面再套壳”。实际上从移动终端项目的落地经验看,这套思路往往走不通:小程序里能顺畅跑的页面,App 端并不一定兼容;H5 里顺手用的 window 对象,小程序编译阶段直接报错。与其维护三套代码、三套发布节奏,不如在源头就统一技术栈,uniapp 商城源码解决的就是这个矛盾——业务代码收敛到同一份 Vue 单文件组件里,平台差异只通过条件编译暴露。本文适合手上已有后端接口、想用最短路径交付多端商城的前端工程师,读完你不仅能搭起一套可复用的商城源码结构,还能避开打包、登录、支付这几个最容易翻车的配置点。
2. 商城源码的工程结构设计:uniapp 的编译边界、目录划分与状态管理选型
2.1 为什么 uniapp 商城源码能跨端,关键在于编译期而不是运行期
先澄清一个常见误解:uniapp 不是运行时把一套 JS 翻译成多端代码,而是在编译期根据目标平台执行不同的转换流程。写在小程序端,编译器把 template 转成 WXML、style 转成 WXSS、script 里的 Vue 生命周期映射到小程序的生命周期;写到 H5 端产出标准 DOM 页面;写到 App 端,非 nvue 页面走 webview 渲染,nvue 页面则走原生渲染。
这意味着在商城源码里只要不调用端特有的 API,就能被编译到任意平台;一旦图省事直接调用 wx.getSystemInfoSync 这类原生 API,小程序上没问题,App 端编译时却未必报错,真机运行就直接白屏。这就是商城源码里最该遵守的第一条规则:所有端能力调用必须走 uni.* 统一 API,或放入条件编译分支。比如获取用户信息,老版本小程序用 uni.getUserProfile,App 端有独立的授权弹窗流程,如果在小程序里直接写 wx.login,H5 端调试时根本不报错,可真机一跑就挂。
一条典型的 uniapp 商城源码分层如下:
| 层级 | 放什么 | 典型文件 |
|---|---|---|
| 页面层 pages | 路由页面 | pages/index、pages/goods/detail |
| 组件层 components | 可复用 UI 与业务组件 | sku 选择器、商品卡片、订单状态条 |
| 状态层 store | 全局共享数据 | 购物车、登录态、收货地址 |
| 接口层 api | 请求封装与模块接口 | api/goods.js、api/order.js |
| 工具层 utils | 纯函数与格式处理 | 价格计算、日期格式化、防抖 |
2.2 商城源码目录怎么摆,订单模块和售后模块才不会互相踩
我一般建议把订单、商品、用户三个主流程拆成独立 api 文件,而不是把所有请求都堆在一个 request.js 里。商城需求迭代最快的往往是订单状态和售后流程,独立文件能降低互相改动的风险。参考结构:
src/ ├── pages/ │ ├── index/index.vue │ ├── goods/detail.vue │ ├── cart/cart.vue │ ├── order/ │ │ ├── list.vue │ │ ├── confirm.vue # 确认订单页 │ │ ├── pay-success.vue │ │ └── detail.vue │ └── user/ │ ├── index.vue │ ├── coupon.vue │ └── address/list.vue ├── components/ │ ├── sku-picker.vue │ └── goods-card.vue ├── store/ │ ├── index.js │ ├── cart.js │ └── user.js ├── api/ │ ├── goods.js │ ├── cart.js │ ├── order.js │ └── user.js ├── utils/ │ └── price.js ├── static/ └── pages.jsonpages 底下的目录名直接对应 pages.json 里的路由 path,不要额外加一层 views 包裹。uniapp 编译到小程序时会把路径原样映射成分包路径,层数越少,越不容易在“主包体积超 2MB”时手忙脚乱地改目录结构。组件层只放真正跨页复用的东西,像 sku-picker 这种只有商品详情页用到的组件,直接放在 pages/goods/components 下,连注册都省了。
2.3 购物车和登录态用 Vuex 还是 Pinia,取决于你的 Vue 版本
老项目的 uniapp 商城源码大多基于 Vue2,配 Vuex 3;新脚手架默认 Vue3 + Pinia。关键不在选哪个库,而在统一 store 的写入和持久化策略。商城场景里购物车最怕“多处修改不同步”,加购在详情页、改数量在购物车页、清空在订单生成后,三处对同一份数据操作,必须走同一个 mutation/action:
// store/cart.js —— Vue2 + Vuex 3 的写法 import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { cartList: uni.getStorageSync('cart') || [] }, mutations: { UPDATE_COUNT(state, { skuId, count }) { const item = state.cartList.find(i => i.skuId === skuId) if (!item) return item.count = count uni.setStorageSync('cart', state.cartList) // 本地缓存,App 杀掉进程后购物车还在 }, CLEAR_CART(state) { state.cartList = [] uni.removeStorageSync('cart') } } })这段代码有两个容易踩坑的位置。第一,mutation 里直接修改了已存在对象的 count 属性,Vuex 的响应式能追踪到;但如果换成整体替换数组,视图同样更新,问题只出在本地缓存写入频率——每次 mutation 都同步 setStorageSync,对商城这种高频加购操作开销偏大,常见做法是加个 debounce,或只在页面隐藏时写一次。第二,setStorageSync 在小程序端有总量限制,购物车对象里不要塞商品轮播图 base64,只存 skuId、数量、缩略图路径、价格这几个结算必需字段。
如果项目从 Vue2 迁到 Vue3,网络热词里高频出现的“uniapp vue2 转 vue3”,最大工作量并不是模板语法,而是全局状态挂载方式变了。Vue2 里通过 Vue.prototype 注入,Vue3 + Pinia 改成 app.use(pinia),想在组件外拿到 store,必须显式引入:
// 在 utils 或 api 模块里使用购物车 store import { useCartStore } from '@/store/cart' export function clearCartOutsideComponent() { const store = useCartStore() store.clear() }注意 useCartStore() 必须在 pinia 实例 install 完成之后调用,否则会报 getActivePinia was called but there was no active Pinia。老项目转新框架时,这个报错往往出现在 api 层拦截器里,调试起来第一眼根本看不出是时序问题。
3. 用 uniapp 商城源码跑通商品列表与购物车:请求封装、状态联动和价格精度
3.1 商品列表页的请求封装与加载态,直接 uni.request 还是二次封装
在每个页面里直接写 uni.request 不是不行,但商城接口通常有统一的 code 约定、token 注入和错误提示,散落各处后一旦后端把业务 code 从 0 改成 200,就得全局替换。我一般维护一个 utils/request.js 做统一出口:
// utils/request.js const BASE_URL = 'https://api.example.com' export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', Authorization: uni.getStorageSync('token') ? `Bearer ${uni.getStorageSync('token')}` : '' }, success: (res) => { if (res.statusCode !== 200) { uni.showToast({ title: '服务异常', icon: 'none' }) reject(new Error(`HTTP ${res.statusCode}`)) return } const body = res.data if (body.code === 0) { resolve(body.data) } else if (body.code === 401) { // token 失效:清除本地登录态并跳转登录页 uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject(new Error('登录态失效')) } else { uni.showToast({ title: body.msg || '请求失败', icon: 'none' }) reject(new Error(body.msg)) } }, fail: (err) => { uni.showToast({ title: '网络异常,请检查网络', icon: 'none' }) reject(err) } }) }) } // api/goods.js import { request } from '@/utils/request' export const fetchGoodsList = (data) => request({ url: '/goods/list', method: 'POST', data })成功回调里判断 body.code 而不是只看 HTTP 状态码,这是商城接口联调最常见的分水岭。网关返回 200 不代表业务成功,后端做服务降级时 code 可能是 5001,此时页面不应继续渲染商品列表。401 分支跳转登录页之前要先清 token,否则会出现登录页闪一下又被拦截器拉回详情页的循环。
3.2 商品详情进购物车的联动:SKU 选择器与 store 的通信边界
购物车的入口通常是商品详情页,用户选了 SKU 规格点加入购物车,这时不要把整个 SKU 选择器塞进全局 store。商城场景里 SKU 数据量可能很大,颜色加尺寸加套餐组合上百条,放进 Vuex 后再做本地缓存序列化非常浪费。常见做法是页面内部维护 currentSku 局部变量,只有确认加购后才把 skuId、数量、价格、缩略图交给 store:
// pages/goods/detail.vue(节选) import { useCartStore } from '@/store/cart' export default { data() { return { skuList: [], currentSku: null, quantity: 1 } }, methods: { onSkuChange(sku) { // sku 选择器每次切换规格都会触发,这里只更新局部变量 this.currentSku = sku }, addToCart() { if (!this.currentSku) { uni.showToast({ title: '请先选择规格', icon: 'none' }) return } const cartStore = useCartStore() cartStore.add({ skuId: this.currentSku.id, count: this.quantity, price: this.currentSku.price, thumb: this.currentSku.thumb }) uni.showToast({ title: '已加入购物车', icon: 'success' }) } } }如果项目还停留在 Vue2 + Vuex,把 useCartStore 换成 mapMutations 或 this.$store.commit 即可,通信边界不变。SKU 规格联动留在页面内部,数据流单向,页面切走自动释放;真正进 store 的只有结算字段,购物车角标订阅 store 后,任意页面加购都能即时刷新。
3.3 价格累计用整数分而不是浮点数,商城金额计算的三条硬规则
商城源码里金额相关的高频 bug 几乎都来自浮点运算。JS 里 0.1 + 0.2 等于 0.30000000000000004,商品数量一多,总价就差几分钱。不加依赖的稳妥做法是转分计算:
// utils/price.js // 元转分:先乘 100 再 Math.round,避免 parseFloat 的精度陷阱 export function yuanToFen(yuan) { return Math.round(parseFloat(yuan) * 100) } export function fenToYuan(fen) { return (fen / 100).toFixed(2) } export function calcTotal(list) { // list: [{ price: 19.9, count: 3 }] const totalFen = list.reduce((sum, item) => { return sum + yuanToFen(item.price) * item.count }, 0) return fenToYuan(totalFen) }| 场景 | 推荐单位 | 原因 |
|---|---|---|
| 后端下发价格 | 分(字符串或整型) | 避免 JSON 里浮点精度丢失 |
| 前端参与计算 | 分 | 加减乘除都在整数域内完成 |
| 界面展示 | 元 | 仅最后一步除以 100 再 toFixed(2) |
三条硬规则:所有下发到前端的金额后端统一给分;前端展示时才转元;优惠券、运费、满减全部在整数分域内计算。另外注意 toFixed 在 JS 里是四舍六入五成双,并非严格四舍五入。总价分转元后如果想四舍五入,先用 Math.round 再除以 100,不要直接写 (1.005).toFixed(2) 这类代码,返回结果可能和预期差一分钱。
4. 小程序端与 App 端的差异怎么处理:条件编译、登录支付与 manifest 配置
4.1 商城源码里的平台差异化代码,用 #ifdef 条件编译隔离而不是运行时判断
商城源码最典型的差异点在支付:微信小程序只能用 wx.requestPayment,App 端走 uni.requestPayment 的 App 支付通道,H5 可能对接支付宝网页支付或微信 JSAPI。如果写 if (platform === 'weixin') 这种运行时判断,所有分支的代码都会被编译进同一个包,而且微信小程序的 API 在 App 端未必存在,一调用就报错。正确解法是条件编译:
// api/pay.js function payByWxMiniProgram(orderId, amount) { return new Promise((resolve, reject) => { uni.requestPayment({ provider: 'wxpay', orderInfo: { orderId, amount }, success: (res) => resolve(res), fail: (err) => reject(err) }) }) } export function payOrder(orderId, amount) { // #ifdef MP-WEIXIN return payByWxMiniProgram(orderId, amount) // #endif // #ifdef APP-PLUS return uni.requestPayment({ provider: 'wxpay', orderInfo: { orderId, amount } }) // #endif // #ifdef H5 return payByH5(orderId, amount) // #endif }条件编译的注释不是普通注释,uniapp 编译器在预处理阶段会删除不匹配平台下的整块代码,所以其他端即使没有对应平台的声明也不会报错。调试时最容易让人困惑的是 IDE 报错提示找不到 wx 变量,这通常是编辑器没识别条件编译,把文件重新保存或确认文件后缀是 .vue,提示就消失了。
4.2 登录态在微信小程序和 App 上的差异:code 换 token 的流程不一样
微信小程序登录是 wx.login 拿 code,后端拿 code 换 openid 和自定义 token;App 端可能是微信开放平台授权、Apple 登录或手机号加验证码。uniapp 商城源码里如果共用同一个登录组件,平台登录按钮区域建议用条件编译隔离:
<!-- pages/login/index.vue 节选 --> <view class="login-form"> <input v-model="phone" type="number" placeholder="手机号" /> <input v-model="code" type="number" placeholder="验证码" /> <button @tap="loginByPhone">登录</button> </view> <!-- #ifdef MP-WEIXIN --> <button open-type="getPhoneNumber" @getphonenumber="wxPhoneLogin"> 微信一键登录 </button> <!-- #endif -->open-type="getPhoneNumber" 是微信小程序专有的按钮属性,App 端没有这个类型。如果不做条件编译,H5 端会把 getPhoneNumber 当普通 button 属性忽略,真机小程序上也没反应。把差异藏在编译期,各端拿到的是干净的模板。
最近折腾商城源码的团队常问 uniapp 扫码怎么接。小程序端扫码入口是 wx.scanCode,App 端是 uni.scanCode,回调参数略有不同。我的做法是全部走 uni.scanCode 统一封装,只有识别小程序码这类特殊场景才走条件编译,普通商品码、订单码用统一 API 足够。
4.3 manifest.json 不是摆设:微信小程序 AppID、App 包名与支付模块都要在这里配好
打开 uniapp 项目的 manifest.json,在小程序配置里填微信 AppID;在 App 模块配置里勾选 OAuth、Payment、Share、Push。很多新手问 uniapp 怎么打包,卡点就在这一步:manifest 里没勾选 Payment,真机调用 uni.requestPayment 会直接返回 API_NOT_FOUND。还有 iOS 的 URL Scheme 和 Android 的包名签名,如果要在 App 端拉起同主体的微信小程序或分享到微信,微信开放平台后台必须与 manifest 里的包名签名保持一致。
// manifest.json —— 关键字段节选 { "name": "商城演示项目", "appid": "", "mp-weixin": { "appid": "wx1234567890abcdef", "setting": { "urlCheck": false }, "usingComponents": true }, "app-plus": { "modules": { "OAuth": {}, "Payment": {}, "Share": {} }, "distribute": { "android": { "packagename": "com.example.mall", "permissions": [ "<uses-permission android:name=\"android.permission.INTERNET\"/>" ] } } } }manifest.json 修改后不是每次打包都会自动生效。小程序端改 AppID 后要重新编译,App 端改包名或勾选模块后建议在 HBuilderX 里执行重新编译运行。常见报错“请先在 manifest.json 里配置 appid”,指的就是这里的 mp-weixin.appid 字段为空。另外,App 端拉起微信小程序用的是 uni.navigateToMiniProgram,传入目标小程序的原始 ID,前提是当前 App 与目标小程序在同一微信开放平台账号下,这个绑定关系不在 manifest 里,而在微信开放平台后台。
5. 商城源码上线前最后一步:分包、缓存与多端打包验收清单
5.1 微信小程序主包超 2MB 时,用分包把下单流程拆出去
微信对主包体积限制是 2MB,整个小程序上限 20MB。商城源码光商品图就可能占掉大半,常规做法是把“下单、支付、售后”拆成分包,在 pages.json 的 subPackages 字段声明:
// pages.json 节选 { "pages": [ { "path": "pages/index/index" }, { "path": "pages/goods/detail" }, { "path": "pages/cart/cart" } ], "subPackages": [ { "root": "pagesOrder", "pages": [ { "path": "confirm/confirm" }, { "path": "pay/pay" }, { "path": "detail/detail" } ] } ], "preloadRule": { "pages/cart/cart": { "network": "all", "packages": ["pagesOrder"] } } }分包的限制在于 tabBar 页面不能放分包里,分包之间不能互相跳转。把确认订单、收银台、订单详情放同一个分包,再配合 preloadRule 做预下载,用户进购物车时就开始加载订单分包,点击结算跳转不白屏,兼顾首屏速度和下单路径体验。
5.2 动态标题与图片缓存:移动终端上被忽视的两个体验细节
商城源码里要把导航栏标题改成商品名,方法是用 uni.setNavigationBarTitle,它同时支持小程序、H5 和 App。但 onLoad 里如果页面还没完成挂载就调用,偶发会被页面原标题覆盖,稳妥做法是放在 onReady 之后或 nextTick 里。App 端如果想自定义原生导航栏,方式不是 setNavigationBarTitle,而是把 pages.json 里对应页面的 navigationStyle 设为 custom,再自己渲染头部。
图片缓存方面,商品图优先走 CDN 并开启懒加载。App 端可用 uni.getImageInfo 预取下一屏商品图,H5 端在 manifest.json 的 h5.publicPath 里配置 CDN 域名,小程序端则把 static 目录里图片压缩到最小。这三个端各做一次,首屏速度差的不是一点半点。
5.3 多端打包验收清单:H5、微信小程序、Android 各查一遍
| 检查项 | H5 | 微信小程序 | App |
|---|---|---|---|
| 登录 | 手机号 | 微信一键登录 + 手机号 | 第三方登录 + 手机号 |
| 支付 | 支付宝 / 微信 JSAPI | wx.requestPayment | uni.requestPayment |
| 分享 | 生成海报 | 转发按钮 | 微信 SDK / 系统分享 |
| 导航栏 | 网页标题 | 原生导航栏 | 原生或自定义导航栏 |
| 包体积 | Gzip 后 < 1MB | 主包 < 2MB | 按应用市场要求 |
验收时逐端真机测试,尤其支付回调:微信小程序支付成功后拿到的 errMsg 要包含 requestPayment:ok 再跳转,App 端支付回调路径不同,写死同一套代码过不了原生验收。最后还有一处最容易提升成交率的改进:在封装好的 request 层里定期刷新 token,或在每次响应后重写本地 token 的过期时间,避免用户算好满减准备付款时突然被登出。
本文还有配套的精品资源,点击获取