
简介这是一套基于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 storeuserStore.js用户信息token、cartStore.js本地购物车同步逻辑 ├── utils/ # 工具函数request.jsaxios 实例封装、validate.js手机号/密码校验、storage.jslocalStorage 增强封装 ├── 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可直接导出类型CartStoreVS Code 悬停即见完整接口调试黑匣子Vuex Devtools 显示的是扁平化的 mutation 名称如cart/addItem而 Pinia 显示的是 store 实例名 action 名cartStore.addItem配合devtools: true选项能直接看到每次调用传入的参数和返回值。一个典型 cartStore 定义如下src/stores/cartStore.jsimport { 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 拦截器关闭 loadinghideLoading()但只关对应请求开启的 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订单列表、订单详情、确认订单需登录 用户 IDsrc/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这是严重安全隐患——攻击者可构造?redirectjavascript: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中显式赋值或改用onBeforeMountawait确保 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())确保单次执行。现象 4router.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时加空值判断。现象 5HMR热更新后路由守卫失效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 开始非 0page_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.scrollTopwindow.innerHeight计算影响帧率。vue-mall 用IntersectionObserver监听“加载更多”占位符div refloadMoreRef/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 classproduct-list !-- 商品卡片列表 -- GoodsCard v-foritem in productList :keyitem.id :goodsitem / !-- 加载更多占位符 -- div refloadMoreRef classload-more-placeholder / !-- 加载中提示 -- div v-ifloading classloading加载中.../div !-- 无更多数据提示 -- div v-ifnoMore productList.length 0 classno-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抵消视觉影响确保检测稳定性。现象 5IntersectionObserver在 iOS Safari 下不触发原因iOS Safari 13.4 才完全支持IntersectionObserver旧版本需 polyfill。解决在main.js中动态加载 polyfillif (!(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并持久化到localStorageUI 实时响应网络就绪时监听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 避坑常见问题与排查购物车 存储现象 1iOS Safari 隐私模式下localStorage报错 “QuotaExceededError”原因iOS Safari 隐私模式禁用localStoragesetItem直接抛错。解决在setItem封装中捕获SecurityError降级为内存存储const memoryStore {}并提示用户“隐私模式下购物车将在关闭页面后丢失”。现象 2多标签页操作购物车A 标签页加购B 标签页未更新原因localStorage变更会触发storage事件但默认只在其他标签页触发当前标签页不触发。解决在cartStore初始化时手动触发一次dispatchEvent(new Event(storage))或在setItemWithTime后主动window.dispatchEvent(new CustomEvent(cart-change))所有组件监听该自定义事件。现象 3用户清空浏览器缓存购物车数据丢失客服投诉原因localStorage属于浏览器缓存用户手动清除时一并删除。解决在用户首次加购时弹窗提示“购物车数据保存在您的浏览器中清除缓存将导致丢失。如需长期保存请登录账号。” —— 这不是技术方案而是降低客诉的 UX 必选项。现象 4JSON.stringify序列化失败报 “Converting circular structure to JSON”原因goods对象中包含__ob__Vue 2 的 Observer或elDOM 元素等循环引用属性。解决在addItem前用JSON.parse(JSON.stringify(goods))深拷贝或用 LodashcloneDeep或在setItem封装中加replacer函数过滤掉函数、undefined、循环引用。现象 5window.online事件在部分安卓 WebView 中不触发原因WebView 未透传系统网络状态。解决降级为定时心跳检测每 30s 发起一次HEAD /api/health请求失败则认为离线或监听navigator.onLine兼容性更好但有误判。6. 从本地开发到部署上线Nginx 配置、环境变量、以及我每次构建都强制走一遍的三步检查清单6.1 环境变量.env、.env.de本文还有配套的精品资源点击获取