第 1 节 概述
前几章我们分别学了 Vue 3 的各个知识点。这一章把它们串起来,从零开始做一个完整的小项目。
我们要做一个产品展示站,包含三个页面:
- 首页 — 产品列表,网格展示
- 产品详情页 — 点进某个产品看详情和加购
- 购物车页 — 查看已加购的商品
涉及的技术栈和对应知识点:
| 技术 | 用途 | 对应章节 |
|---|
| Vue 3 + Vite | 项目框架 | 第 1 章 |
| Vue Router | 页面路由 | 第 8 章 |
| Pinia | 购物车状态管理 | 第 9 章 |
| 组合式函数 | 封装产品数据逻辑 | 第 10 章 |
| 组件 + Props/事件 | 产品卡片、头部导航 | 第 4、5 章 |
| 插槽 | 通用的网格布局组件 | 第 6 章 |
| Tailwind CSS | 页面样式 | — |
第 2 节 1. 创建项目
先用 Vite 创建 Vue 3 + TypeScript 项目:
创建时勾选 TypeScript、Router 和 Pinia。
创建完成后安装 Tailwind CSS:
1
| npm install tailwindcss @tailwindcss/vite
|
在 vite.config.ts 中添加 Tailwind 插件:
1
2
3
4
| import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()]
})
|
在入口 CSS 中引入 Tailwind:
1
2
| /* src/assets/main.css */
@import "tailwindcss";
|
然后在 main.ts 中引入这个 CSS 文件(脚手架已自动配置)。
技巧
Tailwind CSS v4 使用 @import "tailwindcss" 而非旧版的 @tailwind 指令。
2.1 目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| src/
├── assets/
│ └── main.css # 全局样式,引入 Tailwind
├── components/
│ ├── HeaderBar.vue # 顶部导航
│ ├── ProductCard.vue # 产品卡片
│ └── ProductGrid.vue # 产品网格容器
├── composables/
│ └── useProducts.ts # 产品数据逻辑
├── router/
│ └── index.ts # 路由配置
├── stores/
│ └── cart.ts # 购物车状态
├── types/
│ └── index.ts # 类型定义
├── views/
│ ├── HomePage.vue # 首页
│ ├── ProductPage.vue # 产品详情
│ └── CartPage.vue # 购物车
├── App.vue # 根组件
└── main.ts # 入口
|
第 3 节 2. 类型定义
先把项目中用到的数据类型写清楚:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // src/types/index.ts
export interface Product {
id: number
name: string
price: number
description: string
image: string
}
export interface CartItem {
product: Product
quantity: number
}
|
第 4 节 3. 封装产品数据(组合式函数)
用组合式函数来封装产品的获取逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| // src/composables/useProducts.ts
import { ref } from 'vue'
import type { Product } from '@/types'
// 模拟数据
const mockProducts: Product[] = [
{
id: 1,
name: '无线降噪耳机',
price: 599,
description: '40dB 混合降噪,30 小时续航,佩戴舒适。支持蓝牙 5.3 和 LDAC 高清编码。',
image: 'https://picsum.photos/seed/headphone/400/300',
},
{
id: 2,
name: '机械键盘',
price: 399,
description: '87 键紧凑配列,Gasket 结构,全键热插拔。兼容 Mac/Win 双系统。',
image: 'https://picsum.photos/seed/keyboard/400/300',
},
{
id: 3,
name: '便携显示器',
price: 1299,
description: '15.6 英寸 2K OLED,100% DCI-P3 色域,Type-C 一线连接。',
image: 'https://picsum.photos/seed/monitor/400/300',
},
{
id: 4,
name: '智能手表',
price: 899,
description: '1.5 英寸 AMOLED 屏幕,14 天续航,心率血氧监测,IP68 防水。',
image: 'https://picsum.photos/seed/watch/400/300',
},
{
id: 5,
name: '桌面充电站',
price: 199,
description: '100W 总功率,3 个 USB-C + 1 个 USB-A,氮化镓技术,桌面不凌乱。',
image: 'https://picsum.photos/seed/charger/400/300',
},
{
id: 6,
name: '笔记本支架',
price: 149,
description: '全铝合金,可调节高度和角度,镂空散热设计。承重 10kg。',
image: 'https://picsum.photos/seed/stand/400/300',
},
]
export function useProducts() {
const products = ref<Product[]>(mockProducts)
const loading = ref(false)
function getProductById(id: number): Product | undefined {
return products.value.find((p) => p.id === id)
}
return { products, loading, getProductById }
}
|
这里用了 ref 包裹数据,组件调用时数据是响应式的。实际项目中可以改成调用 API 接口,结构和用法不变。
第 5 节 4. 路由设计
三个页面,配置路由:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '@/views/HomePage.vue'
import ProductPage from '@/views/ProductPage.vue'
import CartPage from '@/views/CartPage.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomePage,
},
{
path: '/product/:id',
name: 'product',
component: ProductPage,
props: true,
},
{
path: '/cart',
name: 'cart',
component: CartPage,
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
|
路由结构很简单:
| 路径 | 名称 | 页面 |
|---|
/ | home | HomePage — 产品列表 |
/product/:id | product | ProductPage — 产品详情(动态路由) |
/cart | cart | CartPage — 购物车 |
| 未匹配 | — | 重定向回首页 |
技巧
props: true 可以把路由参数 :id 作为 props 传入组件。这样组件更方便测试和复用。
第 6 节 5. 购物车状态(Pinia)
用 Pinia 管理购物车,采用 Setup 写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| // src/stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Product, CartItem } from '@/types'
export const useCartStore = defineStore('cart', () => {
// state
const items = ref<CartItem[]>([])
// getters
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.product.price * item.quantity, 0)
)
// actions
function addItem(product: Product) {
const existing = items.value.find((item) => item.product.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ product, quantity: 1 })
}
}
function removeItem(productId: number) {
items.value = items.value.filter((item) => item.product.id !== productId)
}
function updateQuantity(productId: number, quantity: number) {
if (quantity <= 0) {
removeItem(productId)
return
}
const item = items.value.find((item) => item.product.id === productId)
if (item) {
item.quantity = quantity
}
}
function clearCart() {
items.value = []
}
return { items, totalCount, totalPrice, addItem, removeItem, updateQuantity, clearCart }
})
|
说明
购物车的数据只在内存中,刷新页面会丢失。实际项目中可以用 watch 把数据同步到 localStorage,或者用 pinia-plugin-persistedstate。
第 7 节 6. 组件开发
每个页面顶部都有导航栏,包含 Logo、导航链接和购物车入口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| <!-- src/components/HeaderBar.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
</script>
<template>
<header class="border-b border-gray-200 bg-white sticky top-0 z-10">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<router-link to="/" class="text-xl font-bold text-gray-800">
极简数码
</router-link>
<nav class="flex items-center gap-6">
<router-link to="/" class="text-gray-600 hover:text-gray-900">
首页
</router-link>
<router-link to="/cart" class="relative text-gray-600 hover:text-gray-900">
<span class="text-lg">购物车</span>
<span
v-if="cart.totalCount > 0"
class="absolute -top-2 -right-4 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
>
{{ cart.totalCount }}
</span>
</router-link>
</nav>
</div>
</header>
</template>
|
购物车数量显示为红色角标,数量为 0 时隐藏。数据来自 Pinia store 的 totalCount getter。
7.2 ProductCard — 产品卡片
产品卡片接收产品数据,对外抛出"加入购物车"事件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| <!-- src/components/ProductCard.vue -->
<script setup lang="ts">
import type { Product } from '@/types'
const props = defineProps<{
product: Product
}>()
const emit = defineEmits<{
addToCart: [product: Product]
}>()
</script>
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
<router-link :to="{ name: 'product', params: { id: product.id } }">
<img
:src="product.image"
:alt="product.name"
class="w-full h-48 object-cover"
/>
</router-link>
<div class="p-4">
<router-link :to="{ name: 'product', params: { id: product.id } }">
<h3 class="font-semibold text-gray-900 text-lg">{{ product.name }}</h3>
</router-link>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">{{ product.description }}</p>
<div class="flex items-center justify-between mt-4">
<span class="text-xl font-bold text-orange-500">¥{{ product.price }}</span>
<button
@click="emit('addToCart', product)"
class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600 transition-colors"
>
加入购物车
</button>
</div>
</div>
</div>
</template>
|
说明
产品图片用了 picsum.photos 的占位图。实际项目中替换为真实图片即可。
7.3 ProductGrid — 产品网格
一个通用的网格布局容器,使用插槽让内容灵活填充:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <!-- src/components/ProductGrid.vue -->
<script setup lang="ts">
defineProps<{
columns?: number
}>()
</script>
<template>
<div
class="grid gap-6"
:class="{
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3': !columns || columns === 3,
}"
>
<slot />
</div>
</template>
|
第 8 节 7. 页面组装
8.1 HomePage — 首页
首页从组合式函数获取产品列表,渲染成网格:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| <!-- src/views/HomePage.vue -->
<script setup lang="ts">
import { useProducts } from '@/composables/useProducts'
import { useCartStore } from '@/stores/cart'
import ProductCard from '@/components/ProductCard.vue'
import ProductGrid from '@/components/ProductGrid.vue'
const { products } = useProducts()
const cart = useCartStore()
</script>
<template>
<div class="max-w-6xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">全部产品</h1>
<ProductGrid>
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
@add-to-cart="cart.addItem"
/>
</ProductGrid>
</div>
</template>
|
8.2 ProductPage — 产品详情页
点击产品卡片跳转到此页,展示完整信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
| <!-- src/views/ProductPage.vue -->
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useProducts } from '@/composables/useProducts'
import { useCartStore } from '@/stores/cart'
import { computed } from 'vue'
const route = useRoute()
const router = useRouter()
const { getProductById } = useProducts()
const cart = useCartStore()
const product = computed(() => {
const id = Number(route.params.id)
return getProductById(id) || null
})
function handleAdd() {
if (product.value) {
cart.addItem(product.value)
}
}
</script>
<template>
<div class="max-w-4xl mx-auto px-4 py-8">
<button
@click="router.back()"
class="text-gray-500 hover:text-gray-700 mb-6 block"
>
← 返回
</button>
<div v-if="product" class="flex flex-col md:flex-row gap-8">
<img
:src="product.image"
:alt="product.name"
class="w-full md:w-96 h-72 object-cover rounded-xl"
/>
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ product.name }}</h1>
<p class="text-4xl font-bold text-orange-500 mt-4">¥{{ product.price }}</p>
<p class="text-gray-600 mt-6 leading-relaxed">{{ product.description }}</p>
<button
@click="handleAdd"
class="mt-8 bg-orange-500 text-white px-8 py-3 rounded-lg text-lg hover:bg-orange-600 transition-colors"
>
加入购物车
</button>
</div>
</div>
<div v-else class="text-center py-20">
<p class="text-gray-500 text-lg">产品不存在</p>
<router-link to="/" class="text-orange-500 hover:underline mt-4 block">
返回首页
</router-link>
</div>
</div>
</template>
|
路由参数 :id 通过 useRoute 获取,再用组合式函数的 getProductById 查找对应产品。如果找不到则显示"产品不存在"。
8.3 CartPage — 购物车页
展示购物车中的商品,支持数量和删除操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
| <!-- src/views/CartPage.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
</script>
<template>
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">购物车</h1>
<button
v-if="cart.items.length > 0"
@click="cart.clearCart()"
class="text-sm text-gray-500 hover:text-red-500"
>
清空购物车
</button>
</div>
<div v-if="cart.items.length === 0" class="text-center py-20">
<p class="text-gray-400 text-lg">购物车是空的</p>
<router-link to="/" class="text-orange-500 hover:underline mt-4 block">
去逛逛
</router-link>
</div>
<div v-else class="space-y-4">
<div
v-for="item in cart.items"
:key="item.product.id"
class="flex items-center gap-4 bg-white rounded-xl border border-gray-100 p-4"
>
<img
:src="item.product.image"
:alt="item.product.name"
class="w-20 h-20 object-cover rounded-lg"
/>
<div class="flex-1 min-w-0">
<router-link
:to="{ name: 'product', params: { id: item.product.id } }"
class="font-semibold text-gray-900 hover:text-orange-500"
>
{{ item.product.name }}
</router-link>
<p class="text-orange-500 font-bold mt-1">¥{{ item.product.price }}</p>
</div>
<div class="flex items-center gap-2">
<button
@click="cart.updateQuantity(item.product.id, item.quantity - 1)"
class="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-100"
>
-
</button>
<span class="w-8 text-center">{{ item.quantity }}</span>
<button
@click="cart.addItem(item.product)"
class="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-100"
>
+
</button>
</div>
<div class="text-right w-24">
<p class="font-bold text-gray-900">¥{{ item.product.price * item.quantity }}</p>
<button
@click="cart.removeItem(item.product.id)"
class="text-xs text-gray-400 hover:text-red-500 mt-1"
>
删除
</button>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-4 text-right">
<p class="text-gray-500">
共 <span class="font-bold text-gray-900">{{ cart.totalCount }}</span> 件商品
</p>
<p class="text-2xl font-bold text-orange-500 mt-1">
合计:¥{{ cart.totalPrice }}
</p>
</div>
</div>
</div>
</template>
|
第 9 节 8. App.vue 组装
根组件只做布局——放 HeaderBar 和页面出口:
1
2
3
4
5
6
7
8
9
10
11
| <!-- src/App.vue -->
<script setup lang="ts">
import HeaderBar from '@/components/HeaderBar.vue'
</script>
<template>
<div class="min-h-screen bg-gray-50">
<HeaderBar />
<router-view />
</div>
</template>
|
第 10 节 9. 运行效果
启动项目:
整个应用的页面流如下:
1
2
3
4
5
6
| 首页(产品列表)
├─ 点击产品卡片 → 产品详情页
│ └─ 点击"加入购物车" → 数据存入 Pinia store
└─ 导航栏点击"购物车" → 购物车页
├─ 显示商品列表、数量、合计
└─ 支持加减数量、删除商品、清空购物车
|
总结
本文用一个完整的小项目串联了 Vue 3 的各个核心知识点:
- 路由 — 三个页面的 URL 映射和参数传递
- Pinia — 购物车状态的跨页面共享
- 组合式函数 — 产品数据逻辑的封装
- 组件 + Props/事件 — ProductCard 的父子通信
- 插槽 — ProductGrid 的内容分发
- Tailwind CSS — 基于实用类的样式编写
这个项目麻雀虽小五脏俱全。如果要扩展到真实项目,可以从这几个方向入手:
- 将模拟数据改为真实的 API 调用
- 购物车数据持久化到 localStorage
- 添加用户登录和订单结算流程
- 产品列表支持搜索、筛选、分页