☰
Vue3电商实战:从商品列表到购物车登录的完整交易链路
2026/10/7 11:08:49 网站建设 项目流程

做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.js

views按页面划分,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 type="text" placeholder="最低价" v-model="minPrice" @input="onMinPriceInput" />
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抛出去,由父组件决定跳转到详情页还是其他页面。

图片懒加载是最容易忽略的性能点。商品列表少则二三十个,多则上百个,如果首屏全部加载原图,流量和时间都浪费在用户根本看不到的区域。原生loading="lazy"是目前最省事的方案,不需要引入额外插件,浏览器会自己判断图片进入视口后再加载:

<template> <div class="goods-card" @click="goDetail"> <div class="goods-img"> <img :src="product.picUrl" :alt="product.title" loading="lazy" /> </div> <div class="goods-title">{{ product.title }}</div> <div class="goods-price"> <span class="price-symbol">¥</span> {{ product.price }} </div> <div class="goods-sales">已售 {{ product.sales }} 件</div> </div> </template>

图片懒加载的实际陷阱在于,loading="lazy"只对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-for="val in spec.values" :key="val.id" :class="{ 'spec-item': true, 'spec-item--disabled': !isSpecOptionAvailable(val.id, selectedSpecMap, skuList) }" @click="toggleSpec(val.id)" > {{ val.name }} </div>

一个容易被忽略的点是:如果某个规格维度还没选,另一个维度的选项判断会宽松很多。用户先点"型号"里的iPhone 14,此时"颜色"里的黑色和白色只要各自和iPhone 14组合有库存就都可选;但用户如果点"颜色"里的黑色,此时iPhone 15这款因为和黑色组合库存为0,就会立刻置灰。逻辑上是对的,但实际交互中用户会觉得奇怪:为什么我选了个黑色,型号的iPhone 15突然不能点了?原因就是我没有给用户明确的"当前选中规格组合对应哪个SKU"的反馈。所以我在SKU区域下放了一行实时反馈:

<div class="sku-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` }

设计稿按375px(iPhone 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里根本看不出来,一上真机就全出来了。电商前台项目的体验差异,大头都在移动端,联调阶段早一点切到真机,后续上线前的返工会少很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询