从购物车开始,Vue项目实战的第9天往往是最有成就感也最容易翻车的时候。今天这篇内容我围绕“购物车、项目、vant组件库、vw、路由”这五个关键词,把从零搭一个Vue 3 + Vant 4移动端购物车页面的完整过程拆开讲清楚。你不光能看到页面怎么画出来,还能知道为什么用vw做适配、路由在项目里到底扮演什么角色、购物车这套状态管理应当怎么设计才不会越写越乱。
这篇文章适合正在学Vue想找项目练手的人,也适合已经写过几个demo但对工程化适配方案和组件库落地不太熟悉的同学。我会把每一步的选择逻辑和踩坑记录都放进来,方便你直接照着做或者迁移到自己的项目里。
1. 项目整体设计与技术选型思路
1.1 为什么购物车是Vue项目练手的黄金场景
购物车这个场景在电商类应用里属于标准模块,但它绝不是表面上看到的"加几个按钮、显示个总数"那么简单。它天然包含列表渲染、组件通信、状态共享、路由跳转、数据持久化、交互反馈这六类Vue高频知识点,几乎是所有基础语法的集合体。
我个人的观点是:如果只做一个纯展示型页面,学不到什么核心东西;但把购物车完整跑通,你至少会逼着自己面对三个问题:商品勾选状态如何管理、数量变化后其他组件如何联动、刷新页面之后数据怎么保住。这三个问题分别对应了Vue的响应式原理、组件通信机制、本地存储配合状态管理的综合应用,任何一个点都是面试里高频出现的东西。
从项目组织方式来看,购物车又是一个独立的业务页面,和首页、商品详情、结算页之间有明确跳转关系,所以你不得不用路由去组织这些页面。这正好把"路由"这个抽象概念落地到具体场景中,比纯粹看文档理解深刻得多。
1.2 Vant组件库:为什么选它而不自己造轮子
在移动端H5项目里,组件库选型几乎绕不开Vant。它是做电商起家的有赞团队开源的组件库,里面的商品卡片、步进器、复选框、结算栏、弹出层这些组件完全是照着电商业务场景设计的,购物车页面需要的那几个核心组件它基本都覆盖了。
有的同学会用Element Plus去写移动端页面,但Element Plus是PC端设计语言体系的组件库,按钮、表单、布局在手机端的小屏交互下体验并不好。Vant在交互设计上专门考虑了触屏场景,比如滑动删除、底部安全区适配、点击态反馈,这些东西自己写要花大量时间,而且写出来大概率不如组件库稳定。
Vant还有一个优势是支持按需引入。现在的Vant 4配合unplugin-vue-components插件,组件在模板里被使用时自动注册,样式也会自动引入,构建产物里不会塞入用不到的组件代码。这意味着你可以放心引入几个组件,不用太担心包体积膨胀的问题。
1.3 vw适配方案的核心逻辑
移动端适配是个老生常谈的话题,方案也经历了多轮演变,从早期的媒体查询、rem + flexible,到现在的vw、甚至容器查询。我在这天的项目里选用vw方案,主要原因是它足够纯粹:vw是CSS原生支持的视口单位,1vw等于视口宽度的百分之一,不依赖JavaScript去动态计算根字号。
对比rem方案,vw省掉了rem.js或者amfe-flexible这类运行时脚本,也不需要监听窗口变化去调整根字体大小。CSS样式编译时通过PostCSS插件把设计稿里的px自动转换成vw,页面在任意宽度的设备上都会按比例伸缩。这种方案是目前性价比最高的移动端适配方式之一,尤其适合以H5为主的营销和电商页面。
1.4 路由在整个项目中的定位
这个购物车项目不是单页面内的自娱自乐,它需要从首页点进商品详情,再通过详情页"加入购物车"跳转到购物车页,购物车内结算时再跳到确认订单页。这个过程必须有路由来承接。
路由的价值不只是页面跳转,还承载了三个更重要的职责:参数传递、代码分割、导航守卫。比如从商品详情页跳到购物车页时需不需要携带商品ID,购物车页面需要用户登录时怎么拦截跳转,大页面如何通过懒加载按需下载。这些在项目规模变大之后会逐渐成为必须处理的问题,而购物车项目刚好是一个足够小而完整的载体,让你把路由的常用知识点都过一遍。
2. vw适配方案:从原理到完整配置
2.1 vw单位与设计稿的换算关系
理解vw适配,核心就一句话:让设计稿里的px值按比例映射到视口宽度上。假设设计师给出的是750px宽度的设计稿(这是安卓端最常用的设计稿宽度),那么:
- 视口宽度750px时,1vw = 7.5px
- 设计稿里一个宽375px的元素,对应50vw
- 设计稿里一个字体大小为28px的元素,对应3.733vw
如果设计稿是375px宽度(iPhone 6/7/8的尺寸),那换算关系变成:1vw = 3.75px,设计稿里28px的元素对应7.467vw。
这里有个关键选择:你的viewportWidth应该配置成设计稿宽度还是组件库宽度。Vant 4的组件样式是以375px设计稿为基础编写的,如果你的设计稿是750px,在PostCSS转换时就需要把viewportWidth设置成750,这样Vant内部样式的px值除以750才能得到正确比例。
2.2 用postcss-px-to-viewport实现自动转换
项目用的构建工具是Vite,配置PostCSS插件非常简单。在项目根目录的vite.config.ts文件里加一段配置即可:
// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import postcssPxToViewport from 'postcss-px-to-viewport' export default defineConfig({ plugins: [vue()], css: { postcss: { plugins: [ postcssPxToViewport({ viewportWidth: 375, unitPrecision: 5, viewportUnit: 'vw', selectorBlackList: [], minPixelValue: 1, mediaQuery: false }) ] } } })viewportWidth设置为375,单位精度5位小数,minPixelValue: 1表示1px以内的样式不转换,mediaQuery: false表示媒体查询里的px不转。这些参数都可以按需调整。
安装依赖时注意,postcss-px-to-viewport并没有内置在Vite里,需要单独安装:
npm install postcss-px-to-viewport -D2.3 边界情况下如何保留px
vw方案虽然省事,但在两种场景下需要特殊处理:1像素物理边框和最大页面宽度限制。
1像素的问题在于,手机屏幕的物理像素密度通常是CSS像素的2倍或3倍,如果设计稿要的是1px物理像素的细边框,转换成vw后很难精确对应。所以我建议在selectorBlackList里加上一个标记类名,比如.border-1px,然后在全局样式中单独处理:
.border-1px { border: 1px solid #ebedf0; }通过媒体查询针对-webkit-min-device-pixel-ratio: 2和3分别设置0.5px或0.333px的边框,这能保证视网膜屏上线条清晰。
最大页面宽度限制也是必须考虑的。vw方案下页面宽度永远是视口的100%,但如果你在iPad或PC浏览器上打开移动端H5,整个页面会被拉伸得非常宽。解决方法是给根容器设置max-width: 540px(也可以按设计需要设成640px),然后配合margin: 0 auto让页面居中。这样在超过最大宽度的设备上,页面不会继续膨胀。
2.4 字体是否也要转vw
关于字体,我的建议是正文和标题可以用vw统一处理,但价格数字、标签文字这类细粒度文本最好在组件内用固定px。因为vw字体的渲染在部分安卓机型的低分辨率屏幕上会出现字重变细的问题,而且以vw为单位的字号在小屏上算出来后经常出现非整数,导致文字渲染虚化。
实际项目里更稳妥的做法是:内容型文本用固定px,视觉型文本(比如大标题、促销标语)再用vw。这样既有全局的自适应能力,又不会在小字号场景下翻车。
3. Vant组件库实操:搭建购物车页面骨架
3.1 安装与自动按需引入配置
Vant 4需要Vue 3环境。在新项目里执行:
npm install vant@4 npm install -D unplugin-vue-components然后在vite.config.ts中加入自动按需引入的插件:
// vite.config.ts import Components from 'unplugin-vue-components/vite' import { VantResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), Components({ resolvers: [VantResolver()] }) ] })配置完成后,你在模板里写<van-checkbox>,插件就会自动帮你引入对应的组件和样式,完全不用手动import。这也是我推荐Vant 4搭配Vite用的原因,开发体验非常顺滑。
3.2 购物车页面的三个核心组件
购物车页面最基础的是三件套:顶部导航栏、商品列表、底部结算栏。
导航栏用van-nav-bar,设置title="购物车",左侧有返回箭头时添加left-arrow属性,点击事件通过@click-left监听。滑动删除商品使用van-swipe-cell,把van-checkbox、商品缩略图、标题、价格、van-stepper都放在滑动内容里,右滑之后露出删除按钮。
底部结算栏使用van-submit-bar,price属性传入总金额,button-text定义按钮文字,@submit监听结算按钮点击事件。van-submit-bar会自动做安全区适配,iPhone X之类的全面屏手机会自动避开底部Home条,省掉你手动处理环境变量的步骤。
商品数量调整用van-stepper,它自带加减按钮和输入框,通过v-model绑定商品数量,设置min="1"和integer属性,减少输入的容错成本。
3.3 一个完整的商品单元格实现
拿商品列表的单个条目来说,模板大致长这样:
<van-swipe-cell> <div class="cart-item"> <van-checkbox v-model="item.checked" /> <img :src="item.image" class="item-image" /> <div class="item-info"> <div class="item-title">{{ item.title }}</div> <div class="item-sku">{{ item.sku }}</div> <div class="item-price-row"> <span class="item-price">¥{{ item.price }}</span> <van-stepper v-model="item.quantity" min="1" integer /> </div> </div> </div> <template #right> <van-button square type="danger" text="删除" @click="onDelete(item)" /> </template> </van-swipe-cell>这里的关键不是代码本身,而是数据绑定方式。商品的checked状态直接绑定到item.checked上,数量直接绑定到item.quantity上,这样Vue的响应式系统会自动把状态同步到计算属性中,你不需要额外写事件去同步数据。
3.4 组件样式定制与主题覆盖
Vant的样式定制走的是CSS变量机制。比如你想把主题颜色从默认蓝色改成橙色,在全局样式文件里覆盖变量即可:
:root { --van-primary-color: #ff6b35; --van-checkbox-checked-icon-color: #ff6b35; --van-submit-bar-button-bg-color: #ff6b35; }如果你用的是Vite,建议把这类变量放在一个独立的theme.css文件里,在main.ts中引入。注意全局变量的覆盖顺序要晚于Vant的默认样式,否则可能被后加载的组件样式顶掉。Vite的CSS按引入顺序加载,手动引入的样式放在入口文件末尾一般能保证覆盖生效。
4. 路由设计:从页面导航到状态保持
4.1 项目页面结构与路由配置
这个购物车项目的页面数量不算多,但页面间的关系已经足够体现路由组织的思路。我规划了四个页面:
- 首页:商品列表入口,点击商品进入详情页
- 详情页:展示商品信息,操作"加入购物车"
- 购物车页:当前文章的核心页面
- 结算页:展示确认订单信息
路由配置如下:
// router/index.js const routes = [ { path: '/', redirect: '/home' }, { path: '/home', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '首页' } }, { path: '/detail/:id', name: 'Detail', component: () => import('@/views/Detail.vue'), meta: { title: '商品详情' } }, { path: '/cart', name: 'Cart', component: () => import('@/views/Cart.vue'), meta: { title: '购物车' } }, { path: '/checkout', name: 'Checkout', component: () => import('@/views/Checkout.vue'), meta: { title: '确认订单' } } ]这里有两个习惯值得一提:一是组件用箭头函数做动态导入,页面会在路由真正访问时才去加载对应的js文件,这就是路由懒加载,能显著缩小首屏包体积;二是每个路由都设置了meta.title,配合路由前置守卫可以把页面标题统一管理起来,避免在每个页面组件里重复写标题。
4.2 动态路由参数与query传参
从商品列表进入详情页,需要带上商品ID。两种方式都可以:
使用路径参数:
router.push({ name: 'Detail', params: { id: item.id } })详情页通过route.params.id读取参数。
使用查询参数:
router.push({ path: '/detail', query: { id: item.id, from: 'home' } })读取时用route.query.id。
这两种方式的区别在于:params的传参方式把ID作为URL路径的一部分,语义清晰,分享链接时参数不丢失;query适合携带搜索词、来源标记这类辅助参数,不污染核心路径。在我的项目里,商品详情页用params传ID,购物车跳转结算页时用query传一个来源标记,方便结算页知道用户是从哪个入口过来的。
4.3 路由守卫控制登录态
购物车页往往是需要登录才能访问的。最简单的做法是给购物车和结算页的路由加上meta: { requiresAuth: true }字段,然后在前置守卫中统一判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (token && to.path === '/login') { next({ path: '/' }) return } next() })登录成功后再通过redirect参数跳回原页面,这是最常见的登录态回跳逻辑。在这个练习项目里你可以先不接真实登录接口,用一个本地模拟的token值测试路由守卫的效果。
4.4 购物车状态为何不能只放在组件里
购物车数据有个特点:首页点击"加入购物车"之后,商品数据需要出现在购物车页面里;购物车页面修改了数量,结算页读取时要是最新值。如果数据只放在某个组件的data中,页面一切换就会销毁,数据全部丢失。
因此购物车状态需要提升到全局共享层。Vue 3下我推荐用Pinia,它的模块化结构和TypeScript支持都比Vuex更清爽,而且自带DevTools调试。购物车store的典型结构:
// store/cart.js import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ cartList: [] }), getters: { totalCount: (state) => state.cartList.reduce((sum, item) => sum + item.quantity, 0), totalPrice: (state) => state.cartList .filter((item) => item.checked) .reduce((sum, item) => sum + item.price * item.quantity, 0) }, actions: { addToCart(product) { const existing = this.cartList.find((item) => item.id === product.id) if (existing) { existing.quantity++ } else { this.cartList.push({ ...product, quantity: 1, checked: true }) } }, removeFromCart(id) { this.cartList = this.cartList.filter((item) => item.id !== id) } } })getters里的totalPrice和totalCount是自动随cartList响应式变化的,购物车页面的结算栏、角标数字、全选状态全都从这两个计算属性衍生,不需要在组件里维护第二份数据。
4.5 本地持久化避免刷新丢数据
状态管理解决了页面跳转带来丢失的问题,但浏览器一刷新,Pinia的状态就重置了。要真正撑起购物车场景,还需要本地持久化。
最简单的方案是手动订阅cartStore.$subscribe:
cartStore.$subscribe((mutation, state) => { localStorage.setItem('cart', JSON.stringify(state.cartList)) })初始化时读取本地数据:
const savedCart = localStorage.getItem('cart') if (savedCart) { cartStore.cartList = JSON.parse(savedCart) }也可以用pinia-plugin-persistedstate插件,配置后自动持久化,省去手写订阅逻辑。这个练习中我更推荐先手写一次,因为你能更直观地体会状态流的方向,以后再用插件心里也踏实。
5. 购物车核心功能实现细节
5.1 数据模型设计
购物车里的每个商品条目,我建议最小字段如下:
{ id: 101, title: '无线蓝牙耳机', image: 'https://img.example.com/earphone.jpg', price: 199.00, sku: '曜石黑 / 标准版', quantity: 1, checked: true, selectedSkuId: 11 }price统一存数字类型,注意不要用字符串,否则后续做加减乘除会出现隐式转换的隐蔽bug。quantity是整个页面交互频率最高的字段,它的每次变化都牵扯totalPrice重算,所以建议在接口返回时就做好字段清洗,别把后端给的多余字段全存进来。
checked字段是电商购物车里最容易设计混乱的地方。我的建议是把它放在每个商品对象内部,而不是维护一个独立的选中ID数组。选中ID数组的维护成本高,商品增删之后还需要同步清理数组,数据会双向不一致。直接在item.checked上改动,全选、反选、单选都只是一次赋值。
5.2 数量加减与价格计算
van-stepper绑定item.quantity后,组件内部加减会直接修改数据。但有一个隐藏的坑:如果商品数据是从接口异步获取的,在接口返回之前Stepper组件已经渲染了,此时item.quantity是undefined,组件会报错。初始数据要保证quantity有默认值。
价格计算方面,最直接的方式是:
totalPrice = cartList .filter((item) => item.checked) .reduce((sum, item) => sum + item.price * item.quantity, 0)但浮点数乘法在JavaScript里非常容易出现精度问题,比如0.1 + 0.2 = 0.30000000000000004。电商项目里金额计算的推荐做法是以分为单位存储和运算,展示时再转换为元。如果你接口返回的是元,可以在加入购物车时把price乘以100转成整数存进store,这样计算过程全部是整数运算,最后展示除以100并用toFixed(2)格式化。
我在代码里用的小技巧是写一个统一的金额格式化函数:
function formatPrice(priceInCents) { return (priceInCents / 100).toFixed(2) }这样购物车里所有价格展示都在同一个地方转换,避免各处重复toFixed导致格式不统一。
5.3 全选与半选联动
全选逻辑用getter推导,而不是手动在每个勾选事件里setState。定义两个getter:
const isAllChecked = computed(() => { return ( cartStore.cartList.length > 0 && cartStore.cartList.every((item) => item.checked) ) }) const isSomeChecked = computed(() => { return ( cartStore.cartList.some((item) => item.checked) && !isAllChecked.value ) })全选框的v-model绑定isAllChecked,点击时调用toggleAllaction。注意van-checkbox是全选逻辑时需要传:model-value而不是v-model,因为isAllChecked是计算属性,不能直接赋值。点击事件里根据当前状态取反:
function onToggleAll() { const target = !isAllChecked.value cartStore.cartList.forEach((item) => { item.checked = target }) }半选状态可以通过van-checkbox的indeterminate属性展示,这是一个原生HTML属性,表示复选框处于半选中的样式,常用来表示"部分商品已选中"。
5.4 删除确认与结算跳转
滑动删除商品时弹一个确认框,用van-dialog的快捷方法:
import { showConfirmDialog } from 'vant' async function onDelete(item) { try { await showConfirmDialog({ title: '删除商品', message: '确定将该商品移出购物车吗?' }) cartStore.removeFromCart(item.id) showToast('已删除') } catch { // 用户点击取消,不做处理 } }这里有个细节值得注意:showConfirmDialog的Promise在用户点击取消时会走reject分支,所以你必须在catch里留空处理,否则控制台会报Uncaught promise rejection。
结算按钮的点击事件要先判断当前是否有选中商品,如果没有选中商品就弹Toast提示,避免用户点了个寂寞。有选中商品时再跳转结算页:
function onSubmit() { if (cartStore.totalCount === 0) { showToast('购物车为空') return } if (cartStore.totalPrice === 0) { showToast('请先选择商品') return } router.push({ path: '/checkout', query: { from: 'cart' } }) }5.5 角标数字与页面标题联动
购物车Tab上的角标数字是电商项目的常见需求,而且这个数字不是写死的,要实时反映购物车商品总数量。放在路由的Tab栏上时,可以直接监听cartStore的totalCount:
<van-tabbar v-model="active"> <van-tabbar-item name="home" icon="home" to="/home">首页</van-tabbar-item> <van-tabbar-item name="cart" icon="cart-o" to="/cart" :badge="cartStore.totalCount || ''" > 购物车 </van-tabbar-item> </van-tabbar>totalCount为0时显示一个空字符串,Vant会自动隐藏badge,不需要手动判断。这个细节是平时很少在教程里被专门提到的,但实际项目里特别实用。
6. 常见问题与排查技巧实录
6.1 postcss-px-to-viewport转换导致第三方组件样式异常
一个经常遇到的问题:配置完vw适配后,Vant组件的样式在部分机型上会出现间距偏大或偏小的情况。原因通常在于viewportWidth配置和组件库内部设计稿宽度不一致。
Vant 4的组件样式以375px为基准,所以如果项目设计稿是750px,而你把viewportWidth设成了750,组件内部的px会被缩小一倍,反而出问题。解决办法有两个方向:一是保持viewportWidth: 375,设计稿视觉尺寸除以2后写px;二是使用postcss-px-to-viewport的include或exclude选项,只转换自己项目的样式文件,不对Vant的样式做转换。
在实际项目中我更推荐前者:统一设计规范,UI设计师按750px出图,开发时把一半的px值写进代码,组件库的375px基准自动换算。这样虽然写代码时多一步除法,但保持了整个工程样式处理的一致性,排查问题成本最低。
6.2 Vant组件按需引入后Dialog方法调用报错
在Vant 4中,showDialog、showToast这类函数式调用和组件式调用有些差异。如果你按需引入了组件但没有引入对应的函数样式,调showToast时页面会白屏或者无样式。
解决办法是全局引入Vant的样式(这在按需引入组件的同时保留函数样式是常用做法):
// main.ts import 'vant/lib/index.css'这样函数式组件就能正常显示,组件部分仍然由unplugin-vue-components按需引入,包体积不会因为这一行全量css而崩掉太多。
6.3 路由跳转到购物车页后数据不刷新
如果你把购物车store里的数据放在store的state里,并且做了本地持久化,跳转到购物车页后数据会正常读取。但有一个场景容易出问题:用户从详情页加入购物车,然后通过浏览器的返回按钮回到购物车页面,此时如果页面组件有缓存(比如用了KeepAlive),页面展示的可能是旧的store状态。
排查思路是先确认store中的数据是否已经更新,再确认页面是否命中缓存。如果命中KeepAlive,需要在onActivated钩子中重新同步一次数据。
6.4 商品数量为0时如何处理
如果不设置min="1",van-stepper允许用户把数量减到0。购物车里数量为0的商品还留在列表里非常奇怪。两种处理策略:一是设置min="1",从源头禁止数量归零,删除只能走滑动的删除按钮;二是在@change事件里判断数量,为0时弹确认框问是否删除。
我习惯用第一种,购物车的交互标准就是数量从1开始,用户想删商品用删除按钮,这样逻辑更清晰,不用在数量变化回调里做分支处理。
6.5 全选状态与商品列表不同步
如果全选框的状态没有用getter而是手动维护了一个isAllChecked变量,会出现一个经典bug:用户点击单个商品取消勾选后,isAllChecked还是true,全选框状态和新数据不一致。
解决这个问题没有捷径,核心思路就是让全选状态从商品列表推导,不单独维护副本身份的布尔值。组件模板里用计算属性绑定全选框状态,再单独处理点击事件,这是最不容易出错的写法。
6.6 本地存储空间写满
购物车如果支持大量数据,把所有商品明细都塞进localStorage,时间长了可能超出5MB的存储限制。更好的做法是只存商品ID和数量,页面加载后根据ID去请求商品详情接口。这样存储体积小,而且能保证购物车里的商品信息是最新的,价格改动也能及时同步。
练习项目中你可以先用完整数据存储,但心里要有这个意识:纯前端的购物车只是为了演示流程,生产环境的数据源一定在后端。
最后再分享两个我在实战中的小经验
第一,购物车的结算金额计算一定要用分作为整数单位,尽可能避免浮点数运算。我第一版偷懒直接用price * quantity算总额,结果在iOS 14的某些机型上出现了199.99 * 3 = 599.9699999999999这种诡异结果,页面显示金额会有非常细微的偏差。换成以分存储后这个问题再也没出现过。
第二,删除商品和修改数量的交互一定要加“反馈前置”。用户滑动删除后,只要确认弹窗一弹出来就立即更新本地状态,不要等接口返回成功后再改。因为移动端网络延迟不稳定,如果等接口成功才更新界面,用户会感觉界面“卡住”了。先改界面、再异步同步接口,失败时回滚,这种“乐观更新”的模式在购物车这类高频交互场景里体验好得多。
做完这个项目,你会发现自己对Vue整体流程的理解会上一个台阶。后面可以继续往里加Sku选择、优惠券计算、库存校验这些电商模块,组件库和状态管理的框架都已经铺好了,扩展起来只是顺着思路往里填东西而已。