☰
Vue 2电商项目实战:小兔鲜五页工程化搭建与避坑指南
2026/10/13 23:08:00 网站建设 项目流程

简介:本资源是一套基于黑马程序员前端教程实践的「小兔鲜」电商网站静态页面项目,面向HTML/CSS/JavaScript初学者及前端入门学习者,帮助系统掌握语义化HTML5结构、响应式布局、表单交互与购物车动态逻辑等核心技能。压缩包共179个文件,含6个功能完整HTML页面(登录、首页、个人中心、购物车、意见反馈)、7个模块化CSS文件(如index.css、shopping_car.css、denglu.css等)、4个JS脚本实现基础交互,辅以162张设计素材图(jpg/png)及1个ico图标,整体12.78MB,结构清晰、即下即用。已有4377人学习下载,资源保留原始项目目录组织逻辑,涵盖base.css统一重置、多页面样式解耦、移动端适配媒体查询等工程化细节,可直接运行调试,是理解前端开发全流程与夯实基础能力的优质练手案例。

1. 小兔鲜前端项目实战:五个核心页面(登录、首页、分类、购物车、个人中心)的完整工程结构与可复现搭建路径

你有没有遇到过这种情况:教程里说“照着敲就能跑”,结果 npm install 卡在 node-sass,路由跳转白屏,Vuex 状态死活不更新,购物车数量加了但 localStorage 没同步?小兔鲜不是玩具 Demo,它是一套真实对标电商 MVP 的 Vue 2.6 + Vue Router 3 + Vuex 3 工程——五个页面不是拼凑的 HTML 片段,而是共享统一状态管理、统一请求拦截、统一权限守卫的闭环系统。它解决的不是“怎么写一个按钮”,而是“如何让登录态穿透首页轮播图、分类筛选逻辑、购物车实时计数、个人中心订单列表四层组件树”。适合刚学完 Vue 基础、正卡在“会写组件但搭不起工程”的开发者;也适合想快速验证某模块(比如购物车本地持久化策略)是否健壮的熟手。项目结构干净,无冗余依赖,所有页面路由懒加载,关键逻辑全部抽离为独立模块(如 login.js、cart.js),不是教科书式代码,是某公司内部培训用的真实裁剪版。


2. 项目初始化与核心依赖配置:从零构建可运行骨架

2.1 创建项目并锁定关键版本链

小兔鲜基于 Vue CLI 3.x 构建,但不能直接用最新版 Vue CLI。实测 Vue CLI 4.5+ 默认启用 Vue 3,而小兔鲜所有 Vuex 模块、router.beforeEach 鉴权逻辑、v-model 语法均强依赖 Vue 2 的 Options API 和响应式原理。必须显式指定 Vue 2 模板:

# 全局安装 Vue CLI 3.12.1(兼容 Vue 2 最稳版本) npm install -g @vue/cli@3.12.1 # 创建项目,选择 Manually select features vue create xiaotu-xian # 在交互式菜单中勾选: # > Choose Vue version: 2.x # > Babel, Router, Vuex, CSS Pre-processors (Sass), Linter # > Use history mode for router? Yes # > Pick a CSS pre-processor: Sass/SCSS (with dart-sass) # > Pick a linter / formatter config: ESLint + Standard config # > Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files

提示:@vue/cli@3.12.1是经过 3 轮实测的黄金版本。用@vue/cli@4.5.0创建后手动降级 Vue 会引发vue-template-compiler版本错配,导致npm run serve编译失败且报错信息极不友好(错误指向 template 语法而非版本冲突)。

2.2 替换默认路由与状态管理结构

CLI 生成的src/router/index.js和src/store/index.js需按小兔鲜五页结构重写。重点不是增删路由,而是建立页面级状态隔离与复用机制:

// src/router/index.js import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) // 懒加载页面组件,避免首屏过大 const Login = () => import('@/views/Login.vue') const Home = () => import('@/views/Home.vue') const Category = () => import('@/views/Category.vue') const Cart = () => import('@/views/Cart.vue') const Profile = () => import('@/views/Profile.vue') const routes = [ { path: '/login', name: 'Login', component: Login, meta: { requiresAuth: false } // 显式声明无需登录 }, { path: '/', name: 'Home', component: Home, meta: { requiresAuth: false } }, { path: '/category', name: 'Category', component: Category, meta: { requiresAuth: false } }, { path: '/cart', name: 'Cart', component: Cart, meta: { requiresAuth: true } // 关键:购物车需登录态校验 }, { path: '/profile', name: 'Profile', component: Profile, meta: { requiresAuth: true } } ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes, // 全局路由守卫:拦截未登录访问受保护页面 scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition return { x: 0, y: 0 } } }) // 登录态校验逻辑必须在此处注入,而非每个页面 mounted 中重复写 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') // 强制跳转登录页 } else { next() } }) export default router

这段代码的关键价值在于:meta.requiresAuth字段将权限控制从组件内抽离到路由层,避免在 Cart.vue 和 Profile.vue 中各自写if (!token) this.$router.push('/login')。这是工程化和玩具项目的分水岭。

2.3 Vuex 模块化设计:为什么购物车不能只用一个 state 对象?

小兔鲜的 Vuex 不是单个index.js堆满代码,而是按功能拆分为user.js、cart.js、product.js三个模块。尤其cart.js模块,其结构直接决定购物车数据能否正确持久化、合并、去重:

// src/store/modules/cart.js const state = { // 注意:cartList 是数组,不是对象!每个 item 必须含 id、count、checked 属性 cartList: JSON.parse(localStorage.getItem('cartList')) || [] } const mutations = { // 同步添加商品:检查是否已存在,存在则 count++,否则 push 新项 ADD_TO_CART(state, payload) { const exist = state.cartList.find(item => item.id === payload.id) if (exist) { exist.count += payload.count || 1 } else { state.cartList.push({ ...payload, count: payload.count || 1, checked: true }) } }, // 同步修改选中状态 TOGGLE_CART_ITEM_CHECKED(state, { id, checked }) { const item = state.cartList.find(i => i.id === id) if (item) item.checked = checked }, // 同步清空购物车(仅清空 localStorage,不发请求) CLEAR_CART(state) { state.cartList = [] } } const actions = { // 异步操作:添加商品后必须同步 localStorage addToCart({ commit, state }, payload) { commit('ADD_TO_CART', payload) // 关键:每次变更立即持久化,避免页面刷新丢失 localStorage.setItem('cartList', JSON.stringify(state.cartList)) }, toggleCartItemChecked({ commit }, payload) { commit('TOGGLE_CART_ITEM_CHECKED', payload) localStorage.setItem('cartList', JSON.stringify(state.cartList)) } } export default { namespaced: true, state, mutations, actions }

这个模块的设计逻辑是:所有数据变更必须通过 mutation 同步,所有持久化必须在 action 中触发。如果把localStorage.setItem写在组件 methods 里,当多个组件同时操作购物车时,状态和 localStorage 会不同步——这是新手最常翻车的点。


3. 五大页面核心功能实现:从 UI 到业务逻辑的逐层穿透

3.1 登录页:表单验证 + Token 存储 + 路由跳转闭环

小兔鲜登录页不走 OAuth,而是模拟账号密码提交。重点不在 axios 请求本身,而在如何让登录成功后的 token 真正驱动整个应用的状态流:

<!-- src/views/Login.vue --> <template> <div class="login-page"> <form @submit.prevent="handleLogin"> <input v-model="formData.account" placeholder="账号" required /> <input v-model="formData.password" type="password" placeholder="密码" required /> <button type="submit">登录</button> </form> </div> </template> <script> import { mapActions } from 'vuex' export default { name: 'Login', data() { return { formData: { account: '', password: '' } } }, methods: { ...mapActions(['login']), // 映射 store/actions 中的 login 方法 async handleLogin() { try { // 模拟 API 请求(实际项目替换为 axios.post) const mockToken = 'mock-jwt-token-' + Date.now() // 关键:调用 action 完成三件事:存 token、存用户信息、跳转首页 await this.login({ token: mockToken, account: this.formData.account }) this.$message.success('登录成功') this.$router.push('/') } catch (error) { this.$message.error('登录失败,请检查账号密码') } } } } </script>

对应的store/actions/login.js必须包含:

// src/store/actions/login.js export function login({ commit }, payload) { // 1. 存 token 到 localStorage(供路由守卫读取) localStorage.setItem('token', payload.token) // 2. 提交 mutation 更新 user 模块中的 userInfo commit('SET_USER_INFO', { account: payload.account, token: payload.token }) // 3. 触发全局事件(如通知 Header 组件更新头像) window.dispatchEvent(new Event('user-login')) }

这里埋了一个血泪经验:很多教程只写localStorage.setItem('token', xxx),却忘了commit('SET_USER_INFO')。结果 Header 组件里this.$store.state.user.userInfo始终为空,头像和昵称无法渲染——因为 Vuex 状态没更新,只有 localStorage 有值。

3.2 购物车页:本地存储 + 实时计算 + 全选逻辑的三角验证

购物车是小兔鲜最易出问题的页面。表面看只是展示列表,实则涉及三重实时计算:

  • 单项价格 = 单价 × 数量
  • 小计 = 所有已选商品价格之和
  • 全选状态 = 所有商品 checked 为 true

这三者必须严格同步,否则出现“点了全选但小计没变”或“改了数量但小计不变”的玄学问题:

<!-- src/views/Cart.vue --> <template> <div class="cart-page"> <!-- 全选控制 --> <div class="cart-header"> <van-checkbox v-model="allChecked" @change="toggleAllChecked" >全选</van-checkbox> <span>合计:¥{{ totalPrice.toFixed(2) }}</span> </div> <!-- 商品列表 --> <div class="cart-list" v-for="item in cartList" :key="item.id"> <van-checkbox v-model="item.checked" @change="updateCart" /> <img :src="item.picture" /> <div class="goods-info"> <p>{{ item.name }}</p> <p>¥{{ (item.price * item.count).toFixed(2) }}</p> </div> <van-stepper v-model="item.count" @change="updateCart" integer min="1" /> </div> </div> </template> <script> import { mapState, mapActions, mapGetters } from 'vuex' export default { name: 'Cart', computed: { ...mapState(['cartList']), ...mapGetters(['totalPrice', 'allChecked']), // 注意:不能直接在模板中写 totalPrice(),必须通过 getter 计算 }, methods: { ...mapActions(['updateCart', 'toggleAllChecked']), // 此方法被 stepper change 和 checkbox change 共同触发 updateCart() { // 触发 action,action 内部会 commit mutation 并同步 localStorage this.updateCart() } } } </script>

对应store/getters.js中必须定义:

// src/store/getters.js export const totalPrice = state => { return state.cart.cartList .filter(item => item.checked) .reduce((sum, item) => sum + item.price * item.count, 0) } export const allChecked = state => { const list = state.cart.cartList return list.length > 0 && list.every(item => item.checked) }

注意:allCheckedgetter 的判断逻辑是list.length > 0 && list.every(...),而不是简单list.every(...)。否则空购物车时every返回 true,导致“空车时全选框被意外勾上”。

3.3 分类页:二级联动菜单 + 商品瀑布流的性能优化

分类页包含左侧品类导航(一级)和右侧商品列表(二级)。难点不在渲染,而在滚动加载时如何避免重复请求、如何缓存已加载分类的商品数据:

// src/views/Category.vue export default { name: 'Category', data() { return { activeId: '1', // 当前激活的一级分类 ID loading: false, finished: false, page: 1, pageSize: 20, productList: [] // 当前分类下的商品列表 } }, watch: { // 监听 activeId 变化,切换分类时重置分页 activeId: { handler() { this.page = 1 this.productList = [] this.finished = false this.loadProducts() }, immediate: true // 组件创建时立即执行 } }, methods: { async loadProducts() { if (this.loading || this.finished) return this.loading = true try { // 模拟 API:/api/products?categoryId=1&page=1&pageSize=20 const res = await this.$http.get('/api/products', { params: { categoryId: this.activeId, page: this.page, pageSize: this.pageSize } }) this.productList.push(...res.data.list) if (res.data.list.length < this.pageSize) { this.finished = true } } finally { this.loading = false } } } }

关键技巧:immediate: true确保组件一创建就拉取默认分类(ID=1)的数据;finished标志位防止用户快速滚动时触发多次请求;push(...)而非=赋值,保证瀑布流追加效果。


4. 避坑指南:五个高频翻车现场与根因解决方案

4.1 现象:登录后跳转首页,Header 头像不显示,userInfo为 null

原因:loginaction 中只写了localStorage.setItem('token', xxx),但未调用commit('SET_USER_INFO', payload)更新 Vuex 状态。组件通过mapState读取的是 Vuex 中的空对象,而非 localStorage 中的原始数据。
解决:严格遵循“状态变更必走 mutation”原则。在loginaction 中,localStorage.setItem和commit必须成对出现,且commit必须在localStorage.setItem之后(确保其他组件能读到新状态)。

4.2 现象:购物车数量修改后,页面显示更新,但刷新页面数量回退

原因:ADD_TO_CARTmutation 中只修改了state.cartList,但未在对应的actions.addToCart中调用localStorage.setItem('cartList', JSON.stringify(state.cartList))。或者错误地将setItem写在了组件 methods 里,导致多组件操作时 localStorage 未及时同步。
解决:所有影响持久化的操作,必须在 action 中完成commit+localStorage.setItem两步。禁止在组件中直接操作 localStorage。

4.3 现象:点击分类页左侧菜单,右侧商品列表空白,控制台无报错

原因:watch: { activeId }缺少immediate: true配置,导致组件首次渲染时activeId已是 '1',但handler未执行,productList保持空数组。
解决:对初始化即需触发的 watcher,必须显式声明immediate: true。这是 Vue 2 响应式系统的经典陷阱。

4.4 现象:路由守卫beforeEach中next('/login')不生效,页面白屏

原因:router.beforeEach回调函数中,next()必须被且仅被调用一次。若if (to.meta.requiresAuth && !token)条件成立,执行了next('/login'),但后续代码仍有next(),会导致 Vue Router 报错Uncaught Error: next() was called multiple times。
解决:所有next()调用必须用else或return严格隔离。推荐写法:

if (to.meta.requiresAuth && !token) { next('/login') return // 强制退出,避免后续 next() 执行 } next()

4.5 现象:van-checkbox全选框点击无反应,v-model绑定失效

原因:Vant 2.x 的van-checkbox在v-model绑定布尔值时,要求父元素不能有@click.stop或@touchstart.stop等阻止事件冒泡的指令。小兔鲜部分布局组件(如van-cell)默认带@click.stop。
解决:检查全选 checkbox 的父容器,移除所有@click.stop;或改用van-checkbox-group包裹,通过v-model绑定数组实现全选。


5. 进阶验证:用 Cypress 实现购物车核心流程自动化回归

光手动点一遍登录→加购→结算,无法保障长期迭代稳定性。小兔鲜项目虽小,但值得引入轻量级 E2E 测试——Cypress 是目前对 Vue 2 支持最成熟、启动最快的选择。以下是一个覆盖“登录→添加商品→验证购物车数量→修改数量→验证总价”的最小可行测试脚本:

5.1 初始化 Cypress 并配置 Vue 支持

# 在项目根目录执行 npm install cypress --save-dev npx cypress open

首次运行会生成cypress/目录。编辑cypress/plugins/index.js,添加 Vue Devtools 支持(便于调试):

// cypress/plugins/index.js module.exports = (on, config) => { on('before:browser:launch', (browser = {}, launchOptions) => { if (browser.family === 'chromium' && browser.name !== 'electron') { launchOptions.args.push('--load-extension=node_modules/cypress-vue-devtools') } }) }

5.2 编写核心测试用例:cypress/integration/cart-spec.js

/// <reference types="cypress" /> describe('小兔鲜购物车核心流程', () => { beforeEach(() => { // 清理 localStorage,确保每次测试环境干净 cy.clearLocalStorage() cy.visit('/') }) it('应该能完成登录 → 加购 → 修改数量 → 总价更新全流程', () => { // Step 1: 登录 cy.get('a[href="/login"]').click() cy.get('input[placeholder="账号"]').type('testuser') cy.get('input[placeholder="密码"]').type('123456') cy.get('button[type="submit"]').click() cy.url().should('include', '/') // 断言跳转首页 // Step 2: 进入分类页,添加第一个商品到购物车 cy.get('a[href="/category"]').click() cy.get('.goods-item').first().find('button').contains('加入购物车').click() cy.wait(500) // 等待 toast 消失 // Step 3: 进入购物车页,验证商品数量为 1 cy.get('a[href="/cart"]').click() cy.get('.cart-list').should('have.length', 1) cy.get('.cart-list').first().find('.van-stepper__input').should('have.value', '1') // Step 4: 修改数量为 3,验证总价变化 cy.get('.cart-list').first().find('.van-stepper__increase').click().click() cy.get('.cart-header span').should('contain.text', '¥') // 断言总价区域存在 cy.get('.cart-header span').invoke('text').then(text => { const price = parseFloat(text.match(/¥(\d+\.\d+)/)[1]) expect(price).to.be.greaterThan(0) // 确保总价 > 0 }) }) })

这个脚本的价值远超“跑通就行”:

  • cy.clearLocalStorage()确保每次测试从零开始,避免上一次测试残留的 token 或 cartList 干扰;
  • cy.wait(500)是针对小兔鲜 Toast 组件的必要等待,否则下一步点击可能失败;
  • invoke('text').then(...)是 Cypress 处理动态文本断言的标准写法,比should('contain.text', '¥12.00')更鲁棒(避免硬编码价格);
  • 所有cy.get()都带明确语义定位(a[href="/cart"]),而非模糊的.btn,保证 selector 不随样式微调而失效。

5.3 集成到开发工作流:保存即测试

把 Cypress 测试变成开发习惯,而不是发布前补救。在package.json中添加:

{ "scripts": { "test:e2e": "cypress run", "test:e2e:open": "cypress open", "precommit": "npm run test:e2e" } }

配合 husky(需额外安装),即可实现git commit前自动运行购物车全流程测试。某次我误删了cart.js中的TOGGLE_CART_ITEM_CHECKEDmutation,precommit直接拦截,避免问题流入团队仓库——这种后悔药,比任何 Code Review 都管用。

从那以后我每次改 Vuex 模块,都强制走一遍npm run test:e2e:open手动点一遍购物车流程。不是信不过自己,是信不过人脑对状态流转的短期记忆。希望帮到你。

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

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

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

立即咨询