第4章 4.5 综合实战:电商首页 + 详情页

🎯 开场:终于要干点"真活"了!

上一章我们折腾了表单校验,相信你已经被 v-model、自定义规则这些概念折磨得差不多了。但说实话,光填表单多无聊啊——学武功不练实战,跟纸上谈兵有啥区别?

你有没有遇到过这种情况:
- 打开一个电商 App,首页琳琅满目的商品是怎么展示出来的?
- 点进一个商品,详情页的数据又是从哪来的?
- 那些搜索、分类、加入购物车……是怎么串起来的?

这一章,我们就用「Vue 全家桶」做一个小型电商原型:一个能跑的首页 + 详情页。把你之前学的组件、路由、状态管理、请求全部串联起来。

学完你能做到:
- 用 Vue Router 做页面跳转(首页 → 详情页)
- 用 Pinia 管理全局状态(购物车数量、用户信息)
- 用 axios 请求真实 API 数据
- 理解一个「正经项目」是怎么组织代码的


🧱 基础:Vue 项目结构就像一家餐厅

在动手之前,我们先搞清楚一家餐厅是怎么运转的,这样理解 Vue 全家\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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

一路选 YesNo 没关系,我们从头手写一遍加深理解。

项目目录结构

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 链接~


📚 总结

这一章我们做了一个「小型电商原型」,学会了:

  1. Vue Router:让页面跳转(首页 ↔ 详情页 ↔ 购物车)
  2. Pinia:管理全局状态(购物车数量、收藏列表)
  3. axios:请求真实 API 数据

说实话,现在这个项目还挺简陋的——样式丑、没有错误处理、数据全靠别人。

下一章我们要学的东西,正好能解决这些问题……


📚 延伸资源


你在做电商项目时,遇到过什么奇葩 bug? 比如商品价格显示成 NaN、购物车数量不更新之类的……评论区聊聊,老粉优先回复!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。