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

经过前三个模块的洗礼,相信您已经完全掌握了项目的工程化基石、全局布局搭建和核心认证流程。
本模块核心学习目标:
- 【核心】掌握组合式函数 (Composables) 封装: 学习如何将“数据获取逻辑”(API 调用、路由依赖、响应式状态管理)封装到独立的
useCategory.ts
文件中,实现业务逻辑的 高内聚和可复用性。 - 实践关注点分离: 通过创建
useBanner
和 useCategory
,让 Category/index.vue
组件的职责回归到纯粹的 “组织和呈现”,而不是“获取和处理数据”。 - 精通动态路由参数的安全处理: 在 Composable 内部优雅地处理
id
的变化和边界情况,让组件对此“无感”。 - 构建数据驱动的 UI: 学习使用
ElBreadcrumb
等组件,并消费来自 Composables 的数据,构建数据驱动的用户界面。
本模块任务清单
任务模块 | 任务名称 | 核心目标与学习要点 |
---|
【核心】逻辑层封装 | 创建 useCategory Composable | 将“获取单个分类数据”的逻辑,从组件中抽离并封装成一个独立的、响应式的 useCategory 组合式函数。 |
【核心】逻辑层封装 | 创建 useBanner Composable | 将“获取分类页 Banner”的逻辑也封装成独立的 useBanner 函数,进一步实践逻辑解耦。 |
视图层构建 | Category 页面的组装 | 构建 Category/index.vue ,使其只负责调用 useCategory 和 useBanner ,并将返回的响应式数据传递给子组件进行渲染。 |
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
| const { faker } = require("@faker-js/faker"); const fs = require("fs"); const path = require("path");
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 = () => { const staticDataPath = path.join(__dirname, "mock-data.json"); const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));
const categories = staticData.categories.map((category) => ({ ...category, banners: readCategoryBanners(category.id), subCategories: readCategoryData(category.id), }));
const data = { users: [], categories: categories, 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", }, ], };
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
| 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
文件夹。 - 它是如何工作的?
path.join(...)
: 使用 Node.js 的 path
模块,安全地拼接出目标 banner
文件夹的绝对路径。fs.existsSync(bannerPath)
: 防御性编程。在读取前,先检查该目录是否存在,避免程序因找不到目录而崩溃。fs.readdirSync(bannerPath)
: 读取目录下的所有文件名。.filter(...)
: 使用正则表达式,只保留 .jpg
, .png
等图片文件,过滤掉其他无关文件(如 .DS_Store
)。.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
),挖掘出其中所有的子分类(如“挂式空调”)和子分类下的所有商品信息。 - 它是如何工作的?
这是一个 双重循环 结构:- 外层循环:
for (const item of items)
,它遍历一级分类下的所有目录,并通过 item.isDirectory()
和 !["banner", ...]
的判断,精确地找出所有 子分类目录。 - 内层循环: 在找到子分类目录后,它会再次进入该目录,遍历并找出所有的 产品目录(如“U铂”)。
- 智能图片处理: 在产品目录中,它会自动识别以
00_cover
开头的图片作为封面 (coverImage
),其余作为详情图 (detailImages
)。这是一种非常常见的工程约定,让数据维护变得简单。 - 数据合并: 它将从
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 = () => { const staticDataPath = path.join(__dirname, "mock-data.json"); const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));
const categories = staticData.categories.map((category) => ({ ...category, banners: readCategoryBanners(category.id), subCategories: readCategoryData(category.id), }));
const data = { categories: categories, }; return data; };
|
- 它的作用是什么?
这是脚本的主入口,负责编排整个数据生成流程。 - 它是如何工作的?
- 它首先加载
mock-data.json
,将其作为顶级分类的“骨架”(只包含 id
和 name
)。 - 然后通过
.map()
遍历这个骨架,为每一项调用我们上面分析过的 readCategoryBanners
和 readCategoryData
工具函数。 - 这个过程就像是为骨架填充“血肉”,将从文件系统动态读取到的真实数据,注入到每个分类对象中。
- 最终,它返回一个包含了所有聚合数据的、结构完美的
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
| server.get("/categories", (req, res) => { const db = router.db; const categories = db.get("categories").value();
res.status(200).json({ code: "200", msg: "操作成功", result: categories, }); });
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, }); });
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. 验证与展望
- 准备数据: 确保你已将完整的
public/data
目录结构和 mock/mock-data.json
基础文件准备好。 - 启动服务: 在终端中重启 Mock Server (
pnpm run mock
)。 - 测试接口:
http://localhost:3001/categories/new
: 访问这个由 json-server
自动提供 的接口。你应该能看到一个完整的 JSON 对象,包含了“家用空调”的所有信息,包括动态读取到的 banners
和 subCategories
。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
获取一些关联数据,我们是不是要在两个组件里都重复写一遍 useRoute
和 useQuery
的逻辑?
架构师
所以,我们要更进一步。我们将创建一个 useCategory.ts
Composable,它的职责不仅仅是调用 API,而是完整地封装与“获取分类数据”相关的所有逻辑,包括 useQuery
本身。
那么,useQuery
的 queryKey
怎么响应路由 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
|
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[]; }
export interface BannerItem { id: string; imgUrl: string; hrefUrl: string; type?: string; }
|
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
| import httpInstance from "@/utils/http"; import type { CategoryItem, BannerItem } from "@/types/category";
export const getCategoryItemAPI = async (id: string): Promise<CategoryItem> => { return (await httpInstance.get(`/categories/${id}`)) as CategoryItem; };
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
| 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(); const categoryId = computed(() => route.params.id as string);
const { data: categoryData, isLoading, isError, } = useQuery({ queryKey: ["categoryItem", categoryId],
queryFn: ({ queryKey }) => { const id = queryKey[1] as string; return getCategoryItemAPI(id); },
enabled: computed(() => !!categoryId.value),
});
return { categoryData, isLoading, isError, }; }
|
4. 深度解读与优势
watch
的消失: 最大的变化是什么?我们不再需要 onMounted
和 watch
!useQuery
通过监听 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
| 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
选项的应用: 在这里我们展示了 useQuery
的 select
功能。我们的 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 代码),我们可以将分类页主体内容拆分为以下几个部分:
- 面包屑导航: 显示用户当前的位置路径。
- Banner 轮播图: 展示该分类下的广告图。
- 子分类列表: 展示该顶级分类下的所有二级分类。
- 分区的商品列表: 按二级分类,分别展示其下的热门商品。
2. 搭建模板 (<template>
)
我们将使用 v-if
来确保只在数据加载完成后才渲染页面内容,这可以防止访问 null
或 undefined
导致的错误。
请用以下完整代码,彻底替换 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">
import { useCategory } from './composables/useCategory' import { useBanner } from './composables/useBanner'
import GoodsItem from './components/GoodsItem.vue'
const { categoryData } = useCategory() const { bannerData, isLoading: isBannerLoading } = useBanner() </script>
<template> <div class="category-page"> <div class="container"> <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>
<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>
<div class="category-page__content" v-if="categoryData"> <div class="category-page__sub-title"> <h3>全部分类</h3> </div>
<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 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__breadcrumb
、sub-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-breadcrumb
或 el-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">
<el-image :src="goods.picture || goods.coverImage" :alt="goods.name" lazy class="goods-item__image"> <template #placeholder> <div class="image-slot"> 加载中<span class="dot">...</span> </div> </template> <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. 最终验证
- 重启开发服务器 (
pnpm run dev
)。 - 再次访问任意一个分类页面,例如
http://localhost:5173/category/airConditioner
。 - 预期效果:
- 页面不再因为找不到
GoodsItem
组件而报错。 - 在“挂式空调”、“立柜式空调”等子分类下方,你会看到由
ElCard
渲染出的、样式精美的商品卡片列表。 - 当你将鼠标悬浮在某个商品卡片上时,应该能看到平滑的上浮和阴影效果。
- 点击任意一个商品卡片,URL 会跳转到对应的商品详情页路径(尽管我们还没创建这个页面,但跳转行为本身证明了
RouterLink
正常工作)。
至此,我们已经完成了分类页面的所有核心静态布局和组件封装。我们的代码结构清晰、逻辑解耦、高度可复用。在下一节,我们将进行最后的收尾工作,并提交本模块的成果。
4.6 模块提交与总结
至此,我们已经成功地完成了 vue3-webShop
项目的第二个核心业务模块——商品分类页。
我们不仅仅是简单地渲染了一个页面,更重要的是,我们深入实践了 Vue 3 最核心的工程化思想:
- 逻辑解耦: 通过将业务逻辑封装到 Composable 中,我们让
Category.vue
组件变得极其纯净和可维护。 - 组件化: 我们将 UI 拆分成了独立的、可复用的单元,如
GoodsItem.vue
。 - 性能优化: 我们利用
ElImage
的 lazy
属性,兵不血刃地实现了图片懒加载,确保了页面的高性能。
现在,是时候将我们本模块的成果提交到版本库了。
当前任务: 4.6 - 模块成果提交
任务目标: 将模块四中完成的所有分类页相关功能,作为一个完整的特性提交到 Git 仓库。
命令行操作
打开您的终端,确保位于项目根目录下,然后执行以下命令:
将所有已修改和新建的文件添加到 Git 暂存区:
提交代码,并附上符合“约定式提交”规范的 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 选择和数据交互。