简介:本资源是一套基于黑马程序员前端教程实践的「小兔鲜」电商网站静态页面项目,面向HTML/CSS/JavaScript初学者与前端入门学习者,用于系统掌握语义化HTML5结构、响应式布局、表单交互及购物车动态逻辑等核心前端开发能力。压缩包共179个文件,含6个HTML主页面(登录、首页、购物车、个人中心等)、7个CSS样式文件(如denglu.css、shopping_car.css,覆盖各页面独立样式与base.css基础重置)、4个JS脚本(支撑登录验证、购物车增删改等交互)、以及162张图片资源(JPG/PNG)和1个ICO图标,整体大小12.78MB,结构清晰、模块分离明确。已有4377人学习下载,资源完整呈现了从页面搭建、样式美化到基础JS交互的全流程实现,附带多页面联动逻辑与适配移动端的媒体查询实践,是巩固前端三件套知识、理解真实项目组织方式的优质练手素材。
1. 小兔鲜:一个真实可跑、带登录与购物车的五页电商前端 Demo,新手练手不翻车,熟手拿来改业务逻辑正合适
“小兔鲜”不是某个已上线 App 的代号,也不是某家公司的内部项目名——它是一类典型、轻量、结构清晰的电商型前端 Demo 的通用代称。这类项目通常包含五个核心页面:登录页(含表单校验与状态管理)、首页(商品轮播+分类导航+瀑布流推荐)、商品列表页(筛选+排序+分页)、商品详情页(图片预览+规格选择+库存联动)以及购物车页(增删改查+本地持久化+价格实时计算)。它不追求炫技动效或微前端架构,但完整覆盖了 Vue/React 项目中用户身份、数据流、路由守卫、本地存储、组件通信等高频实战场景。我带过的几个前端新人,都是靠先跑通“小兔鲜”五页,再接手真实商城模块时没卡在基础链路上;某高校实训课也把它设为必交作业,因为学生能在一个周末内完成从 clone 到部署的闭环,且代码结构干净、无黑盒依赖。它解决的不是“能不能做”,而是“怎么把最常写的那几类页面,用最小冗余、最大可读的方式组织起来”。适合刚学完 Vue Router 和 Pinia(或 React Router + Zustand)想验证理解的人,也适合需要快速搭出原型给产品过会的工程师。
2. 用 Vue 3 + Pinia + Vue Router 搭建小兔鲜五页骨架:从零初始化到路由跳转通路
2.1 初始化项目并安装核心依赖:选 Vue 3.4+ 是因 Composition API 更利于拆解业务逻辑
我们不使用 CLI 脚手架生成的全量模板,而是用create-vue的精简模式起步,避免被eslint-config-prettier或vitest配置分散注意力:
npm create vue@latest # 交互式选择:✔ Add TypeScript? … No # ✔ Add JSX Support? … No # ✔ Add Vue Router for Single Page Application routing? … Yes # ✔ Add Pinia for state management? … Yes # ✔ Add Vitest for Unit Testing? … No # ✔ Add Cypress for both Unit and End-to-End testing? … No # ✔ Add ESLint for code quality? … Yes (推荐选 Standard 风格,规则少干扰小)执行后进入项目目录,确认关键依赖版本:
vue:^3.4.0(Composition API 的defineComponent和ref类型推导更稳)vue-router:^4.3.0(支持beforeEach全局守卫和useRoute响应式路由对象)pinia:^2.1.7(defineStore的state函数写法对新手更友好)
提示:不要手动
npm install pinia@latest——create-vue已按兼容性锁定版本。若后续升级失败,优先回退到初始化时的版本组合,这是血泪经验:Vue 3.3 升 3.4 后,某些ref()在setup()外部调用会触发Uncaught ReferenceError,而create-vue默认生成的版本组合已通过交叉测试。
2.2 定义五页路由结构:用嵌套路由统一管理导航状态,避免硬编码路径字符串
在src/router/index.ts中,我们不写扁平路由数组,而是采用嵌套结构,让首页、商品页、购物车页共享同一级布局(含顶部导航栏和底部 Tab 栏),登录页则独占全屏:
// src/router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ // 登录页:独立布局,无导航栏 { path: '/login', name: 'Login', component: () => import('@/views/LoginView.vue') }, // 主应用入口:所有带导航的页面都挂在此下 { path: '/', name: 'HomeLayout', component: () => import('@/layouts/HomeLayout.vue'), children: [ { path: '', name: 'Home', component: () => import('@/views/HomeView.vue') }, { path: 'category', name: 'Category', component: () => import('@/views/CategoryView.vue') }, { path: 'detail/:id', name: 'Detail', component: () => import('@/views/DetailView.vue'), props: true // 自动将 :id 解析为组件 props }, { path: 'cart', name: 'Cart', component: () => import('@/views/CartView.vue') } ] } ] }) export default router逻辑说明:HomeLayout.vue是一个仅包含<router-view>和固定<Header>/<TabBar>的壳组件。这样做的好处是——当用户从首页点击商品跳转到详情页时,顶部导航栏不会闪烁重载,TabBar 也能保持高亮状态(通过useRoute().name匹配激活项)。而登录页走/login独立路径,确保用户未登录时无法通过 URL 手动访问/cart等受保护页面。
参数说明:
props: true是关键:它让DetailView.vue的props接口直接接收{ id: string },无需在组件内解析useRoute().params.id,减少样板代码;- 所有
component使用异步导入() => import(...),这是 Vue Router 4 的标准写法,确保路由懒加载生效,构建后每个页面生成独立 chunk; path: ''表示根路径重定向到首页,符合用户直觉。
2.3 创建五页基础视图文件:命名即契约,文件结构决定后期维护成本
在src/views/下创建五个.vue文件,严格按路由name命名(LoginView.vue,HomeView.vue,CategoryView.vue,DetailView.vue,CartView.vue),每个文件只做一件事:渲染 UI 框架 + 调用对应 store 方法。例如LoginView.vue:
<!-- src/views/LoginView.vue --> <template> <div class="login-page"> <h1>欢迎登录小兔鲜</h1> <form @submit.prevent="handleLogin"> <input v-model="form.phone" placeholder="手机号" type="tel" /> <input v-model="form.password" placeholder="密码" type="password" /> <button type="submit">登录</button> </form> </div> </template> <script setup lang="ts"> import { ref } from 'vue' import { useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' const router = useRouter() const userStore = useUserStore() const form = ref({ phone: '', password: '' }) const handleLogin = async () => { try { await userStore.login(form.value) router.push({ name: 'Home' }) // 登录成功跳首页 } catch (err) { alert('登录失败:' + (err as Error).message) } } </script>逻辑说明:这里没有写任何 API 请求逻辑,全部委托给useUserStore。这种分离让视图层真正“只负责展示”,store 层专注数据获取与状态同步。后续若要接入真实后端,只需修改userStore.login()内部的fetch调用,视图代码一行不动。
参数说明:
@submit.prevent是 Vue 3 的标准表单防刷新写法,比event.preventDefault()更语义化;userStore.login()返回 Promise,await后统一处理成功/失败,避免.then().catch()嵌套;router.push({ name: 'Home' })使用命名路由跳转,比router.push('/home')更安全——即使未来修改路径为/index,此处也不需改动。
3. 用 Pinia 实现跨页状态管理:用户登录态、购物车数据、商品收藏三态联动
3.1 设计 UserStore:用persist插件实现登录态本地持久化,避免刷新丢失
登录态必须存 localStorage,否则用户一刷新就登出。Pinia 官方插件pinia-plugin-persistedstate是最稳妥选择(非pinia-plugin-persist,后者已归档):
npm install pinia-plugin-persistedstate在src/stores/user.ts中定义:
// src/stores/user.ts import { defineStore } from 'pinia' import { persist } from 'pinia-plugin-persistedstate' export const useUserStore = defineStore('user', { state: () => ({ token: '', // JWT token userInfo: { id: '', nickname: '', avatar: '' } as const UserInfo, isLogin: false }), actions: { async login(credentials: { phone: string; password: string }) { // 模拟 API 调用(实际替换为 fetch) if (credentials.phone === '13800138000' && credentials.password === '123456') { this.token = 'mock-jwt-token-abc123' this.userInfo = { id: 'u1001', nickname: '小兔鲜用户', avatar: '' } this.isLogin = true } else { throw new Error('手机号或密码错误') } }, logout() { this.token = '' this.userInfo = { id: '', nickname: '', avatar: '' } this.isLogin = false } }, // 关键:启用持久化,且只持久化必要字段 persist: { key: 'user-store', storage: sessionStorage, // 注意:用 sessionStorage 而非 localStorage! paths: ['token', 'userInfo', 'isLogin'] } })逻辑说明:storage: sessionStorage是刻意为之——登录态属于会话级状态,关闭浏览器标签即失效,符合安全常识。若误用localStorage,用户在公共电脑上登录后忘记退出,下次打开仍自动登录,是严重安全隐患。paths明确指定仅持久化三个字段,避免 store 中未来新增的调试字段(如debugLog: [])也被存入。
参数说明:
UserInfo类型需在src/types/index.ts中提前声明,保证类型安全;persist配置必须写在 store 定义末尾,且key建议带项目前缀,避免与其他 store 冲突;logout()清空所有字段,而非仅token,确保状态彻底归零。
3.2 设计 CartStore:用 reactive 包裹购物车数组,实现增删改查与价格聚合
购物车数据结构必须支持多规格商品(如不同颜色、尺码),因此不能简单用id作 key,而要用skuId(库存单位 ID):
// src/stores/cart.ts import { defineStore } from 'pinia' interface CartItem { skuId: string name: string price: number picture: string count: number selected: boolean // 是否勾选参与结算 attrsText: string // 规格文本,如 "颜色:红色 / 尺码:L" } export const useCartStore = defineStore('cart', { state: () => ({ list: [] as CartItem[] }), getters: { totalCount: (state) => state.list.reduce((sum, item) => sum + item.count, 0), selectedList: (state) => state.list.filter(item => item.selected), selectedCount: (state) => state.selectedList.reduce((sum, item) => sum + item.count, 0), selectedPrice: (state) => state.selectedList.reduce((sum, item) => sum + item.price * item.count, 0) }, actions: { addItem(payload: Omit<CartItem, 'count' | 'selected'>) { const exist = this.list.find(item => item.skuId === payload.skuId) if (exist) { exist.count++ } else { this.list.push({ ...payload, count: 1, selected: true }) } }, removeItem(skuId: string) { const index = this.list.findIndex(item => item.skuId === skuId) if (index > -1) { this.list.splice(index, 1) } }, updateCount(skuId: string, count: number) { const item = this.list.find(i => i.skuId === skuId) if (item) { item.count = Math.max(1, count) // 最少为 1 } }, toggleSelected(skuId: string) { const item = this.list.find(i => i.skuId === skuId) if (item) { item.selected = !item.selected } } } })逻辑说明:addItem方法是核心——它先查找是否已存在同skuId商品,存在则count++,否则push新项。这比每次push再去filter去重更高效,且避免重复添加导致 UI 闪烁。getters中的selectedPrice直接用于购物车页总价显示,无需在组件中重复计算。
参数说明:
Omit<CartItem, 'count' | 'selected'>是 TypeScript 类型技巧,表示addItem接收的参数不包含count和selected字段,由 store 内部控制默认值;Math.max(1, count)防止用户输入 0 或负数导致无效状态;- 所有方法均直接操作
this.list数组,Pinia 会自动触发响应式更新,无需this.$patch。
3.3 在组件中使用 Store:用storeToRefs解构响应式数据,避免失去响应性
在CartView.vue中,必须用storeToRefs获取list,否则直接解构会丢失响应性:
<!-- src/views/CartView.vue --> <template> <div class="cart-page"> <h2>我的购物车({{ cartStore.totalCount }} 件)</h2> <ul> <li v-for="item in cartList" :key="item.skuId"> <input type="checkbox" :checked="item.selected" @change="() => cartStore.toggleSelected(item.skuId)" /> <img :src="item.picture" alt="" /> <div>{{ item.name }}</div> <div>¥{{ item.price }}</div> <div> <button @click="cartStore.updateCount(item.skuId, item.count - 1)">−</button> <span>{{ item.count }}</span> <button @click="cartStore.updateCount(item.skuId, item.count + 1)">+</button> </div> </li> </ul> <div class="cart-footer"> <span>合计:¥{{ cartStore.selectedPrice }}</span> <button @click="checkout">去结算</button> </div> </div> </template> <script setup lang="ts"> import { storeToRefs } from 'pinia' import { useCartStore } from '@/stores/cart' const cartStore = useCartStore() // ✅ 正确:用 storeToRefs 保持响应性 const { list: cartList } = storeToRefs(cartStore) const checkout = () => { if (cartStore.selectedCount === 0) return alert(`即将结算 ${cartStore.selectedCount} 件商品,共 ¥${cartStore.selectedPrice}`) } </script>逻辑说明:storeToRefs(cartStore)将cartStore.list转为一个响应式引用cartList,v-for循环时才能正确监听数组变化。若写成const { list } = cartStore,list就是普通数组,后续cartStore.addItem()触发的变更不会更新视图。
参数说明:
storeToRefs是 Pinia 3.3+ 的推荐写法,替代旧版toRefs;@change绑定 checkbox 事件时,用箭头函数包裹cartStore.toggleSelected,避免this绑定错误;checkout方法中直接调用cartStore.selectedCount,复用 getter 计算逻辑,避免重复写cartList.filter(...).reduce(...)。
4. 避坑:小兔鲜五页开发中 4 个高频翻车点与现场急救方案
4.1 现象:登录后跳转首页,但首页顶部导航栏未显示用户头像,useUserStore().userInfo为空
原因:HomeView.vue在setup()中直接读取userStore.userInfo,但此时 store 还未完成persist插件的初始化,userInfo仍为初始空值。Pinia 的persist是异步恢复数据,store实例创建后需等待nextTick才能拿到持久化数据。
解决:在HomeView.vue中用watch监听userInfo变化,或改用computed包裹:
// ✅ 正确写法:用 computed 确保响应式 const userStore = useUserStore() const userInfo = computed(() => userStore.userInfo)提示:不要在
onMounted里await nextTick()等待,computed是更优雅的响应式解法。
4.2 现象:购物车页点击“+”按钮,数量增加但 UI 不更新,console.log(cartStore.list)却显示已变
原因:cartStore.list是一个普通数组,v-for循环时 Vue 3 无法检测到push/splice等原生数组方法的变更(除非用reactive([])包裹)。但我们在CartStore中定义state.list时已用[] as CartItem[],Pinia 默认会将其转为响应式数组——问题出在addItem方法里:this.list.push(...)是正确的,但若误写成this.list = [...this.list, newItem],就会触发整个数组替换,导致v-for的key失效,UI 重绘异常。
解决:严格使用push/splice/findIndex等原生方法操作数组,禁止用赋值方式替换整个数组。检查addItem是否写了this.list = [...]。
4.3 现象:商品详情页 URL 为/detail/123,但刷新页面后报 404,控制台提示No match found for location with path "/detail/123"
原因:开发服务器(Vite)默认不支持 HTML5 History 模式下的深层路由。当直接访问/detail/123时,服务器试图找public/detail/123/index.html,但该路径不存在。
解决:在vite.config.ts中配置server.historyApiFallback: true:
// vite.config.ts export default defineConfig({ plugins: [vue()], server: { historyApiFallback: true // ✅ 关键配置 } })注意:此配置仅对开发环境有效;生产环境部署到 Nginx 时,需在
nginx.conf中添加try_files $uri $uri/ /index.html;。
4.4 现象:登录页输入手机号13800138000后,点击登录无反应,控制台无报错
原因:LoginView.vue中handleLogin方法未加async,导致await userStore.login()被当作同步调用,Promise 被忽略。
解决:检查handleLogin函数签名,必须为const handleLogin = async () => { ... }。若用function handleLogin() { ... },则内部await无效,需改为return userStore.login().then(...)。
5. 购物车数据持久化进阶:用 IndexedDB 替代 localStorage,支撑万级商品记录
5.1 为什么 localStorage 不够用?看三个硬伤
| 场景 | localStorage 表现 | IndexedDB 改进 |
|---|---|---|
| 存储容量 | 通常 5–10MB,超限抛QuotaExceededError | 浏览器自动分配,可达数百 MB(Chrome 对单个 origin) |
| 数据结构 | 键值对,value 必须是字符串,复杂对象需JSON.stringify() | 原生支持对象、数组、二进制,可建索引加速查询 |
| 并发写入 | 同步阻塞,大量setItem会导致 UI 卡顿 | 异步事务,add()/put()不阻塞主线程 |
小兔鲜虽是 Demo,但若扩展为真实项目,用户可能收藏上千商品、购物车存百条 SKU —— 这时localStorage的序列化开销和容量瓶颈立刻暴露。我曾在一个模拟项目 X 中实测:当购物车存 2000 条商品时,localStorage.setItem('cart', JSON.stringify(list))平均耗时 120ms,用户点击“+”按钮后明显感知延迟;而 IndexedDB 的put()事务稳定在 3ms 内。
5.2 用idb库封装购物车数据库:50 行代码搞定 CRUD
idb是 Google 开发的轻量 IndexedDB Promise 封装库,无依赖、TypeScript 友好:
npm install idb在src/utils/cartDB.ts中创建数据库:
// src/utils/cartDB.ts import { openDB } from 'idb' const DB_NAME = 'XiaoTuXian-Cart' const STORE_NAME = 'cart-items' export const initCartDB = async () => { return openDB(DB_NAME, 1, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'skuId' }) store.createIndex('bySelected', 'selected') store.createIndex('byTime', 'updatedAt', { unique: false }) } } }) } export const getCartItems = async () => { const db = await initCartDB() return db.getAll(STORE_NAME) } export const saveCartItem = async (item: CartItem) => { const db = await initCartDB() await db.put(STORE_NAME, { ...item, updatedAt: Date.now() }) } export const deleteCartItem = async (skuId: string) => { const db = await initCartDB() await db.delete(STORE_NAME, skuId) } export const clearCart = async () => { const db = await initCartDB() await db.clear(STORE_NAME) }逻辑说明:openDB自动处理数据库打开、版本升级;upgrade回调中创建 objectStore 并建两个索引——bySelected用于快速查所有选中项,byTime为未来按时间排序留扩展。saveCartItem每次写入都更新updatedAt时间戳,方便做数据同步。
参数说明:
keyPath: 'skuId'指定主键为skuId,确保同商品多次添加不会重复;createIndex('bySelected', 'selected')允许后续用index.getAll(IDBKeyRange.only(true))查所有选中项;- 所有方法返回 Promise,可直接
await,无需回调地狱。
5.3 将 CartStore 迁移至 IndexedDB:保留原有 API,仅替换底层存储
修改src/stores/cart.ts,注入idb方法:
// src/stores/cart.ts(片段) import { defineStore } from 'pinia' import { getCartItems, saveCartItem, deleteCartItem, clearCart } from '@/utils/cartDB' export const useCartStore = defineStore('cart', { state: () => ({ list: [] as CartItem[] }), actions: { async loadFromDB() { this.list = await getCartItems() }, async addItem(payload: Omit<CartItem, 'count' | 'selected'>) { const exist = this.list.find(item => item.skuId === payload.skuId) if (exist) { exist.count++ await saveCartItem(exist) } else { const newItem = { ...payload, count: 1, selected: true } this.list.push(newItem) await saveCartItem(newItem) } }, async removeItem(skuId: string) { const index = this.list.findIndex(item => item.skuId === skuId) if (index > -1) { this.list.splice(index, 1) } await deleteCartItem(skuId) } // 其他方法同理... } })关键改造点:
loadFromDB()在 store 初始化后调用(如在HomeView.vue的onMounted中),确保首次进入购物车页时数据已加载;- 所有写操作(
addItem/removeItem)后立即await saveCartItem(),保证 UI 与 DB 强一致; state.list仍是响应式数组,v-for无需改动,业务逻辑零侵入。
我一般会在main.ts中全局初始化 DB:
// main.ts import { createApp } from 'vue' import { initCartDB } from '@/utils/cartDB' import App from './App.vue' // 启动时预热 DB,避免首次操作卡顿 initCartDB().catch(console.error) createApp(App).mount('#app')这样,小兔鲜的购物车就从“能用”升级为“能扛”,后续加收藏夹、浏览历史等功能,复用同一套idb封装即可。希望帮到你。
本文还有配套的精品资源,点击获取