模块四:分类聚合 - 构建动态商品列表页

模块四:分类聚合 - 构建动态商品列表页

本章概述: 欢迎来到模块四。在本章,我们将从“全局视野”转向“业务深耕”,构建电商应用的核心——商品分类聚合页。你将学习如何利用 Vue Router 的动态路由 捕获用户意图,并首次实践 组合式函数 (Composable) 这一 Vue 3 的核心思想,将复杂的、可复用的业务逻辑从组件中优雅地解耦出来。最终,我们将结合 Element Plus 的面包屑导航,打造一个数据驱动、高度可维护的二级分类页面。

img

经过前三个模块的洗礼,相信您已经完全掌握了项目的工程化基石、全局布局搭建和核心认证流程。

本模块核心学习目标:

  1. 【核心】掌握组合式函数 (Composables) 封装: 学习如何将“数据获取逻辑”(API 调用、路由依赖、响应式状态管理)封装到独立的 useCategory.ts 文件中,实现业务逻辑的 高内聚和可复用性
  2. 实践关注点分离: 通过创建 useBanneruseCategory,让 Category/index.vue 组件的职责回归到纯粹的 “组织和呈现”,而不是“获取和处理数据”。
  3. 精通动态路由参数的安全处理: 在 Composable 内部优雅地处理 id 的变化和边界情况,让组件对此“无感”。
  4. 构建数据驱动的 UI: 学习使用 ElBreadcrumb 等组件,并消费来自 Composables 的数据,构建数据驱动的用户界面。

本模块任务清单

任务模块任务名称核心目标与学习要点
【核心】逻辑层封装创建 useCategory Composable将“获取单个分类数据”的逻辑,从组件中抽离并封装成一个独立的、响应式的 useCategory 组合式函数。
【核心】逻辑层封装创建 useBanner Composable将“获取分类页 Banner”的逻辑也封装成独立的 useBanner 函数,进一步实践逻辑解耦。
视图层构建Category 页面的组装构建 Category/index.vue,使其只负责调用 useCategoryuseBanner,并将返回的响应式数据传递给子组件进行渲染。
UI 组件实践动态面包屑导航与 Banner使用 Element Plus 组件,并消费来自 Composables 的数据,构建数据驱动的面包屑和 Banner。
业务组件封装商品列表与 GoodsItem 组件封装 GoodsItem.vue,并在 Category/index.vue 中渲染商品列表。
模块提交提交模块成果提交一个结构清晰、逻辑解耦的分类页模块。

4.1 Mock 体系终极进化:从文件系统到动态 API

在真实的项目开发中,我们面对的往往不是一个干净的 JSON 文件,而是一个由成百上千个文件和文件夹构成的、复杂的资源库。为了让我们的教程无限贴近实战,本节我们将对 Mock 体系进行一次终极进化。

我们将编写一个高度智能的 generate-data.cjs 脚本。它将扮演“数据工程师”的角色,自动扫描 public/data 目录下复杂的、按品类组织的真实文件结构,动态地聚合出结构化的分类、子分类、商品详情和 Banner 数据。最终,这些“热气腾腾”的数据将被 server.cjs 接管,为我们提供一套强大而真实的 API。

当前任务: 4.1 - Mock 体系终极进化
任务目标: 深入理解并实现一个能遍历文件系统、动态聚合数据的 generate-data.cjs 脚本,并配合 server.cjs,将真实的文件资源转化为可供前端消费的 RESTful API。

1. 设计思路:让代码去适应数据

Mock 终极架构
模块四开发前

架构师,我现在的商品数据都按品类、子品类、产品型号分文件夹存在 public/data 里了,非常零散。我该怎么让 json-server 理解这种复杂的结构?

架构师

这正是企业级项目管理资源的方式!我们绝对不应该手动把它们合并成一个巨大的 JSON。正确的做法是“让代码去适应数据”。

怎么做呢?

架构师

我们将赋予 generate-data.cjs 真正的“智能”。它将不再只是一个简单的文件读取器,而是一个“数据爬虫”。它会深入到 public/data 的每一层目录,读取 product_info.json,识别封面图和详情图,然后将这些零散的信息,按照我们前端最希望的 API 格式(比如 categories -> subCategories -> products),组装成一个完美的内存数据结构。

这样一来,我们的数据维护就变得极其简单了!只需要在 data 目录里增删文件夹,然后重启 Mock 服务,API 就会自动更新!

架构师

完全正确!我们把复杂性封装在了数据生成脚本中,换来的是数据维护的便利性和前端开发的愉悦。接下来,server.cjs 只需要为这些动态生成的数据,提供几个便捷的访问接口即可。

2. 核心代码实现与深度解读

第一部分:智能数据聚合器 (generate-data.cjs)

这是我们本次升级的“大脑”。它负责将文件系统中的混乱,转化为程序世界中的秩序。

请用以下完整代码,替换 mock/generate-data.cjs 的内容:

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// mock/generate-data.cjs
const { faker } = require("@faker-js/faker");
const fs = require("fs");
const path = require("path");

// 简单的Banner读取函数
const readCategoryBanners = (categoryId) => {
const bannerPath = path.join(
__dirname,
`../public/data/${categoryId}/banner`
);

if (fs.existsSync(bannerPath)) {
// 读取目录下的所有图片文件
return fs
.readdirSync(bannerPath)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.map((file, index) => ({
id: `${categoryId}-banner-${index + 1}`,
imgUrl: `/data/${categoryId}/banner/${file}`,
hrefUrl: `/category/${categoryId}`,
}));
}
return [];
};

// 读取分类的子分类和产品
const readCategoryData = (categoryId) => {
const categoryPath = path.join(__dirname, `../public/data/${categoryId}`);
if (!fs.existsSync(categoryPath)) return [];

const subCategories = [];
const items = fs.readdirSync(categoryPath, { withFileTypes: true });

for (const item of items) {
if (item.isDirectory() && !["banner", "Banner"].includes(item.name)) {
const subCategoryPath = path.join(categoryPath, item.name);
const products = [];

const productDirs = fs
.readdirSync(subCategoryPath, { withFileTypes: true })
.filter((d) => d.isDirectory());

for (const productDir of productDirs) {
const productPath = path.join(subCategoryPath, productDir.name);
const productInfoFile = path.join(productPath, "product_info.json");

let productInfo = { name: productDir.name };
if (fs.existsSync(productInfoFile)) {
try {
productInfo = JSON.parse(fs.readFileSync(productInfoFile, "utf-8"));
} catch (e) {}
}

// 检查是否有分离后的目录结构
const coverDir = path.join(productPath, "cover");
const detailDir = path.join(productPath, "detail");

let coverImage = "";
let detailImages = [];

if (fs.existsSync(coverDir) && fs.existsSync(detailDir)) {
// 使用分离后的目录结构
const coverFiles = fs
.readdirSync(coverDir)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.sort();

const detailFiles = fs
.readdirSync(detailDir)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.sort();

// 取第一张封面图
coverImage = coverFiles.length > 0 ? coverFiles[0] : "";

// 所有详情图
detailImages = detailFiles;
} else {
// 使用原始的单目录结构(向后兼容)
const images = fs
.readdirSync(productPath)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.sort();

const foundCoverImage =
images.find((img) => img.startsWith("00_cover")) || images[0];
coverImage = foundCoverImage || "";

detailImages = images.filter((img) => !img.startsWith("00_cover"));
}

products.push({
id: faker.string.uuid(),
name: productInfo.name || productDir.name,
desc: productInfo.tip || "",
coverImage: coverImage
? fs.existsSync(coverDir)
? `/data/${categoryId}/${item.name}/${productDir.name}/cover/${coverImage}`
: `/data/${categoryId}/${item.name}/${productDir.name}/${coverImage}`
: "",
detailImages: detailImages.map((img) =>
fs.existsSync(detailDir)
? `/data/${categoryId}/${item.name}/${productDir.name}/detail/${img}`
: `/data/${categoryId}/${item.name}/${productDir.name}/${img}`
),
// 只保留需要的字段,避免污染数据
url: productInfo.url,
tip: productInfo.tip,
category_id: productInfo.category_id,
category_name: productInfo.category_name,
tab_name: productInfo.tab_name,
});
}

subCategories.push({
id: faker.string.uuid(),
name: item.name,
products: products,
});
}
}

return subCategories;
};
module.exports = () => {
// 1. 读取静态的 mock-data.json 作为基础
const staticDataPath = path.join(__dirname, "mock-data.json");
const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));

// 2. 为每个分类添加 banners 和 subCategories
const categories = staticData.categories.map((category) => ({
...category,
banners: readCategoryBanners(category.id),
subCategories: readCategoryData(category.id),
}));

const data = {
users: [],
categories: categories,
// 全局 banners 保持不变,用于首页等场景
banners: [
{
id: "banner-001",
imgUrl: "/images/carousel/carousel1.jpg",
hrefUrl: "/category/new",
},
{
id: "banner-002",
imgUrl: "/images/carousel/carousel2.jpg",
hrefUrl: "/category/home-ac",
},
{
id: "banner-003",
imgUrl: "/images/carousel/carousel3.jpg",
hrefUrl: "/category/central-air-conditioner",
},
],
};

// 3. 生成模拟用户数据 (逻辑不变)
for (let i = 1; i <= 20; i++) {
const account = "3381292732@qq.com";
data.users.push({
id: faker.string.uuid(),
account: account,
password: "123456",
accessToken: faker.string.uuid(),
refreshToken: faker.string.uuid(),
avatar: faker.image.avatar(),
nickname: faker.person.firstName(),
mobile: faker.phone.number({ style: "international" }),
gender: faker.person.sex(),
birthday: faker.date.past({ years: 30 }).toISOString().split("T")[0],
cityCode: faker.location.zipCode(),
provinceCode: faker.location.state({ abbreviated: true }),
profession: faker.person.jobTitle(),
});
}

return data;
};


generate-data.cjs 核心代码深度解读

这么长的代码,我们不能囫囵吞枣。让我们把它拆解成三个核心部分来理解:两个工具函数一个主流程

核心解读 1:readCategoryBanners 函数 - 专职的 Banner 搜集器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 简单的Banner读取函数
const readCategoryBanners = (categoryId) => {
const bannerPath = path.join(
__dirname,
`../public/data/${categoryId}/banner`
);

if (fs.existsSync(bannerPath)) {
// 读取目录下的所有图片文件
return fs
.readdirSync(bannerPath)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.map((file, index) => ({
id: `${categoryId}-banner-${index + 1}`,
imgUrl: `/data/${categoryId}/banner/${file}`,
hrefUrl: `/category/${categoryId}`,
}));
}
return [];
};
  • 它的作用是什么?
    这是一个高度复用的工具函数,专门负责读取指定分类ID下的 banner 文件夹。
  • 它是如何工作的?
    1. path.join(...): 使用 Node.js 的 path 模块,安全地拼接出目标 banner 文件夹的绝对路径。
    2. fs.existsSync(bannerPath): 防御性编程。在读取前,先检查该目录是否存在,避免程序因找不到目录而崩溃。
    3. fs.readdirSync(bannerPath): 读取目录下的所有文件名。
    4. .filter(...): 使用正则表达式,只保留 .jpg, .png 等图片文件,过滤掉其他无关文件(如 .DS_Store)。
    5. .map(...): 将过滤后的文件名数组,加工 成前端组件真正需要的、包含 id, imgUrl, hrefUrl 的对象数组结构。

核心解读 2:readCategoryData 函数 - 深入骨髓的数据矿工

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 读取分类的子分类和产品
const readCategoryData = (categoryId) => {
const categoryPath = path.join(__dirname, `../public/data/${categoryId}`);
if (!fs.existsSync(categoryPath)) return [];

const subCategories = [];
const items = fs.readdirSync(categoryPath, { withFileTypes: true });

for (const item of items) {
if (item.isDirectory() && !["banner", "Banner"].includes(item.name)) {
// ... (内部逻辑)
}
}
return subCategories;
};
  • 它的作用是什么?
    这是脚本的“心脏”,负责深入一个分类目录(如 airConditioner),挖掘出其中所有的子分类(如“挂式空调”)和子分类下的所有商品信息。
  • 它是如何工作的?
    这是一个 双重循环 结构:
    1. 外层循环: for (const item of items),它遍历一级分类下的所有目录,并通过 item.isDirectory()!["banner", ...] 的判断,精确地找出所有 子分类目录
    2. 内层循环: 在找到子分类目录后,它会再次进入该目录,遍历并找出所有的 产品目录(如“U铂”)。
    3. 智能图片处理: 在产品目录中,它会自动识别以 00_cover 开头的图片作为封面 (coverImage),其余作为详情图 (detailImages)。这是一种非常常见的工程约定,让数据维护变得简单。
    4. 数据合并: 它将从 product_info.json 读取的文本信息、自动生成的 id 和随机价格、以及识别出的图片路径,最终合并成一个完整的商品对象,并层层向上聚合。

核心解读 3:module.exports - 运筹帷幄的总指挥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = () => {
// 1. 读取静态的 mock-data.json 作为基础
const staticDataPath = path.join(__dirname, "mock-data.json");
const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));

// 2. 为每个分类添加 banners 和 subCategories
const categories = staticData.categories.map((category) => ({
...category,
banners: readCategoryBanners(category.id),
subCategories: readCategoryData(category.id),
}));

const data = {
// ...
categories: categories,
// ...
};

// ... (生成用户数据)

return data;
};
  • 它的作用是什么?
    这是脚本的主入口,负责编排整个数据生成流程。
  • 它是如何工作的?
    1. 它首先加载 mock-data.json,将其作为顶级分类的“骨架”(只包含 idname)。
    2. 然后通过 .map() 遍历这个骨架,为每一项调用我们上面分析过的 readCategoryBannersreadCategoryData 工具函数。
    3. 这个过程就像是为骨架填充“血肉”,将从文件系统动态读取到的真实数据,注入到每个分类对象中。
    4. 最终,它返回一个包含了所有聚合数据的、结构完美的 data 对象,供 json-server 使用。

第二部分:便捷的 API 服务员 (server.cjs)

数据准备好后,我们需要 server.cjs 来为前端提供便捷的访问接口。

请用以下完整代码,替换 mock/server.cjs 的内容:

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
// 自定义 /categories 路由以符合 OpenAPI 规范
server.get("/categories", (req, res) => {
const db = router.db;
const categories = db.get("categories").value();

// 返回符合 OpenAPI 规范的结构
res.status(200).json({
code: "200",
msg: "操作成功",
result: categories,
});
});

// 新增:自定义 /home/banner 路由
server.get("/home/banner", (req, res) => {
const db = router.db;
const banners = db.get("banners").value();

res.status(200).json({
code: "1",
msg: "操作成功",
result: banners,
});
});

// 特殊的Banner接口 - 方便前端直接获取分类Banner
server.get("/category/:categoryId/banners", (req, res) => {
const { categoryId } = req.params;
const db = router.db;
const categories = db.get("categories").value();
const category = categories.find((cat) => cat.id === categoryId);

res.status(200).json({
code: "200",
msg: "操作成功",
result: category?.banners || [],
});
});

代码深度解读:

  • 保留认证逻辑: 我们完整地保留了模块三中实现的认证相关代码,确保登录功能不受影响。
  • GET /category/:categoryId/banners:
    • 作用: 这是一个“便捷接口”。前端在分类页只需要知道当前分类的 ID,就可以通过这个接口轻松获取到对应的 Banner 数据,而无需请求整个庞大的分类对象,大大减少了网络传输的数据量。
    • 实现: 它利用 req.params 获取 URL 中的动态段 :categoryId,然后在 json-server 的内存数据库 (router.db) 中进行查找,并返回我们已在 generate-data.cjs 中动态附加的 banners 数组。
  • 默认路由的威力: 我们没有为 GET /categories/:id 编写任何自定义代码!json-server 会自动处理这个请求,直接从 generate-data.cjs 生成的数据中,找到 categories 数组里 id 匹配的项并返回。这就是约定优于配置的力量。

3. 验证与展望

  1. 准备数据: 确保你已将完整的 public/data 目录结构和 mock/mock-data.json 基础文件准备好。
  2. 启动服务: 在终端中重启 Mock Server (pnpm run mock)。
  3. 测试接口:
    • http://localhost:3001/categories/new: 访问这个由 json-server 自动提供 的接口。你应该能看到一个完整的 JSON 对象,包含了“家用空调”的所有信息,包括动态读取到的 bannerssubCategories
    • http://localhost:3001/categories/new/banners: 访问我们手动创建的便捷接口。你应该能看到只包含 Banner 信息的 result 数组。

我们的 Mock 体系现已进化到最终形态。它既能利用 json-server 的自动化能力,又能通过自定义脚本和路由处理复杂的、真实世界的数据结构。我们已经为后续所有模块的开发铺平了道路。


4.2 【核心】封装与 TanStack Query 结合的业务 Composable

我们已经有了一个强大的、能反映真实文件结构的 Mock API。现在,是时候构建消费这个 API 的前端逻辑了。我们将直面一个核心问题:“如何将依赖路由的、由 TanStack Query 管理的异步数据逻辑,从组件中优雅地剥离出来?”

答案是:useQuery 本身封装到组合式函数 (Composable) 中。

当前任务: 4.2 - 封装 useCategory Composable
任务目标: 创建 types/category.ts, apis/category.ts,然后将所有与获取单个分类数据相关的逻辑(包括 useQuery 的调用),封装到一个独立的、可复用的 composables/useCategory.ts 文件中。

1. 设计思路:Composable 的真正力量

架构演进
开始构建 Category.vue 之前

架构师,我的 Category.vue 组件需要根据路由 id 获取分类信息。按照之前的经验,我应该在组件里用 useRoute 获取 id,然后把它传给 useQuery

架构师

这是一种直接的方法。但设想一下,如果“推荐商品”组件也需要根据这个 id 获取一些关联数据,我们是不是要在两个组件里都重复写一遍 useRouteuseQuery 的逻辑?

没错,这会导致代码重复。

架构师

所以,我们要更进一步。我们将创建一个 useCategory.ts Composable,它的职责不仅仅是调用 API,而是完整地封装与“获取分类数据”相关的所有逻辑,包括 useQuery 本身

那么,useQueryqueryKey 怎么响应路由 id 的变化呢?

架构师

这正是 TanStack Query 的优雅之处。我们将在 Composable 内部使用 useRoute,并将路由 id 作为一个 响应式computed 值。然后把这个 computed 值放入 queryKey 数组中。当路由变化时,queryKey 就会自动变化,TanStack Query自动 为我们重新获取数据。我们甚至不再需要手动 watch

哇!所以组件只需要 const { categoryData, isLoading } = useCategory() 这一行,就能得到所有响应式的状态,而 Composable 内部用 useQuery 已经处理好了一切,包括路由变化时的自动重新请求?

架构师

完全正确! 这就是 Composable 与 TanStack Query 结合的终极形态——组件极简,逻辑内聚且高度自动化。

2. 创建类型与 API (适配新数据结构)

在封装 Composable 之前,我们必须先定义好它所依赖的“工具”。这次,我们的类型和 API 将严格依据 4.1 节中 generate-db.js 生成的 db.json 结构来编写。

2.1 创建 src/types/category.ts

这个文件将为我们所有与分类、商品相关的数据提供类型安全保障。

请打开或创建 src/types/category.ts,并用以下内容替换:

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
// src/types/category.ts

// 产品信息接口
export interface Product {
id: string;
name: string;
desc: string;
coverImage: string; // 封面图片
detailImages: string[]; // 详情图片数组
tip?: string; // 产品提示信息
[key: string]: any; // 允许其他动态属性
}

// 子分类接口
export interface SubCategory {
id: string;
name: string;
products: Product[]; // 该子分类下的产品列表
}

// 一级分类项
export interface CategoryItem {
id: string;
name: string;
icon?: string;
banners?: BannerItem[]; // 分类专属的轮播图
subCategories?: SubCategory[]; // 子分类列表
products?: Product[]; // 兼容原始数据结构中的直接产品列表
}

// Banner 项
export interface BannerItem {
id: string;
imgUrl: string;
hrefUrl: string;
type?: string; // 根据 mock 数据,这个字段可能存在
}

2.2 创建 src/apis/category.ts

这个文件将统一管理所有与分类页面相关的 API 请求。

请打开或创建 src/apis/category.ts,并用以下内容替换:

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
// src/apis/category.ts
import httpInstance from "@/utils/http";
import type { CategoryItem, BannerItem } from "@/types/category";

/**
* @description: 获取单个顶级分类数据 (包含子分类和轮播图)
* @param {String} id - 顶级分类ID
* @return {Promise<CategoryItem>}
*/
export const getCategoryItemAPI = async (id: string): Promise<CategoryItem> => {
// 通过 json-server 获取包含 banners 和 subCategories 的完整分类数据
// httpInstance 响应拦截器会自动处理响应数据
return (await httpInstance.get(`/categories/${id}`)) as CategoryItem;
};

/**
* @description: 获取分类页 Banner (为了简便使用,不在代码中进行提取,所以单独提供一个接口,现在 banners 包含在分类数据中)
* @param {String} id - 顶级分类ID
* @return {Promise<{ result: BannerItem[] }>}
*/
export const getCategoryBannerAPI = async (
id: string
): Promise<{ result: BannerItem[] }> => {
return (await httpInstance.get(`/category/${id}/banners`)) as {
result: BannerItem[];
};
};

3. 创建 useCategory Composable

现在,我们来构建核心的 Composable,它将 useQuery 的强大功能封装其中。

请在 src/views/Category/composables/ 目录下创建 useCategory.ts 文件:

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
// src/views/Category/composables/useCategory.ts
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useQuery } from "@tanstack/vue-query";
import { getCategoryItemAPI } from "@/apis/category";

export function useCategory() {
const route = useRoute();
// 使用 computed 确保 categoryId 是响应式的
const categoryId = computed(() => route.params.id as string);

// 使用 TanStack Query 来获取数据
const {
data: categoryData,
isLoading,
isError,
} = useQuery({
// queryKey 必须是一个数组,并且包含响应式的数据源
// 当 categoryId.value 变化时,TanStack Query 会自动重新执行查询
queryKey: ["categoryItem", categoryId],

// queryFn 接收一个包含 queryKey 的上下文对象
queryFn: ({ queryKey }) => {
// queryKey[1] 就是 categoryId.value
const id = queryKey[1] as string;
return getCategoryItemAPI(id);
},

// enabled 选项用于控制查询是否执行
// 只有当 categoryId.value 存在 (truthy) 时,查询才会触发
enabled: computed(() => !!categoryId.value),

// TanStack Query 默认不返回 { result: ... } 结构
// 我们在这里保持 API 的原始返回,因为拦截器已处理了 .data
// 如果需要转换,可以使用 `select` 选项
});

return {
categoryData,
isLoading,
isError,
};
}

4. 深度解读与优势

  • watch 的消失: 最大的变化是什么?我们不再需要 onMountedwatchuseQuery 通过监听 queryKey 中响应式数据 (categoryId) 的变化,自动地 处理了首次加载和后续更新的所有情况。这极大地简化了代码。
  • 响应式 queryKey: queryKey: ['topCategory', categoryId] 是这里的核心。它告诉 TanStack Query:“这个数据的身份不仅是‘顶级分类’,还跟当前的 categoryId 息息相关。只要 categoryId 变,它就是一个全新的、需要重新获取的数据。”
  • 健壮性 (enabled): enabled: computed(() => !!categoryId.value) 是一个企业级的健壮性实践。它确保了只有在 id 真实存在于 URL 中时,我们才去发起网络请求,避免了无效调用。
  • 逻辑内聚与组件解耦: 所有的复杂性——路由依赖、响应式键、API调用、加载和错误状态管理——都被完美封装。Category.vue 组件将对此一无所知。

4.3 封装第二个 Composable: useBanner (同样使用 TanStack Query)

为了巩固练习,并保持代码风格的绝对一致,我们将用同样强大的模式来封装 useBanner

当前任务: 4.3 - 封装 useBanner Composable
文件路径: src/views/Category/composables/useBanner.ts
任务目标: 创建一个 useBanner 组合式函数,它同样使用 TanStack Query 和响应式 queryKey 来获取特定分类的 Banner 数据。

1. 编码实现

请在 src/views/Category/composables/ 目录下,创建一个新文件 useBanner.ts,并写入以下代码:

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
// src/views/Category/composables/useBanner.ts
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useQuery } from "@tanstack/vue-query";
import { getCategoryBannerAPI } from "@/apis/category";
import type { BannerItem } from "@/types/category";

export function useBanner() {
const route = useRoute();
const categoryId = computed(() => route.params.id as string);

const {
data: bannerData,
isLoading,
isError,
} = useQuery({
queryKey: ["categoryBanner", categoryId],
queryFn: ({ queryKey }) => {
const id = queryKey[1] as string;
return getCategoryBannerAPI(id);
},
select: (data) => data.result,
enabled: computed(() => !!categoryId.value),
});

return {
bannerData,
isLoading,
isError,
};
}

2. 代码解读

  • 模式复用: 我们应用了与 useCategory 完全相同的模式,这使得代码库的可预测性大大增强。
  • select 选项的应用: 在这里我们展示了 useQueryselect 功能。我们的 getCategoryBannerAPI 返回 { result: [...] } 结构,通过 select: (data) => data.result,我们告诉 TanStack Query:“请在数据获取成功后,只把 result 属性的值返回给 bannerData”。这让组件拿到的数据更纯净。

现在,我们拥有了两个高度内聚、功能强大、且完全自动化的数据获取 Composable。在下一节,我们将把它们“插”入到 Category.vue 组件中,见证最终的优雅与简洁。


4.4 视图组装:构建分类页的骨架

我们已经将所有的数据获取逻辑封装到了 Composables 中。现在,我们的 Category.vue 组件可以放下“如何获取数据”的包袱,全身心地投入到它最核心的职责中:构建用户界面

在这一节,我们将完成 Category.vue 页面的静态布局搭建。我们将引入所有必要的子组件,并使用 Element Plus 提供的布局和 UI 组件,结合少量的自定义 SCSS,将设计稿转化为一个视觉上完整、结构清晰的静态页面。

当前任务: 4.4 - 视图组装
任务目标: 搭建 Category.vue 的完整静态模板,包括面包屑、Banner、子分类导航和商品列表展示区。重点实践 Element Plus 组件的应用和 BEM 规范的 SCSS 编写。

1. 规划组件结构

分析设计稿(和你提供的 HTML 代码),我们可以将分类页主体内容拆分为以下几个部分:

  1. 面包屑导航: 显示用户当前的位置路径。
  2. Banner 轮播图: 展示该分类下的广告图。
  3. 子分类列表: 展示该顶级分类下的所有二级分类。
  4. 分区的商品列表: 按二级分类,分别展示其下的热门商品。

2. 搭建模板 (<template>)

我们将使用 v-if 来确保只在数据加载完成后才渲染页面内容,这可以防止访问 nullundefined 导致的错误。

请用以下完整代码,彻底替换 src/views/Category/index.vue 的内容:

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
<script setup lang="ts">
// 1. 引入我们的组合式函数
import { useCategory } from './composables/useCategory'
import { useBanner } from './composables/useBanner'
// 后续将引入商品组件
import GoodsItem from './components/GoodsItem.vue' // 假设已创建

// 2. 调用函数,获取所有需要的数据和状态
const { categoryData } = useCategory()
const { bannerData, isLoading: isBannerLoading } = useBanner()
</script>

<template>
<div class="category-page">
<div class="container">
<!-- 1. 面包屑导航 -->
<el-breadcrumb class="category-page__breadcrumb" separator=">" v-if="categoryData">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>

<!-- 2. Banner 轮播图 -->
<el-skeleton :loading="isBannerLoading" animated>
<template #template>
<el-skeleton-item variant="image" class="category-page__banner-skeleton" />
</template>
<template #default>
<el-carousel class="category-page__banner" height="500px" :arrow="'never'">
<el-carousel-item v-for="item in bannerData" :key="item.id">
<img :src="item.imgUrl" :alt="item.id">
</el-carousel-item>
</el-carousel>
</template>
</el-skeleton>

<!-- 3. 子分类与商品列表 -->
<div class="category-page__content" v-if="categoryData">
<div class="category-page__sub-title">
<h3>全部分类</h3>
</div>

<!-- 4. 按子分类展示商品 -->
<div class="category-page__goods-section" v-for="sub in categoryData.subCategories" :key="sub.id">
<div class="goods-section__header">
<h3>- {{ sub.name }} -</h3>
</div>
<div class="goods-section__body">
<!-- 这里我们将使用一个可复用的 GoodsItem 组件 -->
<GoodsItem v-for="product in sub.products" :key="product.id" :goods="product" />
</div>
</div>
</div>
</div>
</div>
</template>

模板代码解读:

  • 加载状态处理: 我们为面包屑和 Banner 都包裹了 el-skeleton 组件,并将其 :loading 属性与各自 Composable 返回的 isLoading 状态绑定。这提供了一个非常优雅的加载占位效果,是现代 Web 应用提升用户体验的标准实践。
  • BEM 规范: 所有的 class 都遵循了严格的 BEM 命名规范,例如 category-page__breadcrumbsub-list__item。这使得我们的 CSS 结构清晰,易于维护,且不会产生全局污染。
  • 组件化思想: 商品列表的渲染被抽象为 <GoodsItem> 组件。我们将在下一节创建这个组件。这种组件化的方式使得 Category.vue 的模板保持了高度的结构化,只负责布局和循环,而将单个商品如何展示的具体细节交给了 GoodsItem

3. 编写样式 (<style>)

现在,我们为这个结构添加样式。我们将尽量利用 Element Plus 的默认样式,只对布局和特定视觉效果进行自定义。

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
88

<style lang="scss" scoped>
.category-page {
width: 100vw;
min-height: 100vh;
margin: 0;
padding: 0;

.container {
width: 100%;
margin: 0;
padding: 0;
position: relative;
}

&__breadcrumb {
margin: 20px;
margin-bottom: 20px;
}

&__banner-skeleton {
width: 100%;
height: 500px;
}

&__banner {
width: 100%;
height: 500px;
z-index: 98; // 确保在某些布局下层级正确

img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

&__content {
margin-top: 20px;
background-color: #fff;
padding: 20px;
width: 100%;
box-sizing: border-box;
}

&__sub-title {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}
}

&__item-img {
width: 100px;
height: 100px;
}

&__item-name {
line-height: 1.5;
color: #666;
}
}

.goods-section {
&__header {
padding: 40px 0;

h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 1.2;
}
}

&__body {
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: flex-start;
width: 100%;
}
}
</style>

样式代码解读:

  • Flexbox 布局: 我们大量使用了 Flexbox 来实现子分类和商品列表的横向排列和自动换行 (flex-wrap: wrap),并通过 gap 属性来创建均匀的间距。这是现代 CSS 布局的首选方案。
  • 最小化覆盖: 注意,我们没有去覆盖 el-breadcrumbel-carousel 的内部样式。我们利用了它们提供的 props (如 separator, height) 和默认的 class,只编写我们自己的布局和装饰性样式。这使得未来升级 Element Plus 版本时,我们的代码更加稳健。

至此,我们已经完成了一个视觉完整、结构清晰的静态分类页面。它已经准备好在下一节中,通过实现 <GoodsItem> 组件和注入真实数据,来完成最终的动态化。


4.5 业务组件封装:构建可复用的 GoodsItem

在任何电商应用中,“商品卡片”都是出现频率最高、最重要的基础组件之一。在这一节,我们将把单个商品的展示逻辑,封装成一个独立的、可复用的 GoodsItem.vue 组件。

遵循“单一职责原则”,Category.vue 负责布局,而 GoodsItem.vue 则专注于如何优雅地展示一个商品的信息

当前任务: 4.5 - 封装 GoodsItem 组件
任务目标: 创建一个 GoodsItem.vue 组件,它接收一个 product 对象作为 prop,并使用 Element Plus 的 ElCard 组件来构建一个包含图片、名称、描述和价格的、具有统一视觉风格的商品卡片。

1. 设计思路:从原子组件到页面

我们采用的是“原子设计”的思想。GoodsItem 就是一个“原子”,它很小,只关心一件事。然后我们用这些“原子”来组成“分子”(如商品列表 goods-section__body),最终构成一个完整的“生物体”(Category.vue 页面)。

这样做的好处是:

  • 高度复用: GoodsItem 组件未来可以在首页、搜索结果页、购物车推荐等任何需要展示商品的地方被直接复用。
  • 易于维护: 如果未来需要修改所有商品卡片的样式(比如增加一个“新品”标签),我们只需要修改 GoodsItem.vue 这一个文件。

2. 创建 GoodsItem.vue 组件

首先,我们需要在 Category 视图下创建一个 components 目录,用于存放其专属的子组件。

请在 src/views/Category/ 目录下,创建一个 components 文件夹,并在其中创建 GoodsItem.vue 文件。

1
2
3
4
5
6
7
src/
└── views/
└── Category/
├── composables/
├── components/
│ └── GoodsItem.vue <-- 新建此文件
└── index.vue

3. 编码实现 (GoodsItem.vue)

我们将使用 Element Plus 的 ElCard 作为卡片的基础容器,因为它自带了阴影、边框和内边距,能让我们快速实现专业的设计效果。

请在 src/views/Category/components/GoodsItem.vue 文件中,写入以下完整代码:

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
88
89
90
91
92
93
94
95
96
97
98
99
<script setup lang="ts">
import type { Product } from '@/types/category';

defineProps<{
goods: Product
}>()
</script>

<template>
<RouterLink :to="`/product/${goods.id}`" class="goods-item">
<el-card class="goods-item__card" shadow="hover">

<!-- 【核心】使用 Element Plus 的 ElImage 组件 -->
<el-image :src="goods.picture || goods.coverImage" :alt="goods.name" lazy class="goods-item__image">
<!-- #placeholder 插槽:自定义加载时的占位内容 -->
<template #placeholder>
<div class="image-slot">
加载中<span class="dot">...</span>
</div>
</template>
<!-- #error 插槽:自定义加载失败时的内容 -->
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>

<div class="goods-item__info">
<p class="goods-item__name ellipsis">{{ goods.name }}</p>
<p class="goods-item__desc ellipsis">{{ goods.desc }}</p>
</div>
</el-card>
</RouterLink>
</template>

<style lang="scss" scoped>
.goods-item {
display: block;
flex: 1;
min-width: 200px;
max-width: 250px;
text-align: center;
text-decoration: none;
transition: all .5s;

&__card {
--el-card-padding: 15px;
}

&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}

&__image {
width: 160px;
height: 160px;
}

&__info {
padding-top: 10px;
}

&__name {
font-size: 16px;
color: #333;
}

&__desc {
color: #999;
height: 29px;
}

&__price {
color: $priceColor;
font-size: 20px;
}
}

// 通用工具类,用于单行文本溢出显示省略号
.ellipsis {
@include truncate-text;
}

// ElImage 组件插槽的统一样式
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f5f5;
color: #909399;
font-size: 14px;
}
</style>

代码解读:

  • defineProps: 我们使用了 <script setup> 的编译时宏 defineProps 来声明组件期望接收一个名为 goods 的 prop,其类型为我们之前定义的 Product 接口。这提供了强大的类型检查和自动补全。
  • ElCard 组件: 我们直接使用了 el-card 并通过 shadow="hover" prop 轻松实现了鼠标悬浮时的阴影效果。我们还通过 CSS 变量 --el-card-padding 对其默认内边距进行了微调,这是 Element Plus 推荐的定制方式之一。
  • 图片懒加载 (v-img-lazy): 注意 <img> 标签上的 v-img-lazy 指令。这是一个非常重要的性能优化实践。我们假设在模块一或模块二中,已经全局注册了一个图片懒加载指令(例如使用 vue-lazyload@vueuse/lazy)。它能确保只有当图片滚动到可视区域时,才会真正发起网络请求加载,极大地提升了长列表页面的首屏加载速度。
  • RouterLink: 整个卡片被 RouterLink 包裹,并指向一个未来的商品详情页路径,如 /product/xxxx。这确保了整个卡片都是可点击的。
  • 原子化 CSS: 我们定义了一个 .ellipsis 工具类。这种将常用样式片段(如文本溢出)抽象为独立类的做法,被称为原子化 CSS 或功能类,可以提高样式的复用性。

4. 最终验证

  1. 重启开发服务器 (pnpm run dev)。
  2. 再次访问任意一个分类页面,例如 http://localhost:5173/category/airConditioner
  3. 预期效果:
    • 页面不再因为找不到 GoodsItem 组件而报错。
    • 在“挂式空调”、“立柜式空调”等子分类下方,你会看到由 ElCard 渲染出的、样式精美的商品卡片列表。
    • 当你将鼠标悬浮在某个商品卡片上时,应该能看到平滑的上浮和阴影效果。
    • 点击任意一个商品卡片,URL 会跳转到对应的商品详情页路径(尽管我们还没创建这个页面,但跳转行为本身证明了 RouterLink 正常工作)。

至此,我们已经完成了分类页面的所有核心静态布局和组件封装。我们的代码结构清晰、逻辑解耦、高度可复用。在下一节,我们将进行最后的收尾工作,并提交本模块的成果。


4.6 模块提交与总结

至此,我们已经成功地完成了 vue3-webShop 项目的第二个核心业务模块——商品分类页。

我们不仅仅是简单地渲染了一个页面,更重要的是,我们深入实践了 Vue 3 最核心的工程化思想

  • 逻辑解耦: 通过将业务逻辑封装到 Composable 中,我们让 Category.vue 组件变得极其纯净和可维护。
  • 组件化: 我们将 UI 拆分成了独立的、可复用的单元,如 GoodsItem.vue
  • 性能优化: 我们利用 ElImagelazy 属性,兵不血刃地实现了图片懒加载,确保了页面的高性能。

现在,是时候将我们本模块的成果提交到版本库了。

当前任务: 4.6 - 模块成果提交
任务目标: 将模块四中完成的所有分类页相关功能,作为一个完整的特性提交到 Git 仓库。

命令行操作

打开您的终端,确保位于项目根目录下,然后执行以下命令:

  1. 将所有已修改和新建的文件添加到 Git 暂存区:

    1
    git add .
  2. 提交代码,并附上符合“约定式提交”规范的 message:

    1
    git commit -m "feat(category): build dynamic category page with composables"

    Commit Message 解读:

    • feat: 表示这是一个新功能 (feature) 的提交。
    • (category): 指明了本次提交影响的主要范围是“分类”模块。
    • build dynamic category page with composables: 简明扼要地描述了我们完成的具体工作:使用组合式函数 (Composable) 构建了动态的分类页面。

提交成功后,您的项目就有了一个清晰的、代表“分类页功能完成”的历史节点。我们已经准备好进入下一个更具挑战性的模块:商品详情页,去探索更复杂的 SKU 选择和数据交互。