news 2026/10/28 7:56:42

Vue3.2大型单页面商城实战:Pinia+Vant4+Vue-Router4全栈开发指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3.2大型单页面商城实战:Pinia+Vant4+Vue-Router4全栈开发指南

简介:这是一套基于 Vue3.2 + Vue-Router4.x + Pinia + Vant4.x 技术栈搭建的大型单页面商城项目源码,面向具备一定 Vue 基础、希望深入掌握全家桶实战的前端开发者与学习者。项目完整还原电商核心业务,前台涵盖首页门户、商品分类、搜索、购物车、订单结算与会员中心等模块,后台则包含数据面板、轮播图、商品、订单、会员及分类管理等,可作为移动端 SPA 开发的参考模板。压缩包共 58 个文件,约 2.4MB,以 20 个 vue 组件、13 个 js 逻辑文件为主,辅以 png 预览图、css/less 样式、html 入口及 vite 配置等,结构清晰、便于按模块阅读。目前已有 84 人学习下载。通过该资源可直观理解 Composition API、路由守卫与 Pinia 状态管理的落地方式,并借鉴前后端分离下的接口组织与组件拆分思路。

1. 新蜂商城 Vue3.2 版:一套能扛住大型单页面商城的全家桶组合

打开一个商城首页,商品列表要懒加载、购物车要跨页面同步、用户登录态要持久化、路由切换不能白屏——这些需求堆在一起,用 Vue2 时代的 Options API 加 Vuex 写,代码会迅速膨胀成几千行的“黑匣子”。新蜂商城 Vue3.2 版本给出的解法是:Vue3.2 的<script setup>语法糖负责组件逻辑收敛,Vue-Router4.x 管路由与导航守卫,Pinia 替代 Vuex 做状态管理,Vant4.x 提供移动端 UI 组件。这套组合不是简单升级版本号,而是把大型单页面商城的开发范式从“配置驱动”推向“组合式函数驱动”。如果你正在做 vue3商城、vue3后台管理系统,或者被 vue3面试题 里的响应式原理问住过,这篇文章会从环境搭建一路讲到性能优化,把每个环节的参数和坑都摊开。

2. 环境搭建与工程初始化:从零跑通第一个页面

2.1 用 Vite 创建 Vue3.2 项目并锁定依赖版本

大型商城项目最怕依赖版本漂移,今天能跑明天报错。我一般用 Vite 的 vue 模板起手,然后手动锁死核心依赖的次版本号。命令行操作如下:

# 使用 Vite 创建 Vue3 项目,模板选 vue npm create vite@latest xinbee-mall -- --template vue # 进入目录 cd xinbee-mall # 安装核心依赖,锁定次版本 npm install vue@3.2.x vue-router@4.x pinia@2.x vant@4.x npm install axios@1.x npm install -D sass unplugin-vue-components

这里有几个参数需要说明。vue@3.2.x中的x让 npm 拉取 3.2 系列的最新补丁版,避免 3.3 的 breaking change。unplugin-vue-components用于 Vant 的按需引入,后面会配置。sass是 Vant4 自定义主题的硬依赖,不装会在覆盖变量时报错。

安装完成后,在vite.config.js里配置别名和 Vant 按需引入:

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import Components from 'unplugin-vue-components/vite' import { VantResolver } from 'unplugin-vue-components/resolvers' import path from 'path' export default defineConfig({ plugins: [ vue(), Components({ resolvers: [VantResolver()], // 自动按需引入 Vant 组件 dts: false, // 不生成类型声明文件,纯 JS 项目关掉 }), ], resolve: { alias: { '@': path.resolve(__dirname, 'src'), // @ 指向 src }, }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', // 后端接口地址 changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, ''), }, }, }, })

VantResolver的作用是:模板里写<van-button>时自动引入对应组件和样式,不用手动 import。changeOrigin: true解决跨域时 Host 头不匹配的问题。代理的rewrite把/api前缀去掉,这样前端请求/api/goods/list实际打到后端/goods/list。

2.2 目录结构设计与路由表拆分

大型单页面商城的目录不能平铺。我按“功能域”划分,而不是按“文件类型”划分。推荐结构:

src/ ├── api/ # 接口封装,按模块分文件 ├── assets/ # 静态资源 ├── components/ # 全局公共组件 ├── composables/ # 组合式函数,如 useCart、useAuth ├── router/ # 路由配置 │ ├── index.js │ └── modules/ # 按业务拆分的路由表 ├── stores/ # Pinia store ├── styles/ # 全局样式与 Vant 主题覆盖 ├── utils/ # 工具函数 └── views/ # 页面级组件

路由表按模块拆分,避免index.js膨胀到几百行。以商品模块为例:

// router/modules/goods.js export default [ { path: '/goods', name: 'GoodsList', component: () => import('@/views/goods/List.vue'), meta: { title: '商品列表', keepAlive: true }, }, { path: '/goods/:id', name: 'GoodsDetail', component: () => import('@/views/goods/Detail.vue'), meta: { title: '商品详情', keepAlive: false }, }, ]

meta.keepAlive控制页面是否缓存。列表页需要缓存(用户从详情返回时保留滚动位置),详情页不缓存(每次进入要重新拉数据)。component: () => import(...)是路由级懒加载,Vite 会自动做代码分割,首屏只加载当前路由的 chunk。

在router/index.js里合并模块并挂载守卫:

import { createRouter, createWebHistory } from 'vue-router' import goodsRoutes from './modules/goods' import userRoutes from './modules/user' const routes = [ { path: '/', redirect: '/home' }, { path: '/home', name: 'Home', component: () => import('@/views/Home.vue') }, ...goodsRoutes, ...userRoutes, ] const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition // 浏览器前进后退恢复位置 return { top: 0 } // 新页面滚到顶部 }, }) router.beforeEach((to, from, next) => { document.title = to.meta.title || '新蜂商城' next() }) export default router

createWebHistory是 HTML5 History 模式,URL 不带#,但需要服务端配置 fallback 到index.html。scrollBehavior的savedPosition在浏览器前进后退时自动恢复滚动位置,这是提升商城浏览体验的细节。

3. Pinia 状态管理:购物车与用户态的落地写法

3.1 用 Pinia 定义购物车 Store 并持久化

Vuex 的 mutation/action 分离在大型项目里反而增加心智负担。Pinia 去掉了 mutation,action 直接改 state,配合pinia-plugin-persistedstate做持久化。先装插件:

npm install pinia-plugin-persistedstate

在main.js注册:

import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import App from './App.vue' import router from './router' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) createApp(App).use(pinia).use(router).mount('#app')

购物车 Store 的完整写法:

// stores/cart.js import { defineStore } from 'pinia' import { computed, ref } from 'vue' export const useCartStore = defineStore('cart', () => { // state const items = ref([]) // [{ id, name, price, count, checked }] // getters const totalCount = computed(() => items.value.reduce((sum, item) => sum + item.count, 0) ) const totalPrice = computed(() => items.value .filter((item) => item.checked) .reduce((sum, item) => sum + item.price * item.count, 0) ) const checkedIds = computed(() => items.value.filter((item) => item.checked).map((item) => item.id) ) // actions function addItem(goods) { const existing = items.value.find((item) => item.id === goods.id) if (existing) { existing.count += goods.count || 1 } else { items.value.push({ ...goods, count: goods.count || 1, checked: true }) } } function removeItem(id) { items.value = items.value.filter((item) => item.id !== id) } function toggleCheck(id) { const item = items.value.find((item) => item.id === id) if (item) item.checked = !item.checked } function clearCart() { items.value = [] } return { items, totalCount, totalPrice, checkedIds, addItem, removeItem, toggleCheck, clearCart, } }, { persist: { key: 'xinbee-cart', storage: localStorage, paths: ['items'], // 只持久化 items,getters 不需要存 }, })

这里用的是 Setup Store 写法(传入函数),比 Options Store 更灵活。persist.paths只存items,因为totalCount和totalPrice是计算属性,存了反而会数据不一致。storage: localStorage让购物车在刷新后不丢,这是商城的基本要求。

3.2 用户登录态管理与路由守卫联动

用户态 Store 要处理 token 存储、用户信息拉取、登出清理。关键点是:token 存 localStorage,用户信息存内存,刷新时用 token 重新拉。

// stores/user.js import { defineStore } from 'pinia' import { ref } from 'vue' import { loginApi, getUserInfoApi } from '@/api/user' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref(null) async function login(form) { const res = await loginApi(form) token.value = res.data.token localStorage.setItem('token', res.data.token) await fetchUserInfo() } async function fetchUserInfo() { if (!token.value) return const res = await getUserInfoApi() userInfo.value = res.data } function logout() { token.value = '' userInfo.value = null localStorage.removeItem('token') } return { token, userInfo, login, fetchUserInfo, logout } })

路由守卫里判断需要登录的页面:

router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.token) { next({ name: 'Login', query: { redirect: to.fullPath } }) } else { if (userStore.token && !userStore.userInfo) { await userStore.fetchUserInfo() // 刷新后补拉用户信息 } next() } })

query: { redirect: to.fullPath }让登录后跳回原页面。!userStore.userInfo判断避免每次路由跳转都重复请求用户信息。

4. Vant4 组件定制与商城核心页面实现

4.1 Vant4 主题定制与按需引入的样式覆盖

Vant4 用 CSS 变量做主题,覆盖时在styles/vant-theme.scss里写:

// styles/vant-theme.scss :root { --van-primary-color: #ff5000; // 商城主色改成橙色 --van-button-primary-background: #ff5000; --van-tabbar-item-active-color: #ff5000; --van-nav-bar-title-font-size: 18px; }

然后在main.js引入这个文件,注意要在 Vant 组件样式之后引入才能覆盖:

import 'vant/lib/index.css' // 如果用了按需引入,这行可省略 import '@/styles/vant-theme.scss'

如果用了unplugin-vue-components的按需引入,Vant 样式是自动注入的,此时主题文件要在vite.config.js的css.preprocessorOptions里配置:

css: { preprocessorOptions: { scss: { additionalData: `@import "@/styles/vant-theme.scss";`, }, }, },

additionalData会把主题变量注入每个 scss 文件,确保组件样式能读到变量。注意路径要用@别名,且确保vite.config.js里 alias 已配置。

4.2 商品列表页的虚拟滚动与下拉刷新

商城列表动辄几百条商品,直接v-for渲染会卡。Vant4 的van-list配合分页加载是标准做法:

<template> <van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad" > <div class="goods-grid"> <goods-card v-for="item in list" :key="item.id" :goods="item" @click="goDetail(item.id)" /> </div> </van-list> </van-pull-refresh> </template> <script setup> import { ref } from 'vue' import { useRouter } from 'vue-router' import { getGoodsListApi } from '@/api/goods' import GoodsCard from '@/components/GoodsCard.vue' const router = useRouter() const list = ref([]) const loading = ref(false) const finished = ref(false) const refreshing = ref(false) const page = ref(1) const pageSize = 10 async function onLoad() { const res = await getGoodsListApi({ page: page.value, size: pageSize }) if (refreshing.value) { list.value = [] refreshing.value = false } list.value.push(...res.data.list) loading.value = false if (res.data.list.length < pageSize) { finished.value = true } else { page.value++ } } function onRefresh() { page.value = 1 finished.value = false loading.value = true onLoad() } function goDetail(id) { router.push({ name: 'GoodsDetail', params: { id } }) } </script>

van-list的@load在滚动到底部时自动触发,loading控制加载动画,finished控制是否还有数据。van-pull-refresh的@refresh重置页码后重新加载。注意onRefresh里要把loading设为true,否则van-list不会触发@load。

4.3 商品详情页的 SKU 选择与加购逻辑

SKU 选择是商城最复杂的交互。核心是:规格组合决定库存和价格,选中后更新按钮状态。数据结构设计:

// 后端返回的 SKU 结构 const skuList = [ { id: 1, specs: ['红色', 'S'], price: 99, stock: 10 }, { id: 2, specs: ['红色', 'M'], price: 99, stock: 0 }, { id: 3, specs: ['蓝色', 'S'], price: 109, stock: 5 }, ]

前端用selectedSpecs数组记录当前选中,每次点击规格时判断该组合是否存在且有库存:

const selectedSpecs = ref([]) const currentSku = computed(() => { if (selectedSpecs.value.length !== specKeys.length) return null return skuList.find((sku) => sku.specs.every((s, i) => s === selectedSpecs.value[i]) ) }) function selectSpec(index, value) { selectedSpecs.value[index] = value // 检查该组合是否有库存 const temp = [...selectedSpecs.value] const matched = skuList.find((sku) => sku.specs.every((s, i) => temp[i] === undefined || s === temp[i]) ) if (!matched || matched.stock === 0) { // 该规格组合无货,可以在这里给用户提示 } }

currentSku只有在所有规格都选中时才返回具体 SKU,否则返回null,加购按钮置灰。selectSpec里的temp数组允许部分选中,用于判断当前点击的规格是否可选。

5. 避坑与排查:大型单页面商城最容易翻车的 5 个点

5.1 现象:刷新页面后 Pinia 数据丢失,购物车清空

原因:Pinia 默认不持久化,刷新后 store 重置。很多人只装了pinia-plugin-persistedstate但忘了在 store 里加persist配置,或者paths写错导致关键字段没存。

解决:确认main.js里pinia.use(piniaPluginPersistedstate)已执行,且 store 的persist.paths包含所有需要持久化的 state 字段。注意paths只写 state 的 key,不写 getters。如果用了 Setup Store,persist要作为defineStore的第三个参数传入。

5.2 现象:Vant 组件样式不生效,按钮还是默认蓝色

原因:按需引入时unplugin-vue-components自动注入了组件样式,但自定义主题的 scss 变量没有注入到组件内部,导致覆盖失败。

解决:在vite.config.js的css.preprocessorOptions.scss.additionalData里引入主题文件,而不是在main.js里 import。additionalData会在每个 scss 文件编译前注入,确保 Vant 组件内部的 scss 也能读到变量。如果还不行,检查additionalData的路径是否用了绝对路径或正确的别名。

5.3 现象:路由懒加载后,从详情页返回列表页,滚动位置丢失

原因:scrollBehavior返回了{ top: 0 },或者列表页没有开启keepAlive,组件被销毁重建。

解决:在路由meta里给列表页加keepAlive: true,并在App.vue里用<keep-alive>包裹<router-view>:

<router-view v-slot="{ Component }"> <keep-alive :include="cachedViews"> <component :is="Component" /> </keep-alive> </router-view>

cachedViews从路由表里筛选meta.keepAlive为 true 的组件名。注意include匹配的是组件的name选项,<script setup>里需要用defineOptions({ name: 'GoodsList' })显式声明。

5.4 现象:Pinia 的 getter 在组件里解构后失去响应式

原因:直接const { totalPrice } = useCartStore()解构会丢失响应式,因为解构出来的是普通值。

解决:用storeToRefs包裹:

import { storeToRefs } from 'pinia' const cartStore = useCartStore() const { totalPrice, totalCount } = storeToRefs(cartStore)

storeToRefs只转换 state 和 getters 为 ref,actions 直接解构没问题。如果解构的是 action,不需要storeToRefs。

5.5 现象:Vant4 的 Toast 在路由切换后不消失

原因:Toast 是挂载在 body 上的全局组件,路由切换时组件卸载但 Toast 实例还在。

解决:在路由守卫的afterEach里手动关闭:

import { closeToast } from 'vant' router.afterEach(() => { closeToast() })

或者在onBeforeUnmount里调用closeToast()。Vant4 的closeToast是具名导出,不是Toast.clear(),这点和 Vant3 不同,升级时容易踩。

6. 性能优化与上线前检查:把首屏时间压到 1.5 秒内

商城首页的首屏时间直接决定转化率。我一般从三个层面压:资源体积、请求数量、渲染性能。

资源体积方面,Vite 默认会做代码分割,但 Vant 全量引入会多出 200KB 左右。确认unplugin-vue-components生效后,检查打包产物里有没有vant/lib/index.css的全量样式。如果用了additionalData注入主题,样式是按需的,没问题。另外把axios换成fetch封装可以省掉一个依赖,但商城项目里 axios 的拦截器更成熟,我一般保留。

请求数量方面,首页的接口用Promise.all并发拉取,不要串行。商品列表和分类数据可以并行:

const [bannerRes, categoryRes, goodsRes] = await Promise.all([ getBannerApi(), getCategoryApi(), getGoodsListApi({ page: 1, size: 10 }), ])

渲染性能方面,长列表用van-list分页,不要一次性渲染几百条。图片用van-image的lazy-load属性,配合Lazyload指令:

import { Lazyload } from 'vant' app.use(Lazyload, { loading: '', error: '' })

上线前必查的清单:路由是否全部懒加载、Pinia 持久化是否只存必要字段、Vant 主题变量是否生效、scrollBehavior是否处理了前进后退、keepAlive的include是否匹配组件名、Toast 是否在路由切换时关闭。这几项过一遍,基本能避开 90% 的线上问题。

最后说个血泪经验:<script setup>里用defineOptions声明组件名时,Vite 的 HMR 有时会缓存旧的组件名,导致keepAlive失效。遇到这种情况,重启 dev server 比改代码管用。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 7:56:00

算法备案时机选择与高效准备策略

1. 算法备案的时机选择误区与行业现状去年参与某金融风控系统项目时&#xff0c;团队在算法上线前72小时才匆忙启动备案流程&#xff0c;结果因为材料不全导致产品延期三周上线——这个惨痛教训让我深刻认识到&#xff0c;算法备案绝不是临门一脚的行政手续。当前行业普遍存在两…

作者头像 李华
网站建设 2026/10/28 7:55:43

多功能电源轨道场景需求、技术参数与安装规范说明

家装厨房动线改造、办公区工位调整、商业空间灵活展陈等场景中&#xff0c;传统固定插座存在点位固化、数量不足的问题。轨道式插座通过适配器沿导轨任意位置取电实现灵活配电。本文从工程技术角度说明场景需求划分、技术参数核查、适配器配套与安装规范&#xff0c;内容基于公…

作者头像 李华
网站建设 2026/10/28 7:54:30

AI Agent 一年实践复盘:从烧钱到大白的工程避坑指南

去年我在 AI Agent 上烧的钱&#xff0c;够买一辆中档电动车了。不是买显卡烧的&#xff0c;是 API 调用、上下文越滚越大、模型选错、重试没设上限&#xff0c;这些杂七杂八的费用一年下来四五千块。最难受的不是花多少&#xff0c;而是很多钱属于“不花就学不会”的学费&…

作者头像 李华
网站建设 2026/10/28 7:51:15

连续互信息:概念、计算与工程应用解析

1. 连续互信息的概念基础连续互信息是信息论中衡量两个连续随机变量之间统计依赖性的核心指标。与离散情形不同&#xff0c;连续互信息涉及概率密度函数的积分运算&#xff0c;其定义式为&#xff1a;$$ I(X;Y) \iint p(x,y) \log \frac{p(x,y)}{p(x)p(y)} dxdy $$这个看似简单…

作者头像 李华
网站建设 2026/10/28 7:51:00

三种AI编程哲学:Claude Code、Codex与Harness

最近不管是技术群还是社区私信&#xff0c;总是冒出同一个问题&#xff1a;Claude Code、Codex、DeepSeek Harness到底该选哪个&#xff1f;这三样东西放在一起对比&#xff0c;本身就说明AI编程工具已经过了"谁家模型分高我选谁"的阶段&#xff0c;进入了一个更讲究…

作者头像 李华
网站建设 2026/10/28 7:50:54

RAG-MCP架构:智能工具链管理的动态决策系统

1. 项目概述&#xff1a;当工具链遇上智能选择去年在重构一个跨平台CLI工具时&#xff0c;我遇到了工具链管理的噩梦&#xff1a;需要同时处理Python数据分析包、Rust高性能模块和前端构建工具&#xff0c;不同工具间的依赖冲突导致环境配置成功率不足60%。这正是RAG-MCP混合架…

作者头像 李华