ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue 3 + Vant 4 移动端购物车实战:从 vw 适配到路由状态管理

2026/9/9 22:53:00 拓冰建站 浏览量
Vue 3 + Vant 4 移动端购物车实战:从 vw 适配到路由状态管理 从购物车开始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 边界情况下如何保留pxvw方案虽然省事但在两种场景下需要特殊处理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 vant4 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-barprice属性传入总金额button-text定义按钮文字submit监听结算按钮点击事件。van-submit-bar会自动做安全区适配iPhone X之类的全面屏手机会自动避开底部Home条省掉你手动处理环境变量的步骤。商品数量调整用van-stepper它自带加减按钮和输入框通过v-model绑定商品数量设置min1和integer属性减少输入的容错成本。3.3 一个完整的商品单元格实现拿商品列表的单个条目来说模板大致长这样van-swipe-cell div classcart-item van-checkbox v-modelitem.checked / img :srcitem.image classitem-image / div classitem-info div classitem-title{{ item.title }}/div div classitem-sku{{ item.sku }}/div div classitem-price-row span classitem-price{{ item.price }}/span van-stepper v-modelitem.quantity min1 integer / /div /div /div template #right van-button square typedanger text删除 clickonDelete(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.$subscribecartStore.$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。定义两个getterconst 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的totalCountvan-tabbar v-modelactive van-tabbar-item namehome iconhome to/home首页/van-tabbar-item van-tabbar-item namecart iconcart-o to/cart :badgecartStore.totalCount || 购物车 /van-tabbar-item /van-tabbartotalCount为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时如何处理如果不设置min1van-stepper允许用户把数量减到0。购物车里数量为0的商品还留在列表里非常奇怪。两种处理策略一是设置min1从源头禁止数量归零删除只能走滑动的删除按钮二是在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选择、优惠券计算、库存校验这些电商模块组件库和状态管理的框架都已经铺好了扩展起来只是顺着思路往里填东西而已。