第4章 4.5 综合实战:电商首页 + 详情页
🎯 开场:终于要干点"真活"了!
上一章我们折腾了表单校验,相信你已经被 v-model、自定义规则这些概念折磨得差不多了。但说实话,光填表单多无聊啊——学武功不练实战,跟纸上谈兵有啥区别?
你有没有遇到过这种情况:
- 打开一个电商 App,首页琳琅满目的商品是怎么展示出来的?
- 点进一个商品,详情页的数据又是从哪来的?
- 那些搜索、分类、加入购物车……是怎么串起来的?
这一章,我们就用「Vue 全家桶」做一个小型电商原型:一个能跑的首页 + 详情页。把你之前学的组件、路由、状态管理、请求全部串联起来。
学完你能做到:
- 用 Vue Router 做页面跳转(首页 → 详情页)
- 用 Pinia 管理全局状态(购物车数量、用户信息)
- 用 axios 请求真实 API 数据
- 理解一个「正经项目」是怎么组织代码的
🧱 基础:Vue 项目结构就像一家餐厅
在动手之前,我们先搞清楚一家餐厅是怎么运转的,这样理解 Vue 全家\n\n
\n\n
\n\n桶就简单多了:
餐厅版 Vue 全家桶
| Vue 概念 | 餐厅类比 | 是干嘛的 |
|---|---|---|
| Vue Router | 餐厅导航员 | 决定客人该去哪个桌(页面) |
| Pinia | 中央厨房 | 存放共享食材(全局状态),所有部门都能用 |
| axios | 采购员 | 外出采购食材(请求接口数据) |
| 组件 | 厨房里的大厨 | 每个人负责一道菜(功能) |
现在你知道了每个角色的职责,接下来我们就按这个逻辑搭项目!
创建项目
npm create vue@latest shop-demo
cd shop-demo
npm install
npm install vue-router@4 pinia axios
一路选 Yes 或 No 没关系,我们从头手写一遍加深理解。
项目目录结构
shop-demo/
├── src/
│ ├── api/ # 放 axios 请求封装
│ ├── components/ # 公共组件(导航栏、商品卡片)
│ ├── views/ # 页面组件(首页、详情页)
│ ├── stores/ # Pinia 状态管理
│ ├── router/ # 路由配置
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
🔥 实战:从零搭一个电商小 demo
项目 1:配置路由(5 分钟)
目标:让浏览器访问 / 显示首页,访问 /product/:id 显示详情页。
第 1 步:创建两个页面组件
src/views/HomeView.vue(首页):
<template>
<div class="home">
<h1>🛍️ 欢迎来到小明的杂货铺</h1>
<p>这里是首页,正在出售各种好物~</p>
</div>
</template>
<script setup>
</script>
<style scoped>
.home {
padding: 20px;
text-align: center;
}
</style>
src/views/ProductView.vue(详情页):
<template>
<div class="product">
<h1>📦 商品详情页</h1>
<p>商品 ID:{{ $route.params.id }}</p>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<style scoped>
.product {
padding: 20px;
text-align: center;
}
</style>
第 2 步:配置路由规则
src/router/index.js:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import ProductView from '../views/ProductView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/product/:id',
name: 'product',
component: ProductView
}
]
})
export default router
第 3 步:在 main.js 里启用路由
src/main.js:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
第 4 步:App.vue 里放一个路由出口
src/App.vue:
<template>
<nav>
<router-link to="/">首页</router-link> |
<router-link to="/product/1">商品1</router-link> |
<router-link to="/product/2">商品2</router-link>
</nav>
<router-view />
</template>
<style>
nav {
padding: 20px;
background: #f5f5f5;
text-align: center;
}
nav a {
margin: 0 10px;
text-decoration: none;
color: #42b983;
}
router-link-active {
font-weight: bold;
}
</style>
运行 npm run dev,点击链接看看——路由通了!
✅ 预期输出:点击「商品1」链接,页面跳转到 /product/1,显示「商品 ID:1」
项目 2:用 axios 请求真实商品数据(15 分钟)
上一章我们学了表单校验,但数据从哪来?这里用 axios 发请求。
第 1 步:封装 axios 请求
src/api/goods.js:
import axios from 'axios'
// 创建 axios 实例,设置基础配置
const request = axios.create({
baseURL: 'https://fakestoreapi.com', // 免费的假数据 API
timeout: 5000
})
// 请求拦截器:每个请求都自动带上这个
request.interceptors.request.use(config => {
console.log('发请求了:', config.url)
return config
})
// 响应拦截器:统一处理错误
request.interceptors.response.use(
response => response.data,
error => {
console.error('请求失败了', error)
return Promise.reject(error)
}
)
export default request
第 2 步:在 Pinia 里定义商品状态
src/stores/goods.js:
import { defineStore } from 'pinia'
import request from '../api/goods'
export const useGoodsStore = defineStore('goods', {
state: () => ({
products: [], // 商品列表
currentProduct: null, // 当前商品
loading: false,
error: null
}),
actions: {
// 获取商品列表
async fetchProducts() {
this.loading = true
this.error = null
try {
// FakeStoreAPI 的商品接口
const data = await request.get('/products')
this.products = data
} catch (err) {
this.error = '获取商品列表失败'
console.error(err)
} finally {
this.loading = false
}
},
// 获取单个商品详情
async fetchProduct(id) {
this.loading = true
this.error = null
try {
const data = await request.get(`/products/${id}`)
this.currentProduct = data
} catch (err) {
this.error = '获取商品详情失败'
console.error(err)
} finally {
this.loading = false
}
}
}
})
第 3 步:在首页展示商品列表
src/views/HomeView.vue:
<template>
<div class="home">
<h1>🛍️ 欢迎来到小明的杂货铺</h1>
<div v-if="goodsStore.loading">加载中...</div>
<div v-else-if="goodsStore.error">{{ goodsStore.error }}</div>
<div v-else class="product-list">
<div
v-for="product in goodsStore.products"
:key="product.id"
class="product-card"
@click="goToDetail(product.id)"
>
<img :src="product.image" :alt="product.title" class="product-image">
<h3>{{ product.title }}</h3>
<p>💰 ${{ product.price }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGoodsStore } from '../stores/goods'
const router = useRouter()
const goodsStore = useGoodsStore()
onMounted(() => {
goodsStore.fetchProducts()
})
const goToDetail = (id) => {
router.push(`/product/${id}`)
}
</script>
<style scoped>
.product-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
padding: 20px;
}
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
cursor: pointer;
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.product-image {
width: 100%;
height: 150px;
object-fit: contain;
}
</style>
第 4 步:在详情页展示商品详情
src/views/ProductView.vue:
<template>
<div class="product">
<div v-if="goodsStore.loading">加载中...</div>
<div v-else-if="goodsStore.error">{{ goodsStore.error }}</div>
<div v-else-if="goodsStore.currentProduct" class="product-detail">
<button @click="goBack" class="back-btn">← 返回</button>
<img :src="goodsStore.currentProduct.image" class="detail-image">
<h1>{{ goodsStore.currentProduct.title }}</h1>
<p class="price">💰 ${{ goodsStore.currentProduct.price }}</p>
<p class="description">{{ goodsStore.currentProduct.description }}</p>
<p class="category">分类:{{ goodsStore.currentProduct.category }}</p>
<button @click="addToCart" class="cart-btn">🛒 加入购物车</button>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGoodsStore } from '../stores/goods'
import { useCartStore } from '../stores/cart'
const route = useRoute()
const router = useRouter()
const goodsStore = useGoodsStore()
const cartStore = useCartStore()
onMounted(() => {
const id = route.params.id
goodsStore.fetchProduct(id)
})
const goBack = () => {
router.push('/')
}
const addToCart = () => {
cartStore.addItem(goodsStore.currentProduct)
alert('已加入购物车!')
}
</script>
<style scoped>
.product-detail {
max-width: 600px;
margin: 0 auto;
padding: 20px;
text-align: center;
}
.detail-image {
width: 300px;
height: 300px;
object-fit: contain;
}
.price {
font-size: 24px;
color: #42b983;
font-weight: bold;
}
.description {
color: #666;
line-height: 1.6;
}
.category {
color: #999;
font-size: 14px;
}
.cart-btn {
background: #42b983;
color: white;
border: none;
padding: 12px 30px;
border-radius: 25px;
font-size: 16px;
cursor: pointer;
margin-top: 20px;
}
.back-btn {
background: #f5f5f5;
border: none;
padding: 8px 16px;
cursor: pointer;
margin-bottom: 20px;
}
</style>
✅ 预期输出:首页显示商品列表,点击商品跳转到详情页,显示商品图片、标题、价格、描述。
项目 3:加入购物车状态管理(15 分钟)
现在首页和详情页都通了,但购物车数量应该在任何页面都能看到——这就需要 Pinia 全局状态。
第 1 步:创建购物车 Store
src/stores/cart.js:
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [], // 购物车里的商品
}),
getters: {
// 计算购物车总数
totalCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
// 计算总价
totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
actions: {
addItem(product) {
// 查找购物车是否已有该商品
const existItem = this.items.find(item => item.id === product.id)
if (existItem) {
// 已有就 +1 数量
existItem.quantity++
} else {
// 没有就添加
this.items.push({
id: product.id,
title: product.title,
price: product.price,
image: product.image,
quantity: 1
})
}
},
removeItem(id) {
this.items = this.items.filter(item => item.id !== id)
},
clearCart() {
this.items = []
}
}
})
第 2 步:在导航栏显示购物车数量
修改 src/App.vue:
<template>
<nav>
<router-link to="/">首页</router-link> |
<router-link to="/product/1">商品1</router-link> |
<router-link to="/product/2">商品2</router-link>
<span class="cart-info">🛒 购物车 ({{ cartStore.totalCount }})</span>
</nav>
<router-view />
</template>
<script setup>
import { useCartStore } from './stores/cart'
const cartStore = useCartStore()
</script>
<style>
nav {
padding: 20px;
background: #f5f5f5;
text-align: center;
}
nav a {
margin: 0 10px;
text-decoration: none;
color: #42b983;
}
.cart-info {
margin-left: 30px;
color: #ff6b6b;
font-weight: bold;
}
</style>
第 3 步:做个简单的购物车页面
src/views/CartView.vue:
<template>
<div class="cart">
<h1>🛒 我的购物车</h1>
<div v-if="cartStore.items.length === 0">
<p>购物车是空的,去首页逛逛吧~</p>
<router-link to="/">去首页</router-link>
</div>
<div v-else>
<div v-for="item in cartStore.items" :key="item.id" class="cart-item">
<img :src="item.image" class="item-image">
<div class="item-info">
<h3>{{ item.title }}</h3>
<p>单价:${{ item.price }} × {{ item.quantity }}</p>
</div>
<button @click="cartStore.removeItem(item.id)">删除</button>
</div>
<div class="cart-summary">
<p>共 {{ cartStore.totalCount }} 件商品</p>
<p class="total">总计:${{ cartStore.totalPrice.toFixed(2) }}</p>
<button @click="cartStore.clearCart">清空购物车</button>
</div>
</div>
</div>
</template>
<script setup>
import { useCartStore } from '../stores/cart'
const cartStore = useCartStore()
</script>
<style scoped>
.cart {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.cart-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
border-bottom: 1px solid #eee;
}
.item-image {
width: 60px;
height: 60px;
object-fit: contain;
}
.item-info {
flex: 1;
}
.cart-summary {
margin-top: 20px;
text-align: right;
}
.total {
font-size: 24px;
color: #42b983;
font-weight: bold;
}
</style>
第 4 步:添加购物车路由
在 src/router/index.js 里加一行:
import CartView from '../views/CartView.vue'
// routes 数组里加这个
{
path: '/cart',
name: 'cart',
component: CartView
}
并在 App.vue 的导航里加一个链接:
<router-link to="/cart">🛒 购物车</router-link>
✅ 预期输出:
- 首页点「加入购物车」按钮
- 右上角购物车数量实时 +1
- 点击购物车链接,看到已添加的商品
💪 进阶:3 个新手必踩的坑
坑 1:路由参数是字符串,不是数字 ❌ → ✅
// ❌ 错误:API 请求失败
const id = route.params.id // 这里拿到的是字符串 "1"
const data = await request.get(`/products/${id}`) // fakestoreapi 认数字
// ✅ 正确:转成数字
const id = Number(route.params.id)
坑 2:Pinia 在 setup 外获取 store ❌ → ✅
// ❌ 错误:在 onMounted 外面获取 store 实例
const goodsStore = useGoodsStore()
onMounted(() => {
goodsStore.fetchProducts() // 可能在组件挂载前就执行了
})
// ✅ 正确:在 setup 里获取
const goodsStore = useGoodsStore()
onMounted(() => {
goodsStore.fetchProducts()
})
坑 3:请求完没清空旧数据 ❌ → ✅
// ❌ 错误:切换商品时,先显示旧数据,再闪一下新数据
async fetchProduct(id) {
this.loading = true
try {
const data = await request.get(`/products/${id}`)
this.currentProduct = data
} finally {
this.loading = false
}
}
// ✅ 正确:先清空,再请求
async fetchProduct(id) {
this.loading = true
this.currentProduct = null // 先清空!
try {
const data = await request.get(`/products/${id}`)
this.currentProduct = data
} finally {
this.loading = false
}
}
坑 4:图片加载失败没兜底 ❌ → ✅
<!-- ❌ 危险:图片挂了就是白块 -->
<img :src="product.image">
<!-- ✅ 稳妥:加个兜底图 -->
<img
:src="product.image"
@error="($event.target as HTMLImageElement).src = '/placeholder.png'"
>
调试技巧:Vue DevTools
打开浏览器开发者工具(F12),安装 Vue DevTools 插件后,你可以:
- 查看 Pinia store 里的状态变化
- 看到每次路由切换
- 检查组件的 data 和 props
✏️ 练习题
练习 1(1 分钟):改商品 ID
- 输入:把详情页路由
/product/1改成/product/5 - 预期输出:显示 ID 为 5 的商品信息
- 提示:直接在地址栏改,或者改
App.vue里的链接
练习 2(2 分钟):加个分类筛选
- 输入:在首页加一个「只显示价格 > 50 的商品」的判断
- 预期输出:只展示价格大于 50 的商品
- 提示:用
v-if="product.price > 50"过滤
练习 3(5 分钟):用新 API 数据
- 输入:用
https://jsonplaceholder.typicode.com/users获取用户数据 - 预期输出:在控制台打印出用户列表
- 提示:复用
api/goods.js的 request 实例,改个 baseURL
练习 4(5 分钟):把商品列表改成用户列表
- 输入:用练习 3 的 API 数据显示用户卡片
- 预期输出:首页显示用户 name、email、phone
- 提示:把
products改成users,字段名对应改
练习 5(5 分钟):修复报错
- 输入:运行时报错
Cannot read properties of undefined (reading 'title') - 预期输出:说出原因并写出修复方案
- 提示:检查数据加载顺序
作业:做个完整的「商品浏览 + 收藏夹」小工具
需求描述:
做一个类似购物车的工具,但功能换成「收藏」——用户可以浏览商品、收藏喜欢的商品、查看收藏列表、取消收藏。
功能点:
1. 首页展示商品列表(用 fakestoreapi)
2. 商品卡片有个 ⭐ 收藏按钮,点击切换收藏状态
3. 右上角显示收藏数量
4. 有个「收藏页」,展示所有收藏的商品
5. 收藏页可以「取消收藏」,收藏数实时更新
加分项:
1. 收藏数据持久化(刷新页面不丢失,用 localStorage)
2. 加个「分类筛选」功能
验收标准:
- 能跑起来(npm run dev 不报错)
- 首页显示商品列表
- 点击收藏有反应
- 收藏页正确显示已收藏商品
提交方式: 评论区贴代码或 GitHub 链接~
📚 总结
这一章我们做了一个「小型电商原型」,学会了:
- Vue Router:让页面跳转(首页 ↔ 详情页 ↔ 购物车)
- Pinia:管理全局状态(购物车数量、收藏列表)
- axios:请求真实 API 数据
说实话,现在这个项目还挺简陋的——样式丑、没有错误处理、数据全靠别人。
下一章我们要学的东西,正好能解决这些问题……
📚 延伸资源
- Vue Router 官方文档:路由守卫、嵌套路由
- Pinia 官方文档:持久化、模块化
- FakeStoreAPI:我们用到的免费假数据接口
你在做电商项目时,遇到过什么奇葩 bug? 比如商品价格显示成 NaN、购物车数量不更新之类的……评论区聊聊,老粉优先回复!

评论(0)