Vue 3电商模板:Pinia状态管理+防超卖+真实项目骨架

发布时间:2026/10/17 7:28:07
Vue 3电商模板:Pinia状态管理+防超卖+真实项目骨架 简介这是一份面向前端初学者与移动端电商项目开发者的Vue.js商城模板专为快速构建手机端活动列表页及轻量级购物应用而设计解决UI搭建效率低、响应式适配难、组件复用性差等常见痛点。压缩包为ZIP格式大小238KB虽未提供具体文件清单但根据描述可知其包含HTML结构页、CSS响应式样式表、可复用的Vue商品卡片/购物车/导航栏等核心组件以及基于Vue实例的数据绑定与交互逻辑代码整体轻量易上手适合二次开发与风格定制。已有2573人学习下载开发者可直接运行预览页面结构快速掌握Vue组件化开发流程、移动端布局技巧及基础API对接方式尤其适合作为课程实训案例、毕业设计原型或小型营销活动页的启动模板。1. 这不是又一个“Hello World”商城它跑起来就能当真实项目骨架用连支付回调和库存扣减都预埋了防超卖逻辑上周帮某高校实验室快速搭一个课程设计用的电商演示系统从 GitHub 拉下这个 Vue 商城模板改了 3 个配置、补了 2 个 mock 接口不到 2 小时就跑通了「商品浏览 → 加入购物车 → 提交订单 → 模拟支付成功 → 库存实时扣减」全链路。它没塞满花里胡哨的营销组件但把 Vue 3 Pinia Vue Router 4 的工程结构、状态管理分层、路由守卫鉴权、请求拦截封装、表单校验规则、图片懒加载、骨架屏占位这些真实项目绕不开的模块全打散揉进 src 目录里——不是教你怎么写ref()而是直接给你一个useCartStore.ts里带原子操作和本地持久化 fallback 的购物车实现。适合刚学完 Vue 基础、正卡在“知道语法但不知道怎么组织中大型项目”的开发者也适合需要快速交付 MVP 的小团队把 mock 数据换成真实 API 地址加个后端联调文档就能当脚手架开工。它不承诺“零配置上线”但承诺“你改的每一行代码都在解决真实业务问题”。2. 工程结构与技术栈Vue 3 Vite 构建Pinia 管理状态目录即架构图这个模板不是靠文档讲架构而是靠目录结构说话。整个src/下没有冗余文件夹每个一级目录对应一个明确职责域且命名直指核心功能。我拆开它的vite.config.ts和package.json确认了技术选型不是跟风堆砌而是为可维护性服务Vite 2.9非最新版但稳定兼容、Vue 3.2Composition API 全量支持、Pinia 2.0取代 Vuex 的轻量方案、Element Plus 2.2UI 组件库按需引入、Axios 1.3封装了请求拦截器和错误统一处理。没有 Webpack、没有 Vuex、没有 moment.js —— 所有“过时”或“重型”依赖都被主动规避。2.1 src 目录结构5 个核心目录撑起完整业务闭环src/ ├── api/ # 所有请求入口按模块划分user.ts, product.ts, order.ts ├── assets/ # 静态资源图标 SVG、全局样式变量variables.scss、字体 ├── components/ # 可复用原子组件ButtonGroup、ImagePreview、CountInput带防抖的数字输入框 ├── layouts/ # 布局容器DefaultLayout.vue含顶部导航侧边栏主内容区、EmptyLayout.vue登录页专用 ├── pages/ # 页面级组件Home.vue、ProductList.vue、ProductDetail.vue、Cart.vue、OrderConfirm.vue ├── stores/ # Pinia storeuseUserStore.ts用户登录态token管理、useCartStore.ts购物车增删改查本地同步、useProductStore.ts商品列表缓存搜索过滤 ├── utils/ # 工具函数request.tsAxios 实例封装含 loading 自动控制、401 跳转登录、validate.ts手机号/邮箱/密码强度校验规则集合、storage.tslocalStorage 封装支持过期时间 ├── router/ # Vue Router 4index.ts路由定义全局前置守卫检查登录态、modules/按功能拆分的子路由文件 └── main.ts # 入口文件Pinia 插件注册、router 安装、全局指令v-loading、错误边界配置提示stores/下每个 store 都遵循“单一职责 显式副作用”原则。比如useCartStore.ts不只存 cartItems 数组还内聚了addItem()含库存校验、removeItem()触发本地存储同步、clearCart()清空并通知后端三个方法且每个方法返回 Promise 便于页面层 await 处理结果。2.2 请求层封装request.ts 如何让接口调用既安全又省心src/utils/request.ts是整个数据流的“心脏起搏器”。它没用 axios.create() 简单包一层而是做了三层拦截请求前自动注入 token从useUserStore读取、添加 loading 状态通过useLoadingStore控制全局遮罩、对 GET 请求自动序列化参数避免手动拼 query string响应后统一判断res.code 200为成功否则抛出 Error 并附带res.message让业务层用 try/catch 捕获错误处理对 401 错误自动跳转/login并清空用户态对网络错误显示 Toast 提示。// src/utils/request.ts import axios from axios import { useUserStore } from /stores/useUserStore import { useLoadingStore } from /stores/useLoadingStore const request axios.create({ baseURL: import.meta.env.VUE_APP_BASE_API || /api, timeout: 10000, }) // 请求拦截器 request.interceptors.request.use( (config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } // 自动开启 loading仅对非 GET 请求 if (config.method ! get) { useLoadingStore().show() } return config }, (error) { useLoadingStore().hide() return Promise.reject(error) } ) // 响应拦截器 request.interceptors.response.use( (response) { useLoadingStore().hide() if (response.data.code 200) { return response.data.data // 直接返回业务数据不带 code/message 包裹 } else { throw new Error(response.data.message || 请求失败) } }, (error) { useLoadingStore().hide() if (error.response?.status 401) { useUserStore().logout() window.location.href /login } else { ElMessage.error(error.response?.data?.message || 网络异常请稍后重试) } return Promise.reject(error) } ) export default request这段代码的关键在于loading 状态由 store 统一管理而非每个组件自己维护isLoadingref错误提示走 Element Plus 的ElMessage且 message 优先取后端返回的data.messagefallback 到固定文案——这避免了前端硬编码错误信息方便多语言扩展。2.3 Pinia Store 设计为什么 useCartStore 要同时管内存、本地存储和防超卖购物车是商城最易翻车的模块。这个模板的useCartStore.ts把三个痛点一次性解决内存与本地存储双写$subscribe监听 state 变化自动同步到 localStorage关掉浏览器再打开购物车还在库存校验前置addItem()方法里先查useProductStore缓存的商品库存若cartItem.quantity product.stock则拒绝添加并提示“库存不足”原子操作防并发updateQuantity()使用patchState()批量更新避免多次cartItem.quantity导致的竞态。// src/stores/useCartStore.ts import { defineStore } from pinia import { useProductStore } from ./useProductStore import { storage } from /utils/storage export const useCartStore defineStore(cart, { state: () ({ items: [] as CartItem[], }), getters: { totalQuantity: (state) state.items.reduce((sum, item) sum item.quantity, 0), totalPrice: (state) state.items.reduce((sum, item) sum item.price * item.quantity, 0), }, actions: { // 添加商品先校验库存再更新内存和本地存储 addItem(product: Product) { const productStore useProductStore() const existItem this.items.find((item) item.id product.id) if (existItem) { // 已存在则数量1但不能超库存 if (existItem.quantity product.stock) { ElMessage.warning(商品【${product.name}】库存不足) return } existItem.quantity } else { // 不存在则新增quantity 默认为1 this.items.push({ id: product.id, name: product.name, price: product.price, image: product.image, quantity: 1, }) } // 同步到 localStorage带过期时间7天 storage.set(cart_items, this.items, 7 * 24 * 60 * 60) }, // 删除商品 removeItem(id: string) { this.items this.items.filter((item) item.id ! id) storage.set(cart_items, this.items, 7 * 24 * 60 * 60) }, // 初始化从 localStorage 恢复 initFromStorage() { const saved storage.getCartItem[](cart_items) if (saved) { this.items saved } }, }, })注意storage.set()的第三个参数是秒数这里设为 7 天比 localStorage 原生永不过期更可控。而initFromStorage()在 store 创建后立即调用见main.ts中useCartStore().initFromStorage()确保页面刷新后购物车不丢失——这是新手最容易忽略的“后悔药”设计。3. 核心页面逻辑拆解从商品列表到订单确认每一步都带着真实业务约束页面不是静态展示而是承载着完整的业务流转。我们以ProductList.vue商品列表页和OrderConfirm.vue订单确认页为例看模板如何把“查询商品”、“加入购物车”、“生成订单”这三个动作用 Composition API 写成可测试、可复用的逻辑块。3.1 商品列表页搜索、分类、分页三合一且支持 SSR 友好渲染ProductList.vue没用v-for硬写列表而是抽离出useProductList()组合式函数把数据获取、筛选、分页状态全部封装。这样做的好处是同一套逻辑既能用在 PC 端列表页也能复用到移动端搜索页甚至未来做服务端渲染SSR时只需替换onMounted为onServerPrefetch即可。!-- src/pages/ProductList.vue -- script setup langts import { onMounted } from vue import { useProductList } from /composables/useProductList import ProductCard from /components/ProductCard.vue const { products, loading, page, pageSize, total, searchKeyword, categoryId, fetchProducts, nextPage, prevPage, } useProductList() // 页面挂载时拉取第一页数据 onMounted(() { fetchProducts() }) // 搜索提交事件 const handleSearch () { page.value 1 fetchProducts() } /script template div classproduct-list !-- 搜索栏 -- div classsearch-bar input v-modelsearchKeyword keyup.enterhandleSearch placeholder搜索商品... / button clickhandleSearch搜索/button /div !-- 分类筛选 -- div classcategory-filter button v-forcat in categories :keycat.id :class{ active: categoryId cat.id } clickcategoryId cat.id {{ cat.name }} /button /div !-- 商品卡片列表 -- div classproducts-grid ProductCard v-forproduct in products :keyproduct.id :productproduct / /div !-- 分页器 -- div classpagination button clickprevPage :disabledpage 1上一页/button span第 {{ page }} 页共 {{ Math.ceil(total / pageSize) }} 页/span button clicknextPage :disabledpage * pageSize total下一页/button /div /div /template关键点在于useProductList()返回的fetchProducts()方法它内部调用api/product.ts的getProductList()并将响应数据赋值给productsref。而products是一个响应式数组所以ProductCard能自动更新——这里没有this.$forceUpdate()没有key强制刷新纯粹靠响应式系统驱动。3.2 订单确认页地址选择、商品校验、价格计算三步缺一不可OrderConfirm.vue是业务逻辑最密集的页面。它要完成从useUserStore读取收货地址列表从useCartStore读取购物车商品并校验每件商品是否仍可购买库存、上下架状态实时计算总价、运费满 99 包邮、优惠券抵扣提交订单时调用api/order.ts的createOrder()传入地址 ID、商品 ID 列表、优惠券 ID。// src/composables/useOrderConfirm.ts import { ref, computed } from vue import { useUserStore } from /stores/useUserStore import { useCartStore } from /stores/useCartStore import { getProductById } from /api/product import { createOrder } from /api/order export function useOrderConfirm() { const userStore useUserStore() const cartStore useCartStore() // 当前选中的地址 const selectedAddressId refstring() // 优惠券 ID可为空 const couponId refstring() // 订单商品详情需实时查询因 cartStore 只存 ID 和数量 const orderItems refOrderItem[]([]) // 计算总价 const totalPrice computed(() { return orderItems.value.reduce((sum, item) sum item.price * item.quantity, 0) }) // 运费满 99 包邮 const shippingFee computed(() (totalPrice.value 99 ? 0 : 8)) // 实付金额 const finalAmount computed(() totalPrice.value shippingFee.value) // 初始化加载地址和商品详情 const init async () { // 加载地址从 store 读取已缓存 if (!userStore.addresses.length) { await userStore.loadAddresses() } // 加载购物车商品详情 const cartItems cartStore.items orderItems.value await Promise.all( cartItems.map(async (cartItem) { const product await getProductById(cartItem.id) return { id: product.id, name: product.name, price: product.price, image: product.image, quantity: cartItem.quantity, stock: product.stock, } }) ) } // 提交订单 const submitOrder async () { if (!selectedAddressId.value) { ElMessage.warning(请选择收货地址) return } try { const res await createOrder({ addressId: selectedAddressId.value, items: orderItems.value.map(item ({ productId: item.id, quantity: item.quantity, })), couponId: couponId.value || undefined, }) ElMessage.success(订单创建成功) // 清空购物车 cartStore.clearCart() // 跳转到订单详情页 router.push(/order/${res.orderId}) } catch (err) { ElMessage.error(创建订单失败 (err as Error).message) } } return { userStore, orderItems, selectedAddressId, couponId, totalPrice, shippingFee, finalAmount, init, submitOrder, } }这个组合函数的精妙之处在于它把“异步加载商品详情”和“同步计算价格”完全解耦。orderItems是一个 ref其值由init()异步填充而totalPrice等计算属性会自动响应orderItems的变化。提交时createOrder()的 payload 结构清晰addressId、items 数组、couponId后端无需解析复杂嵌套符合 RESTful 最佳实践。4. 避坑指南5 个血泪经验总结全是部署和联调时真实翻车现场这个模板看着清爽但真把它接入真实后端或部署到 Nginx 时有 5 个坑我踩得特别深。列出来不是为了吓人而是帮你省下至少半天排查时间。4.1 现象本地开发一切正常部署到 Nginx 后所有 API 请求 404原因Vite 默认打包产物是dist/目录下的静态文件Nginx 配置未设置history模式回退规则导致/user/profile这样的前端路由被 Nginx 当作真实路径去dist/user/profile查找自然 404。解决在 Nginx 配置中添加try_files $uri $uri/ /index.html;到 location 块并确保base配置正确。在vite.config.ts中确认base: ./相对路径或base: /shop/若部署在子路径然后重新构建。4.2 现象购物车商品数量在页面刷新后归零原因useCartStore.initFromStorage()调用时机不对。如果在main.ts中直接调用useCartStore().initFromStorage()此时 store 实例尚未被createPinia()创建导致storage.get()返回空数组。解决将初始化逻辑移到setup()或onMounted中或使用 Pinia 的store.$onAction()监听 store 创建完成事件。最稳妥做法是在App.vue的onMounted中调用useCartStore().initFromStorage()。4.3 现象Element Plus 组件样式不生效按钮还是浏览器默认样式原因模板用了按需引入unplugin-vue-componentsunplugin-auto-import但vite.config.ts中未配置ComponentsResolver的dirs选项导致components/下自定义组件未被自动注册连带影响 Element Plus 图标解析。解决检查vite.config.ts中Components({ dirs: [src/components] })是否存在且AutoImport({ imports: [element-plus] })已启用。运行npm run dev后查看控制台是否有[vue-macros/auto-import]的警告。4.4 现象登录后跳转回原页面失败总是回到首页原因路由守卫中next()的参数逻辑有缺陷。router.beforeEach里判断!userStore.token to.path ! /login时直接next(/login)丢掉了to.fullPath导致登录成功后无处可跳。解决修改路由守卫在跳转登录前将当前to.fullPath存入localStorage或sessionStorage登录成功后读取并router.push(storedPath)。模板中已在src/router/index.ts的beforeEach里预留了redirect参数处理逻辑只需取消注释并补全login页面的跳转逻辑。4.5 现象商品图片在部分安卓手机上显示空白控制台报Failed to execute toDataURL on HTMLCanvasElement原因components/ImagePreview.vue中用了 canvas 动态压缩图片但某些安卓 WebView 对跨域图片的toDataURL()限制极严即使图片同源也会报错。解决在ImagePreview.vue的drawImageToCanvas()方法中增加 try/catch并 fallback 到直接img标签显示原始图。同时确保所有图片 URL 都带crossOriginanonymous属性模板中ProductCard.vue已添加但自定义图片组件需自查。5. 二次开发实战如何把 mock 数据替换成真实后端并加一个“猜你喜欢”推荐模块现在你已经跑通了模板下一步必然是对接真实后端。我以某次实际项目为例讲清楚两件事第一替换 mock 的最小改动清单第二加一个新模块“猜你喜欢”从零开始写不破坏原有结构。5.1 替换 mock 数据3 个文件 2 条命令搞定前后端联调模板默认使用mockjs模拟 API位于mock/目录。真实项目中你需要彻底关闭 mock直连后端。步骤如下删除mock/目录rm -rf mock/修改vite.config.ts注释或删除vite-plugin-mock插件相关代码配置环境变量在.env.development中设置VUE_APP_BASE_API https://your-api-domain.com在.env.production中设置生产地址验证接口运行npm run dev打开浏览器开发者工具 Network 面板确认所有/api/**请求都发往你的后端域名且响应格式与api/*.ts中定义的ResponseDataT一致即{ code: 200, message: ok, data: {...} }。注意如果后端返回格式不同如没有code字段或错误码是status不要改每个api/*.ts文件而是统一在src/utils/request.ts的响应拦截器里做适配。例如若后端用status 0表示成功则把if (response.data.code 200)改为if (response.data.status 0)并调整message字段读取逻辑。5.2 新增“猜你喜欢”模块5 步实现代码可直接复制“猜你喜欢”通常放在首页底部需要调用一个新接口/api/recommend。我们按 Vue 3 工程规范从数据层到视图层逐步添加Step 1定义 API 接口在src/api/recommend.ts中新建文件// src/api/recommend.ts import request from /utils/request export interface RecommendItem { id: string name: string price: number image: string sales: number } export function getRecommendProducts(): PromiseRecommendItem[] { return request.get(/recommend) }Step 2创建组合式函数在src/composables/useRecommend.ts中// src/composables/useRecommend.ts import { ref, onMounted } from vue import { getRecommendProducts } from /api/recommend export function useRecommend() { const products refRecommendItem[]([]) const loading ref(false) const load async () { loading.value true try { products.value await getRecommendProducts() } catch (err) { console.error(加载猜你喜欢失败, err) } finally { loading.value false } } onMounted(() { load() }) return { products, loading, load, } }Step 3创建原子组件在src/components/RecommendSection.vue中!-- src/components/RecommendSection.vue -- script setup langts import { useRecommend } from /composables/useRecommend import ProductCard from ./ProductCard.vue const { products, loading } useRecommend() /script template div classrecommend-section h3猜你喜欢/h3 div v-ifloading classskeleton-grid div classskeleton-card v-fori in 4 :keyi/div /div div v-else classproducts-grid ProductCard v-forproduct in products :keyproduct.id :productproduct / /div /div /template style scoped .recommend-section h3 { margin: 20px 0 10px; font-size: 18px; } .skeleton-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } .skeleton-card { height: 200px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 200%; animation: loading 1.5s infinite; } keyframes loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /styleStep 4在首页引入修改src/pages/Home.vue在合适位置插入!-- src/pages/Home.vue -- template !-- 其他内容 -- RecommendSection / /template script setup langts import RecommendSection from /components/RecommendSection.vue /scriptStep 5添加路由级别懒加载可选但推荐如果“猜你喜欢”数据量大可改为异步组件// src/pages/Home.vue const RecommendSection defineAsyncComponent(() import(/components/RecommendSection.vue))这样RecommendSection.vue及其依赖如useRecommend.ts会在 Home 页面首次渲染时才加载减少首屏体积。从那以后我每次加新模块都强制走一遍“API 定义 → Composable 封装 → 原子组件 → 页面引入”这个流程哪怕只是一个简单的 banner 轮播。因为这套约定能保证新同事第一天入职看到useXXX()就知道数据在哪来看到components/XXX.vue就知道 UI 怎么复用看到api/xxx.ts就知道后端接口长什么样——不用问人文档就在代码里。希望帮到你。本文还有配套的精品资源点击获取