Vue3电商实战:从商品列表到购物车登录的完整交易链路

发布时间:2026/10/7 11:08:58
Vue3电商实战:从商品列表到购物车登录的完整交易链路 做Vue3电商前台项目写到第三篇了。前两篇我们把工程化骨架搭完了Vite Vue3 Pinia Vue Router封装了axios请求层把首页的公共头部、底部、楼层模块都拆成了可复用组件。这一篇我想直接进入交易主链路商品列表 - 商品详情 - SKU选择 - 购物车 - 登录 - 结算前的准备。适合正在用Vue3做商城类项目的朋友也适合那些把基础语法过了一遍、但不知道怎么把组件通信、路由守卫、状态管理串起来做完整业务的人。这篇不会像文档那样一个API一个API地铺开讲而是按照电商项目真正开发的推进顺序来走列表页筛选参数怎么设计、SKU可选状态怎么算、购物车在未登录和已登录状态怎么切换、token刷新失败后怎么踢人。这些都是实际项目里绕不开的琐碎问题我尽量把踩过的坑和最终采取的方案写清楚。1. 在写第三篇之前先说清楚当前项目的进度很多朋友会误解项目实战系列的意义以为是从零开始教Vue3基础。前两篇我已经把Vite脚手架、目录分层、路由注册、axios二次封装、以及首页的公共组件全部处理完了这一篇默认你会Vue3组合式API的基础写法包括ref、reactive、computed、watch也默认你已经按前面的内容把项目跑起来了。1.1 当前目录结构长什么样我的项目结构一直保持得很扁平没有把目录拆得特别深因为电商前台的核心页面就那几个拆太深反而要翻很多层。目前是这样src ├── api # 接口请求模块 │ ├── product.js │ ├── cart.js │ └── user.js ├── assets # 静态资源 ├── components # 公共组件 │ ├── AppHeader.vue │ ├── AppFooter.vue │ ├── GoodsCard.vue │ └── CountStepper.vue ├── router │ └── index.js ├── stores │ ├── cart.js │ └── user.js ├── utils │ ├── request.js # axios封装 │ └── auth.js # token存取 ├── views │ ├── Home.vue │ ├── ProductList.vue │ ├── ProductDetail.vue │ ├── Cart.vue │ ├── Login.vue │ └── Checkout.vue └── main.jsviews按页面划分components里全部是跨页面复用的UI组件。api目录单独拎出来是为了避免在组件里直接写请求地址后续接口域名切换或者加统一处理逻辑只动一个地方就行。1.2 这一篇要打通的核心链路前两篇做完的首页本质上是一个流量入口用户能浏览、能点进楼层但到这里还不能买东西。所以这一篇的核心任务是把交易链路打通用户在商品列表页筛选商品点进详情页选择规格和数量加入购物车然后登录最后进入结算页面。按这个顺序写是因为前后依赖关系很强列表页要为详情页提供商品ID详情页要为购物车提供完整的SKU信息购物车又要依赖登录态来决定是否和后端同步。如果打乱顺序后面讲状态合并的时候会非常绕。2. 商品列表页把筛选条件当状态处理而不是每选一次就发请求列表页是电商前台最常见的搜索 筛选 分页组合但很多新手会写成一个误区每点一次筛选条件立即调用一次接口接口响应慢一点页面就疯狂抖动。实际上筛选条件的本质是页面状态应该先改状态再让状态的变化统一驱动数据请求。2.1 用路由query承载筛选状态我把列表页的筛选参数全部放到路由的query上而不是放到ref里。原因是用户点筛选后刷新页面筛选状态应该还在用户从列表页跳到详情页再返回筛选状态也应该还在。如果放在组件内部的ref里刷新就丢如果放在Pinia里刷新也丢放路由query里天然就能保留因为query本来就在URL上。比如价格排序、页码、分类ID这几个参数// 路由跳转改变筛选条件 const router useRouter() function changeSort(sortType) { router.push({ path: /product/list, query: { ...route.query, sort: sortType, page: 1 } }) }page重置为1是因为用户一旦改了排序方式继续停留在第5页是没有意义的——排序规则变了第5页已经不是原来的第5页。在列表页组件内部我用watch监听route.query的变化变化后重新拉取数据import { ref, watch } from vue import { useRoute } from vue-router import { getProductList } from /api/product const route useRoute() const list ref([]) const total ref(0) const loading ref(false) async function fetchList() { loading.value true const params { page: Number(route.query.page || 1), pageSize: 20, sort: route.query.sort || default, categoryId: route.query.categoryId || } // keyword从搜索页传过来 if (route.query.keyword) params.keyword route.query.keyword const res await getProductList(params) list.value res.list total.value res.total loading.value false } watch( () route.query, () { fetchList() }, { immediate: true } )这里要注意watch的immediate必须开否则组件第一次挂载时不会触发监听。有了这个设计列表页的筛选 - 重新请求 - 数据渲染的链路就非常清晰一切以URL为准组件本身不存任何中间状态。2.2 筛选面板交互请求不是按一下发一次而是要防抖筛选面板里最常见的是价格区间的输入框和排序按钮。排序按钮这种离散操作点击一次跳一次query问题不大。但价格区间、关键词搜索这种连续输入的场景如果不防抖用户敲一个字母就会触发一次请求体验和性能都会很糟糕。我封装了一个简单的防抖函数// utils/debounce.js export function debounce(fn, delay 300) { let timer null return function (...args) { if (timer) clearTimeout(timer) timer setTimeout(() { fn.apply(this, args) }, delay) } }在模板中绑定输入事件时这样用input typetext placeholder最低价 v-modelminPrice inputonMinPriceInput /import { debounce } from /utils/debounce function updatePriceQuery() { router.push({ query: { ...route.query, minPrice: minPrice.value || , page: 1 } }) } const onMinPriceInput debounce(updatePriceQuery, 400)为什么防抖延迟设400ms而不是300ms因为移动端输入法联想和快速连打的情况更复杂400ms刚好能覆盖大部分停顿后继续输入的场景又不会让用户觉得页面迟钝。实测下来这个值比300ms更稳一点。防抖后的请求触发频率大大降低配合上一步的watch route.query整个列表页就变成状态变化 - 防抖 - 更新URL - 监听URL - 发请求的稳定循环。2.3 商品卡片组件拆分与图片懒加载列表里的商品卡片我拆成了GoodsCard.vue公共组件因为首页、列表页、猜你喜欢模块都要用。卡片组件接收一个product对象内部渲染商品图、标题、价格、销量等字段点击事件通过emit抛出去由父组件决定跳转到详情页还是其他页面。图片懒加载是最容易忽略的性能点。商品列表少则二三十个多则上百个如果首屏全部加载原图流量和时间都浪费在用户根本看不到的区域。原生loadinglazy是目前最省事的方案不需要引入额外插件浏览器会自己判断图片进入视口后再加载template div classgoods-card clickgoDetail div classgoods-img img :srcproduct.picUrl :altproduct.title loadinglazy / /div div classgoods-title{{ product.title }}/div div classgoods-price span classprice-symbol¥/span {{ product.price }} /div div classgoods-sales已售 {{ product.sales }} 件/div /div /template图片懒加载的实际陷阱在于loadinglazy只对img标签生效如果商品图是背景图也就是通过cssbackground-image设置的lazy属性就没有效果必须用其他方案。所以我的商品图一律用img标签渲染这样既能拿到原生懒加载又能方便后续做CDN切换。3. 商品详情页SKU选择是本期最值得细写的部分列表页做好之后用户点开商品卡片就会进入详情页。详情页在电商项目里看着简单就一个商品信息展示但规格选择这个功能是很多新手容易写崩的。一个商品如果有颜色、尺寸、版本三个规格维度每个维度有3到4个选项组合起来就是几十种SKU。哪些SKU能选、哪些因为没库存不能选必须实时计算。3.1 动态路由与返回位置的滚动恢复商品详情页我用的是动态路由/product/detail/:id。在Vue Router里注册{ path: /product/detail/:id, name: ProductDetail, component: () import(/views/ProductDetail.vue) }动态路由传参的方式统一用params不要通过query传商品ID因为ID是定位资源的唯一标识语义上应该放在路径里。在详情页获取ID的方式是import { useRoute } from vue-router const route useRoute() const id route.params.id还有一个细节用户在列表页往下滚了几屏点进详情看完详情再返回列表发现列表页滚回顶部了。这个体验很差。我通常在router的scrollBehavior里做处理const router createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } return { top: 0 } } })savedPosition是浏览器自动记录的滚动位置返回时如果存在就恢复没有记录就回到顶部这是最自然的处理方式。很多项目为了记住滚动位置用各种奇技淫巧去缓存列表数据实际上Vue Router自带的savedPosition机制已经能解决绝大多数场景。3.2 SKU可选状态的计算从全量组合到实时判定SKU选择的核心问题是用户选了某些规格后其他规格能不能选比如一个手机壳有黑色、白色、透明三种颜色和iPhone 14、iPhone 15两个型号黑色iPhone 14有库存黑色iPhone 15没库存那用户先选了黑色之后iPhone 15这个规格选项就应该置灰。这个需求的处理思路是先拿到后端返回的SKU列表每一项包含规格组合和库存。数据结构大致长这样skuList: [ { specIds: [101, 201], stock: 100, price: 29.9 }, // 黑色 iPhone 14 { specIds: [102, 201], stock: 50, price: 29.9 }, // 白色 iPhone 14 { specIds: [101, 202], stock: 0, price: 29.9 }, // 黑色 iPhone 15无库存 ]规格维度的定义单独放在另一个字段里比如specList: [ { name: 颜色, values: [ { id: 101, name: 黑色 }, { id: 102, name: 白色 } ] }, { name: 型号, values: [ { id: 201, name: iPhone 14 }, { id: 202, name: iPhone 15 } ] } ]判断一个规格值在当前选中的其他规格条件下是否可选做法是把当前已选中的规格和待判断的规格组合成一个临时SKU标识去SKU列表里查这个组合是否存在且库存大于0。这里我写成一个计算属性import { computed } from vue function isSpecOptionAvailable(specValueId, selectedMap, skuList) { // 先复制一份当前选中结果再把待判断的规格加进去 const tempSpecs { ...selectedMap, [specValueId]: specValueId } return skuList.some((sku) { const skuIdSet new Set(sku.specIds) const selectedIds Object.values(tempSpecs) // 当前已经选中的规格必须全部在sku里 const allSelectedIncluded selectedIds.every((id) skuIdSet.has(id)) if (!allSelectedIncluded) return false // 如果当前还没有选全所有维度只要选的维度能匹配并且库存0即可 return sku.stock 0 }) }判断逻辑的核心不是要求用户选到的组合一定是一个完整的SKU而是我当前选的这些规格是否能组成一个真实存在且有库存的SKU。比如我选了颜色黑色还没选型号此时黑色任意一个型号只要有库存黑色就可选。如果用户选了黑色型号选iPhone 14那这个组合就是真正落到SKU上的能选、价格和库存也跟着显出来。在模板渲染时每个规格值都通过这个函数判断不可选的就加一个disabled类div v-forval in spec.values :keyval.id :class{ spec-item: true, spec-item--disabled: !isSpecOptionAvailable(val.id, selectedSpecMap, skuList) } clicktoggleSpec(val.id) {{ val.name }} /div一个容易被忽略的点是如果某个规格维度还没选另一个维度的选项判断会宽松很多。用户先点型号里的iPhone 14此时颜色里的黑色和白色只要各自和iPhone 14组合有库存就都可选但用户如果点颜色里的黑色此时iPhone 15这款因为和黑色组合库存为0就会立刻置灰。逻辑上是对的但实际交互中用户会觉得奇怪为什么我选了个黑色型号的iPhone 15突然不能点了原因就是我没有给用户明确的当前选中规格组合对应哪个SKU的反馈。所以我在SKU区域下放了一行实时反馈div classsku-status {{ selectedSku ? 已选: ${selectedSkuText} 库存${selectedSku.stock}件 : 请选择规格 }} /div这样用户看到置灰时马上会明白是库存原因而不是商品本身没这个型号。3.3 数量选择器最大值不能只看库存SKU选完之后用户才能选数量。数量组件的最大值不完全是库存值还要看单次限购数量。这个逻辑我直接放在详情页而不是数量组件里因为这是商品维度业务不是通用组件职责。const maxBuy computed(() { if (!selectedSku.value) return 1 const limit productInfo.value.limitCount || 99 return Math.min(selectedSku.value.stock, limit) })CountStepper.vue组件内部只接收max和v-model不做任何库存判断这样在购物车、结算页都能复用同一套加减逻辑。数量加减的点击事件上我也做了防抖防止用户快速点加减时连续触发多次状态更新和购物车价格计算虽然价格计算本身是同步的但后续如果接接口做库存预占防抖和节流就非常必要。4. 购物车模块未登录状态也可以用的本地购物车购物车是电商前台项目里状态最复杂的模块因为存在未登录和已登录两种状态。理想体验是用户没登录也能加购加购后去登录购物车内容不能丢还要和后端购物车合并。这个需求如果一开始没有设计好后期改动会非常痛苦。4.1 购物车Store的状态设计我用Pinia来管理购物车状态。购物车里每一项的数据结构是固定的// stores/cart.js import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.count, 0), selectedCount: (state) state.items .filter((item) item.checked) .reduce((sum, item) sum item.count, 0), totalPrice: (state) state.items .filter((item) item.checked) .reduce((sum, item) sum item.count * item.price, 0) }, actions: { addItem(product) { const existItem this.items.find( (item) item.skuId product.skuId ) if (existItem) { existItem.count product.count } else { this.items.push({ ...product }) } }, removeItem(skuId) { this.items this.items.filter((item) item.skuId ! skuId) }, toggleChecked(skuId) { const item this.items.find((item) item.skuId skuId) if (item) item.checked !item.checked } } })加购的时候要判断是否已经存在相同skuId的条目存在就累加数量不存在就新增一行。如果用skuId之外的字段来判断重复比如商品ID不同规格的同一商品会被错误合并这是一个很典型的细节错误。4.2 持久化手动localStorage还是插件购物车数据必须持久化否则用户刷新页面购物车就空了。市面上比较常用的是pinia-plugin-persistedstate插件一行配置就能把整个store同步到localStorage。但我最终选择了手动持久化原因有两个第一购物车store里后续要放用户ID、登录状态、合并状态等字段有些字段根本不需要持久化插件全量持久化会产生冗余数据。第二插件默认的序列化方式在某些场景下对Map、Set结构支持不够好而我在后面的SKU判断和历史记录里用到了Set不想为兼容性花额外时间。手动持久化的方式很直接// 在addItem、removeItem、toggleChecked等action里调用统一方法 function syncStorage() { localStorage.setItem(cart_items, JSON.stringify(this.items)) }在store初始化时读取本地const savedCart localStorage.getItem(cart_items) if (savedCart) { this.items JSON.parse(savedCart) }两种做法的对比方案优点缺点pinia-plugin-persistedstate接入快配置少全量序列化对Map/Set支持弱需要额外配置过滤手动localStorage可控颗粒度目标明确每次action后要手动同步容易遗漏新手阶段用插件没问题但项目里一旦有自定义数据结构建议还是手动写同步。毕竟购物车持久化的逻辑本身不复杂写一次也就十行上下胜在可控。4.3 登录后购物车合并前端合并还是后端合并购物车合并有两种做法。电商平台级项目一般是在后端做合并用户登录后前端把本地购物车的SKU列表和数量一次性发给后端接口后端把它们和用户账号下的已有购物车合并返回最新的购物车数据。这么做的好处是购物车数据以服务端为准换设备也不丢。我这边因为是前台项目演示采用前端合并的简化版// stores/cart.js async function mergeCartAfterLogin(userId) { const localItems this.items.filter((item) !item.synced) if (localItems.length 0) return const res await api.cart.mergeCart({ userId, cartItems: localItems.map((item) ({ skuId: item.skuId, count: item.count })) }) // 用后端返回的购物车顶掉本地购物车 this.items res.cartItems.map((item) ({ ...item, checked: true })) syncStorage() }合并时机放在登录成功之后。这里有一个交互细节合并完之后给用户一个轻提示已为你合并购物车中的商品让用户知道登录前加购的东西还在而不是被清空了。很多用户会因此以为加购失败产生客诉。5. 登录模块token 闭环与请求拦截购物车和登录状态是强关联的所以登录模块不能拖到后面。登录模块的核心不是登录页面本身而是token的存取、axios拦截、以及路由守卫。5.1 登录表单校验与提交登录表单通常包含用户名、密码有时候还有短信验证码。我用的是Element Plus的Form组件校验规则直接写在rules里const loginFormRef ref(null) const rules { username: [ { required: true, message: 请输入用户名, trigger: blur }, { min: 3, max: 20, message: 用户名长度在3到20个字符, trigger: blur } ], password: [ { required: true, message: 请输入密码, trigger: blur }, { min: 6, max: 32, message: 密码长度在6到32个字符, trigger: blur } ] } async function handleLogin() { await loginFormRef.value.validate() const { token, userInfo } await api.user.login(loginForm.value) // 保存token和用户信息 setToken(token) setUserInfo(userInfo) // 合并购物车 const cartStore useCartStore() await cartStore.mergeCartAfterLogin(userInfo.id) // 跳转回来源页 const redirect route.query.redirect || / router.replace(redirect) }validate()方法如果校验不通过会抛出异常所以直接用await包住就行不需要额外判断整个流程立刻短路。提交成功之后要先存token再合购物车最后才跳转。顺序不能乱如果先跳转购物车合并请求可能还没完成页面里展示的购物车数量就是旧的。5.2 axios请求拦截与401处理axios封装在utils/request.js里这是整个项目所有请求的必经之路。请求拦截器负责把token从localStorage取出来加到请求头import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) request.interceptors.request.use((config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } return config })响应拦截器要处理的不只是成功响应还有业务码和401。我们后端的约定是HTTP状态码2xx代表请求成功但业务层面用code字段区分code 0成功code 401表示token失效。request.interceptors.response.use( (response) { const res response.data if (res.code 0) { return res.data } if (res.code 401) { // token失效 clearToken() const redirect encodeURIComponent(router.currentRoute.value.fullPath) router.replace({ path: /login, query: { redirect } }) return Promise.reject(new Error(登录已过期)) } return Promise.reject(new Error(res.message || 请求失败)) }, (error) { return Promise.reject(error) } )这里有一个很重要的处理原则不要在拦截器里弹出乱七八糟的错误提示。像token失效这种场景用户正在结算页面突然弹一个登录过期的提示再加一个跳转体验很突兀。正确做法是静默清除token然后跳转登录页等用户重新登录后再通过redirect参数跳回原页面。我在项目里只在真正的接口错误比如500、网络异常时才统一提示一次401是静默处理。5.3 路由守卫不只是没登录就跳首页全局前置守卫的写法大家都会但有两个细节值得注意。第一个是白名单登录页、注册页、首页这些页面不需要登录权限不能所有页面都拦router.beforeEach((to, from, next) { const token getToken() const whiteList [/login, /register, /, /product/list] if (token) { next() } else { if (whiteList.includes(to.path)) { next() } else { next(/login?redirect${encodeURIComponent(to.fullPath)}) } } })第二个细节是redirect参数的处理。如果用户已经登录再访问/login页面应该直接跳回首页而不是让登录页闪一下if (token to.path /login) { next(/) return }这个判断放在所有逻辑的最前面能避免已登录用户重复看到登录页的问题。我在调试移动端浏览器时发现过这类问题用户登录成功token有了但某些原因点击了浏览器的前进或后退按钮又回到了登录页页面上还显示着登录表单很让人困惑。加了上述判断后这种情况就没了。6. 几个容易被忽略的上线细节交易主链路跑通后项目整体已经能用了但如果要真正上线我还有几个细节要跟大家分享。这些不算新技术但都是线上环境才会逼你想明白的问题。6.1 移动端适配方案我为什么选了vw电商项目移动端流量占比极高所以适配必须做。我在项目里选了vw适配方案没有引入rem的插件体系。原因很简单vw是纯CSS单位直接把设计稿的像素除以设计稿宽度再乘以100转换为vw。比如设计稿是750px宽那1px等于100 / 750 vw也就是0.1333vw。实际开发时我写了一个转换函数// utils/viewport.js export function pxToVw(px) { return ${(100 * px) / 375}vw }设计稿按375pxiPhone 6/7/8的宽度标准来转换的时候直接用这个函数包一层。这种方式比rem适配好在不需要依赖html的font-size也不会有动态脚本计算带来的编译期滞后问题。vw唯一的坑点是极端小屏比如特别老的安卓机上可能出现小数点精度不足导致的错位但现在的浏览器基本都支持得不错。6.2 图片资源与CDN前缀后台管理系统里图片路径可以直接写相对路径但前台项目上线后图片一般都放CDN。商品图片、轮播图、楼层图全部不要写死本地路径而是通过环境变量控制// src/config/index.js export const baseURL import.meta.env.VITE_API_BASE_URL export const cdnURL import.meta.env.VITE_CDN_URL在组件里拼接图片地址img :src${cdnURL}${product.picUrl} /本地开发时VITE_CDN_URL设置为空字符串线上设置为CDN域名。这个变量统一通过.env.development和.env.production配置文件区分不能写死否则换环境和换CDN厂商会非常痛苦。6.3 构建体积与首屏性能最后说一下打包优化。Vue3项目开发模式跑得很流畅但线上构建后资源体积经常让人惊讶。我在vite.config.js里做了两件事第一开启build.sourcemap为false生产环境不输出sourcemap文件减少服务器压力也防止源码泄露。第二手动分包把体积较大的第三方库单独拆出来利用浏览器缓存机制避免用户每次更新都重新下载体积最大的包// vite.config.js build: { rollupOptions: { output: { manualChunks: { vue: [vue, vue-router, pinia], element: [element-plus], vendor: [axios] } } } }为什么要把vue全家桶和element-plus单独拆出来因为这两个包更新频率低、体积大拆出后浏览器会在首次访问时分别缓存。以后代码更新vue相关包的hash如果没变用户就不用重新下载这大几百KB的资源首屏速度会快很多。其他业务代码的hash再变也只是影响小包。路由懒加载在之前的篇章已经做过这再次强调一下商品详情页、结算页这种非首屏页面必须用() import()方式懒加载不能一股脑全打进首屏包。特别是结算页用户不一定会访问提前加载就是纯浪费。最后分享一个我实际调项目时的小技巧在开发环境用vite --host启动后直接用手机连同一个局域网访问电脑的IP这样测移动端适配、点击响应、图片懒加载效果比在浏览器开发者工具里模拟真实得多。我遇到过好几次问题都是真机上暴露出来的——比如Android端输入框被键盘顶起、iOS端点击有300ms延迟、手机窄屏下购物车数量组件溢出等等。在PC浏览器DevTools里根本看不出来一上真机就全出来了。电商前台项目的体验差异大头都在移动端联调阶段早一点切到真机后续上线前的返工会少很多。