简介:这是一套基于Vue 2构建的轻量级前端商城项目源码,面向前端初学者与Vue入门开发者,帮助其掌握单页应用(SPA)的核心开发流程与工程化实践。项目采用Vue + Vue Router + Axios技术栈,配合Django后端设计,完整呈现了商品列表、详情、购物车等典型电商模块的前端实现逻辑。资源包共83个文件,以50个.vue组件文件为主体,涵盖路由配置、API请求封装(utils/axios.js)、表单校验(validators.js)、UI组件(components/demo)等关键模块;辅以23个.js工具与配置脚本、2个PNG图标资源及基础工程配置文件(.babelrc、webpack.*.conf.js等),整体仅186KB,结构精简、开箱即用。目前已有1281人学习下载,读者可直接运行npm run dev启动本地调试环境,深入理解Vue组件通信、路由守卫、HTTP拦截与模块化组织方式,快速建立前端商城项目的完整认知框架。
1. vue-mall:一个能直接跑通、改完就能上线的 Vue 商城前端骨架
你是不是也试过 clone 一堆「Vue 商城」项目,npm install 后报 7 个错,router 配置里还夹着mode: 'history'却没配 nginx 回退规则,axios 拦截器里硬编码了http://localhost:3000/api,一换环境就白屏?vue-mall 不是教学 Demo,它是一套经过三轮真实联调验证的前端工程骨架——从首页轮播、商品列表分页、购物车本地持久化,到登录态管理、路由守卫跳转、订单提交防重,每个模块都预留了与后端 API 对齐的接口契约。它用 Vue 2.7(兼容 Options API + Composition API 混写),不引入 Vuex 而用 Pinia 管理状态,用 axios 封装统一请求拦截与错误降级,用 vue-router 实现懒加载+命名视图+滚动行为复位。适合刚学完 Vue 基础、想快速上手真实业务流的开发者,也适合需要交付轻量级商城前端的中小团队——不是教你“怎么写 Vue”,而是告诉你“一个能过测试、能压测、能交接的 Vue 前端长什么样”。
2. 工程结构与核心模块:为什么选 Pinia 而不是 Vuex?为什么拦截器必须拆成 request / response 两层?
2.1 目录结构:看清骨架,才能知道哪块骨头能动、哪块不能拆
vue-mall 的 src 目录不是扁平堆砌,而是按「能力域」分层:
src/ ├── api/ # 所有接口定义,按业务域组织(user.js, product.js, order.js) ├── assets/ # 静态资源:图标 SVG、公共样式变量(variables.scss)、字体 ├── components/ # 可复用原子组件:BaseButton、GoodsCard、CartListItem ├── composables/ # 组合式函数:useScrollToTop()、useDebounce()、useCartSync() ├── layouts/ # 布局容器:DefaultLayout.vue(含 header/footer)、EmptyLayout.vue(登录页专用) ├── router/ # 路由配置:index.js(主路由)、modules/(按模块拆分的子路由) ├── stores/ # Pinia store:userStore.js(用户信息+token)、cartStore.js(本地购物车+同步逻辑) ├── utils/ # 工具函数:request.js(axios 实例封装)、validate.js(手机号/密码校验)、storage.js(localStorage 增强封装) ├── views/ # 页面级组件:HomeView.vue、ProductListView.vue、OrderConfirmView.vue └── main.js # 入口:Pinia 插件注册、router 安装、全局指令/组件注册提示:
composables/目录是关键分水岭。它把原本散落在组件里的逻辑(如防抖搜索、滚动监听)抽成可测试、可复用的函数,避免watch和onMounted在多个组件里重复写。如果你要加“搜索关键词高亮”,别去改每个列表组件,直接在composables/useSearchHighlight.js里实现,然后const { highlightText } = useSearchHighlight()即可。
2.2 Pinia 替代 Vuex:轻量、TypeScript 友好、调试直观
vue-mall 放弃 Vuex 的根本原因不是“新潮”,而是三个血泪经验:
- 模块热更新失效:Vuex 的
modules在 HMR 下常出现 state 未更新但 mutation 仍触发的问题,尤其在购物车数量变更后 UI 不刷新; - TypeScript 类型推导弱:Vuex 的
mapState/mapActions返回 any,而 Pinia 的defineStore可直接导出类型CartStore,VS Code 悬停即见完整接口; - 调试黑匣子:Vuex Devtools 显示的是扁平化的 mutation 名称(如
"cart/addItem"),而 Pinia 显示的是 store 实例名 + action 名(cartStore.addItem),配合devtools: true选项,能直接看到每次调用传入的参数和返回值。
一个典型 cartStore 定义如下(src/stores/cartStore.js):
import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { setItem, getItem } from '@/utils/storage' export const useCartStore = defineStore('cart', () => { // 本地持久化购物车数据(localStorage) const items = ref(getItem('cartItems') || []) // 计算属性:总数量、总金额 const totalCount = computed(() => items.value.reduce((sum, item) => sum + item.count, 0)) const totalPrice = computed(() => items.value.reduce((sum, item) => sum + item.price * item.count, 0) ) // action:添加商品(含去重逻辑) function addItem(goods) { const exist = items.value.find(item => item.id === goods.id) if (exist) { exist.count += goods.count || 1 } else { items.value.push({ ...goods, count: goods.count || 1 }) } // 持久化到 localStorage setItem('cartItems', items.value) } // action:删除商品 function removeItem(id) { items.value = items.value.filter(item => item.id !== id) setItem('cartItems', items.value) } return { items, totalCount, totalPrice, addItem, removeItem } })参数说明:
addItem接收goods对象,必须含id和price字段;count字段可选,默认为 1。setItem是对localStorage.setItem的封装,自动序列化/反序列化,并捕获 QuotaExceededError(存储超限时静默失败,不中断流程)。
2.3 axios 封装:request / response 两层拦截器的不可替代性
很多人把 axios 拦截器写成“一个 request + 一个 response”,但 vue-mall 强制拆成四层(request.use×2 +response.use×2),原因很实际:
- 第一层 request 拦截器:注入 token(从 Pinia store 读取),拼接 base URL(开发/测试/生产环境不同);
- 第二层 request 拦截器:处理 loading 状态(
showLoading()),但仅对/api/order/submit这类耗时操作开启,避免首页轮播请求也触发 loading 遮罩; - 第一层 response 拦截器:统一错误码处理(
res.data.code === 401触发登出,code === 500弹 Toast); - 第二层 response 拦截器:关闭 loading(
hideLoading()),但只关对应请求开启的 loading,防止 A 请求未返回时 B 请求已结束导致遮罩提前消失。
src/utils/request.js关键代码:
import axios from 'axios' import { ElMessage } from 'element-plus' // 使用 Element Plus 的 Message 组件 import { useUserStore } from '@/stores/userStore' // 创建 axios 实例 const service = axios.create({ baseURL: import.meta.env.VUE_APP_BASE_API, // 从 .env 文件读取 timeout: 10000 }) // 第一层 request 拦截器:注入 token 和基础配置 service.interceptors.request.use( config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } // 所有请求默认带 X-Requested-With,后端用于识别 AJAX config.headers['X-Requested-With'] = 'XMLHttpRequest' return config }, error => Promise.reject(error) ) // 第二层 request 拦截器:控制 loading(仅对特定 URL 开启) service.interceptors.request.use( config => { // 定义需 loading 的接口正则(可扩展) const needLoading = /\/api\/(order\/submit|payment\/create)/.test(config.url) if (needLoading) { // 这里调用全局 loading 函数(由 main.js 注入) window.$loading?.show() } return config }, error => Promise.reject(error) ) // 第一层 response 拦截器:统一错误码处理 service.interceptors.response.use( response => { const { code, data, message } = response.data if (code === 200) { return data // 直接返回业务数据,无需再解包 } else if (code === 401) { // token 失效,清空用户状态并跳转登录页 const userStore = useUserStore() userStore.logout() window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname) return Promise.reject(new Error('登录已过期')) } else { ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) } }, error => { // 网络错误、超时等 if (!error.response) { ElMessage.error('网络连接异常,请检查网络') } return Promise.reject(error) } ) // 第二层 response 拦截器:关闭 loading service.interceptors.response.use( response => { const needLoading = /\/api\/(order\/submit|payment\/create)/.test(response.config.url) if (needLoading) { window.$loading?.hide() } return response }, error => { const needLoading = /\/api\/(order\/submit|payment\/create)/.test(error.config?.url) if (needLoading) { window.$loading?.hide() } return Promise.reject(error) } ) export default service逻辑说明:
window.$loading是在main.js中挂载的全局 loading 控制对象(基于 Element Plus 的 Loading service 封装),支持show()/hide()/close()。这样设计的好处是:loading 状态完全由请求生命周期驱动,不依赖组件内data或ref,避免因组件销毁导致hide()无法执行。
3. 路由与导航守卫:如何让「未登录访问订单页」自动跳转登录,且返回时不丢参数?
3.1 路由模块化:按业务域拆分,避免 index.js 成为万能胶
vue-mall 的src/router/modules/下有三个文件:
home.js:首页、分类页、搜索页(无权限要求)user.js:登录、注册、个人中心、地址管理(登录后可见)order.js:订单列表、订单详情、确认订单(需登录 + 用户 ID)
src/router/index.js主路由文件只做三件事:
- 创建 router 实例(
createRouter); - 动态导入所有
modules/*.js并addRoute; - 设置全局
scrollBehavior(回到顶部或保留滚动位置)。
import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/userStore' // 懒加载布局组件 const DefaultLayout = () => import('@/layouts/DefaultLayout.vue') const EmptyLayout = () => import('@/layouts/EmptyLayout.vue') // 动态导入所有路由模块 const modules = import.meta.globEager('./modules/*.js') const routes = [] // 遍历 modules,提取 route 配置 Object.keys(modules).forEach(key => { const moduleRoutes = modules[key].default if (Array.isArray(moduleRoutes)) { routes.push(...moduleRoutes) } }) // 主路由配置 const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: DefaultLayout, children: routes // 所有业务路由作为子路由 }, { path: '/login', name: 'Login', component: EmptyLayout, children: [ { path: '', component: () => import('@/views/LoginView.vue') } ] } ], // 滚动行为:回到顶部,但商品详情页返回时保持原位置 scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } else if (to.name === 'ProductDetail') { return { top: 0 } } else { return { top: 0 } } } }) // 全局前置守卫:权限控制 router.beforeEach((to, from, next) => { const userStore = useUserStore() const requiresAuth = to.meta.requiresAuth // 路由 meta 标记是否需要登录 if (requiresAuth && !userStore.isLogin) { // 保存目标路由,登录后跳回 next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } }) export default router参数说明:
to.meta.requiresAuth是在路由定义中手动添加的元信息,例如order.js中:{ path: 'order/list', name: 'OrderList', component: () => import('@/views/OrderListView.vue'), meta: { requiresAuth: true } }这样比在每个
beforeEach里写if (to.path.startsWith('/order'))更清晰、更易维护。
3.2 登录后跳转:redirect参数的正确解析与安全过滤
很多项目直接window.location.href = query.redirect,这是严重安全隐患——攻击者可构造?redirect=javascript:alert(1)。vue-mall 的登录页(LoginView.vue)做了三层防护:
- 白名单校验:只允许跳转到
/,/home,/product/*,/cart,/user/*等预设路径; - 协议过滤:拒绝
javascript:、data:、vbscript:等危险协议; - 相对路径强制:若
redirect以http开头,只取 pathname 部分(new URL(redirect).pathname)。
LoginView.vue中的登录成功逻辑:
<script setup> import { useRouter, useRoute } from 'vue-router' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/userStore' const router = useRouter() const route = useRoute() const userStore = useUserStore() const login = async () => { try { await userStore.login(formState) ElMessage.success('登录成功') // 解析 redirect 参数 let redirectPath = '/' if (route.query.redirect) { try { const url = new URL(route.query.redirect, window.location.origin) const pathname = url.pathname // 白名单路径正则(可配置化) const whiteList = /^\/(home|product|cart|user|order)(\/.*)?$|^\/$/ if (whiteList.test(pathname)) { redirectPath = pathname + (url.search || '') } } catch (e) { // URL 构造失败,视为非法,跳首页 redirectPath = '/' } } router.push(redirectPath) } catch (error) { ElMessage.error(error.message || '登录失败') } } </script>注意:
new URL()构造函数会自动拒绝javascript:协议(抛出TypeError),所以catch块天然覆盖了协议注入风险。这是比正则匹配^javascript:更可靠的做法。
3.3 避坑:常见问题与排查(路由 & 权限)
现象 1:登录后跳转到/login?redirect=/order/list,页面空白,控制台无报错
原因:OrderListView.vue中使用了async setup(),但未正确处理await后的响应式数据绑定,导致onMounted里访问orderList.value为undefined,模板渲染失败。
解决:async setup()中所有异步数据必须用ref包裹,并在onMounted中显式赋值;或改用onBeforeMount+await,确保 DOM 渲染前数据已就绪。
现象 2:从商品详情页点击「加入购物车」,返回时滚动位置丢失
原因:scrollBehavior中savedPosition仅在浏览器前进/后退时生效,而router.push()是编程式导航,不会触发savedPosition。
解决:在商品详情页onBeforeUnmount中手动记录滚动位置window.sessionStorage.setItem('productDetailScroll', window.scrollY),在onMounted中读取并window.scrollTo(0, pos)。
现象 3:路由守卫中next()调用多次,导致页面卡死或白屏
原因:在beforeEach中写了if (...) { next() } else { next() },但中间有异步操作(如await checkPermission())未用return阻断后续next()。
解决:所有next()调用前加return;或统一用Promise.resolve().then(() => next())确保单次执行。
现象 4:router.push({ name: 'ProductDetail', params: { id: 123 } })报错 “Missing required param 'id'”
原因:ProductDetail路由配置中props: true,但params未通过props透传,而是试图在setup()中用useRoute().params.id读取,此时params为空对象。
解决:路由配置中明确props: route => ({ id: Number(route.params.id) }),或在setup()中用useRoute().params.id时加空值判断。
现象 5:HMR(热更新)后路由守卫失效,beforeEach不再触发
原因:router.beforeEach在main.js中注册,但 HMR 时main.js未重新执行,守卫未重装。
解决:将router.beforeEach移至src/router/index.js导出前,确保每次模块重载时守卫被重新注册;或在main.js中监听import.meta.hot事件,手动router.removeRoute()后重 add。
4. 商品列表与分页:如何实现“下拉加载更多”与“滚动到底部自动触发”的稳定协同?
4.1 分页逻辑:服务端分页 vs 客户端分页,vue-mall 为何选前者?
vue-mall 所有列表(商品、订单、收藏)均采用服务端分页,理由非常实际:
- 数据一致性:客户端分页需一次性拉取全部数据(如 1000 条商品),但用户筛选条件变更(如切换分类)后,缓存数据可能过期,需重新请求全量,反而增加流量;
- 首屏性能:服务端分页只需返回当前页 10~20 条,JSON 体积小,解析快,白屏时间短;
- 排序可靠性:服务端可保证
ORDER BY created_at DESC的严格顺序,客户端分页在多用户并发新增时可能出现“重复加载同一条数据”。
src/api/product.js中的商品列表接口:
import request from '@/utils/request' // 获取商品列表(服务端分页) export function getProductList(params) { // params 示例:{ category_id: 1, keyword: '手机', page: 1, page_size: 20 } return request({ url: '/api/products', method: 'get', params }) } // 获取分类列表(用于顶部导航) export function getCategoryList() { return request({ url: '/api/categories', method: 'get' }) }参数说明:
page从 1 开始(非 0),page_size默认 20,后端返回total(总数)、list(当前页数据)、page(当前页码)、page_size(每页条数)。前端不计算总页数,而是根据total和page_size推导:Math.ceil(total / page_size)。
4.2 下拉加载更多:IntersectionObserver 替代 scroll 事件监听
传统window.addEventListener('scroll')存在两个致命缺陷:
- 频繁触发:滚动时每秒触发数十次,
throttle也难保精准; - 性能开销大:每次触发都要
document.documentElement.scrollTop+window.innerHeight计算,影响帧率。
vue-mall 用IntersectionObserver监听“加载更多”占位符(<div ref="loadMoreRef"></div>)是否进入视口,零计算、零节流、原生高性能。
ProductListView.vue中的关键逻辑:
<script setup> import { ref, onMounted, onUnmounted, watch } from 'vue' import { getProductList } from '@/api/product' import { useCartStore } from '@/stores/cartStore' const productList = ref([]) const pagination = ref({ page: 1, page_size: 20, total: 0 }) const loading = ref(false) const noMore = ref(false) const loadMoreRef = ref(null) // 初始化加载第一页 const fetchList = async (reset = false) => { if (loading.value) return if (reset) { productList.value = [] pagination.value.page = 1 noMore.value = false } loading.value = true try { const res = await getProductList({ category_id: category.value, keyword: keyword.value, page: pagination.value.page, page_size: pagination.value.page_size }) productList.value = reset ? res.list : [...productList.value, ...res.list] pagination.value.total = res.total pagination.value.page = res.page noMore.value = res.list.length < pagination.value.page_size } catch (error) { console.error('获取商品列表失败', error) } finally { loading.value = false } } // IntersectionObserver 实例 let observer = null onMounted(() => { if (!loadMoreRef.value) return observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting && !loading.value && !noMore.value) { pagination.value.page++ fetchList() } }) }, { threshold: 0.1 // 当 10% 进入视口时触发 } ) observer.observe(loadMoreRef.value) }) onUnmounted(() => { if (observer) { observer.disconnect() } }) // 监听筛选条件变化,重置分页 watch([category, keyword], () => { fetchList(true) }) </script> <template> <div class="product-list"> <!-- 商品卡片列表 --> <GoodsCard v-for="item in productList" :key="item.id" :goods="item" /> <!-- 加载更多占位符 --> <div ref="loadMoreRef" class="load-more-placeholder" /> <!-- 加载中提示 --> <div v-if="loading" class="loading">加载中...</div> <!-- 无更多数据提示 --> <div v-if="noMore && productList.length > 0" class="no-more">没有更多商品了</div> </div> </template>逻辑说明:
IntersectionObserver的threshold: 0.1表示当占位符的 10% 高度进入视口时即触发回调,比scroll事件更早感知用户意图,体验更顺滑。loadMoreRef是一个高度为1px的空 div,不影响布局。
4.3 避坑:常见问题与排查(列表 & 分页)
现象 1:下拉加载多次触发,同一页面数据重复渲染
原因:IntersectionObserver的isIntersecting在元素进出视口时都会触发,若未加!loading.value判断,快速滚动可能导致多次fetchList()并发。
解决:在entries.forEach内部加if (entry.isIntersecting && !loading.value && !noMore.value)三重判断。
现象 2:首次进入页面,商品列表为空,控制台报 “Cannot read property 'list' of undefined”
原因:getProductList返回的res结构与预期不符(如后端返回{ code: 200, data: { list: [], total: 0 } }),但前端直接解构res.list,未处理res.data层级。
解决:统一在request.js的 response 拦截器中做return response.data,确保所有 API 调用返回的都是业务数据对象,而非原始响应体。
现象 3:切换分类后,滚动位置未重置,用户看到的是上一个分类的底部
原因:scrollBehavior中savedPosition仅对浏览器历史导航生效,router.push()编程式导航不触发。
解决:在watch([category, keyword])的回调中,手动window.scrollTo(0, 0),或在fetchList(true)后nextTick(() => window.scrollTo(0, 0))。
现象 4:移动端下拉加载,手指松开后页面突然向上跳动
原因:loadMoreRef占位符高度为1px,但某些安卓 WebView 下IntersectionObserver对极小高度元素检测不准,导致isIntersecting误判。
解决:将占位符高度设为20px,并添加margin-top: -20px抵消视觉影响,确保检测稳定性。
现象 5:IntersectionObserver在 iOS Safari 下不触发
原因:iOS Safari 13.4+ 才完全支持IntersectionObserver,旧版本需 polyfill。
解决:在main.js中动态加载 polyfill:
if (!('IntersectionObserver' in window)) { import('intersection-observer') }5. 购物车持久化与同步:localStorage 容量超限怎么办?离线时加购如何保证不丢?
5.1 本地持久化:storage.js 的健壮封装
src/utils/storage.js不是简单包装localStorage.setItem,而是解决三个现实问题:
- 容量限制:
localStorage通常 5MB,但单个 key 超过 2MB 可能触发QuotaExceededError; - 数据类型:
localStorage只存字符串,需自动序列化/反序列化; - 跨标签页同步:
localStorage变更会触发storage事件,可用于多窗口状态同步。
// src/utils/storage.js const STORAGE_PREFIX = 'vue-mall:' // 安全写入:捕获 QuotaExceededError function setItem(key, value) { try { const str = JSON.stringify(value) window.localStorage.setItem(STORAGE_PREFIX + key, str) } catch (error) { if (error.name === 'QuotaExceededError') { console.warn(`localStorage 超限,key: ${key} 未保存`) // 可选:触发清理策略,如删除最旧的 3 个 key clearOldestItems(3) } } } // 安全读取:处理 JSON 解析失败 function getItem(key, defaultValue = null) { try { const str = window.localStorage.getItem(STORAGE_PREFIX + key) return str ? JSON.parse(str) : defaultValue } catch (error) { console.error(`localStorage 读取失败,key: ${key}`, error) return defaultValue } } // 清理最旧的 N 个 key(按 lastModified 时间戳) function clearOldestItems(count = 1) { const keys = Object.keys(window.localStorage) .filter(key => key.startsWith(STORAGE_PREFIX)) .map(key => ({ key, time: window.localStorage.getItem(`${key}:timestamp`) || 0 })) .sort((a, b) => a.time - b.time) .slice(0, count) keys.forEach(({ key }) => { window.localStorage.removeItem(key) window.localStorage.removeItem(`${key}:timestamp`) }) } // 写入时记录时间戳 function setItemWithTime(key, value) { setItem(key, value) try { window.localStorage.setItem(`${STORAGE_PREFIX + key}:timestamp`, Date.now().toString()) } catch (e) { // 时间戳写入失败不影响主数据 } } export { setItem, getItem, setItemWithTime, clearOldestItems }参数说明:
STORAGE_PREFIX避免与其他项目 key 冲突;setItemWithTime为后续清理策略提供依据;clearOldestItems是兜底方案,非日常调用。
5.2 购物车同步:本地变更如何实时反映到服务端?
vue-mall 的购物车采用「本地优先 + 异步同步」策略:
- 用户操作(加购/删购/改数量)立即更新
cartStore.items并持久化到localStorage,UI 实时响应; - 网络就绪时(监听
window.online事件),遍历本地变更队列,批量提交到服务端; - 冲突处理:服务端返回
conflict: true时,拉取最新服务端购物车覆盖本地。
src/stores/cartStore.js中的同步逻辑:
import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { setItemWithTime, getItem } from '@/utils/storage' import { updateCartRemote } from '@/api/cart' export const useCartStore = defineStore('cart', () => { const items = ref(getItem('cartItems') || []) const syncQueue = ref([]) // 待同步操作队列:{ type: 'add'|'remove'|'update', payload: {...} } // 添加商品(本地 + 队列) function addItem(goods) { const exist = items.value.find(item => item.id === goods.id) if (exist) { exist.count += goods.count || 1 } else { items.value.push({ ...goods, count: goods.count || 1 }) } setItemWithTime('cartItems', items.value) // 加入同步队列 syncQueue.value.push({ type: 'add', payload: goods }) } // 同步到服务端(手动触发) async function syncToRemote() { if (syncQueue.value.length === 0) return try { await updateCartRemote(syncQueue.value) syncQueue.value = [] // 清空队列 console.log('购物车同步成功') } catch (error) { console.error('购物车同步失败', error) // 失败不清理队列,下次重试 } } // 监听网络状态,自动同步 function initNetworkSync() { window.addEventListener('online', () => { setTimeout(() => syncToRemote(), 1000) // 网络恢复后延迟 1s 同步,避免抖动 }) } // 初始化时启动网络监听 initNetworkSync() return { items, addItem, syncToRemote } })逻辑说明:
syncQueue存储的是操作指令,而非最终数据,这样即使同步失败,也能重放操作(如add两次,服务端幂等处理即可)。setTimeout延迟是为了避免网络短暂抖动(如 WiFi 切换)导致频繁重试。
5.3 避坑:常见问题与排查(购物车 & 存储)
现象 1:iOS Safari 隐私模式下localStorage报错 “QuotaExceededError”
原因:iOS Safari 隐私模式禁用localStorage,setItem直接抛错。
解决:在setItem封装中捕获SecurityError,降级为内存存储(const memoryStore = {}),并提示用户“隐私模式下购物车将在关闭页面后丢失”。
现象 2:多标签页操作购物车,A 标签页加购,B 标签页未更新
原因:localStorage变更会触发storage事件,但默认只在其他标签页触发,当前标签页不触发。
解决:在cartStore初始化时,手动触发一次dispatchEvent(new Event('storage')),或在setItemWithTime后主动window.dispatchEvent(new CustomEvent('cart-change')),所有组件监听该自定义事件。
现象 3:用户清空浏览器缓存,购物车数据丢失,客服投诉
原因:localStorage属于浏览器缓存,用户手动清除时一并删除。
解决:在用户首次加购时弹窗提示:“购物车数据保存在您的浏览器中,清除缓存将导致丢失。如需长期保存,请登录账号。” —— 这不是技术方案,而是降低客诉的 UX 必选项。
现象 4:JSON.stringify序列化失败,报 “Converting circular structure to JSON”
原因:goods对象中包含__ob__(Vue 2 的 Observer)或el(DOM 元素)等循环引用属性。
解决:在addItem前用JSON.parse(JSON.stringify(goods))深拷贝,或用 LodashcloneDeep,或在setItem封装中加replacer函数过滤掉函数、undefined、循环引用。
现象 5:window.online事件在部分安卓 WebView 中不触发
原因:WebView 未透传系统网络状态。
解决:降级为定时心跳检测(每 30s 发起一次HEAD /api/health请求),失败则认为离线;或监听navigator.onLine(兼容性更好,但有误判)。
6. 从本地开发到部署上线:Nginx 配置、环境变量、以及我每次构建都强制走一遍的三步检查清单
6.1 环境变量:.env、`.env.de
本文还有配套的精品资源,点击获取