模块零:项目概述、技术选型与架构蓝图 本章概述 : 本章旨在为 vue3-webShop
实战项目建立一个清晰的宏观认知。我们将明确项目的总体目标、核心学习路径,并详细阐述其技术选型与分层架构设计,为后续所有开发工作奠定坚实的理论基础。
0.1 项目定义与核心学习目标 项目定义 : vue3-webShop
是一个功能完备、对标企业级标准的全栈模拟电商前台应用。它涵盖了从项目初始化、用户认证、商品展示、购物车交互到订单生成的完整业务闭环。
本次实战的核心目标,并非简单地复刻功能,而是通过该项目,深入掌握构建现代化、大型、可维护前端应用的 核心能力 。具体学习目标如下:
精通企业级 UI 解决方案 : 深度掌握 Element Plus
组件库的应用,重点学习其在复杂业务场景下的使用技巧与 主题化定制 方案。掌握现代状态管理架构 : 学习并实践 TanStack Query
(服务端状态) + Pinia
(客户端状态) 的分层管理模式,彻底分离异步数据与 UI 状态,提升应用的可预测性和健壮性。构建工业级工程化体系 : 掌握从零开始搭建一个包含自动化代码检查、格式化、提交规范验证的完整开发环境与工作流。实践可扩展的架构模式 : 学习并应用“功能内聚”的目录组织方式、高内聚低耦合的组件设计原则,以及组合式函数(Composables)的最佳实践。0.2 技术选型与分层架构设计 一个项目的技术选型,直接决定了其开发效率、性能上限和长期可维护性。我们采用了一套经过业界广泛验证的现代化技术栈,并遵循清晰的分层架构设计。
架构层级 核心技术 选型理由与核心职责 UI 表现层 Element Plus
提供一套统一、高质量的 UI 组件,极大提升开发效率。其丰富的组件生态和强大的功能(如表单校验、复杂表格)能满足绝大多数企业级需求。我们将重点实践其 基于 SCSS 变量的深度定制能力 。 状态管理层 TanStack Query
服务端状态核心 。专门用于管理与服务器的异步数据交互。它提供了缓存、后台同步、请求去重等强大功能,将组件从繁琐的 loading/error 状态管理中解放出来,是现代 Web 应用处理异步数据的最佳实践。Pinia
客户端状态核心 。Vue 官方的状态管理器,用于管理与 UI 强相关或需跨组件共享的、非持久化在后端的纯前端状态(如:用户登录信息、购物车数据)。核心框架层 Vue 3
/ Vite 5
基于组合式 API (<script setup>
) 进行开发,提供更灵活、更高效的逻辑组织与复用能力。Vite 则保证了极致的开发服务器性能和构建速度。 工程化基建 TypeScript
为项目提供静态类型系统,是构建大型、可维护应用,减少运行时错误的基石。 SCSS
成熟的 CSS 预处理器,其变量、嵌套、混入等特性是实现代码复用和主题化定制的必要工具。 ESLint
/ Prettier
保证代码在质量规范和风格上的一致性,是团队协作的必备工具。 Husky
/ lint-staged
将代码规范检查前置到 Git 提交阶段,从流程上保证代码仓库的纯净度。
为使开发过程能完全聚焦于前端工程化,本项目将采用 json-server
来提供稳定、可预测的模拟 RESTful API。这种方式实现了彻底的前后端分离,是现代前端“接口先行”开发模式的典型实践。
0.3 项目模块化开发路线图 我们将采用模块化的方式,循序渐进地完成整个项目的开发。以下是本次实战的完整开发路线图:
模块一:工程化环境搭建 目标 : 初始化项目,集成所有核心依赖,并建立一套完整的自动化代码质量保障体系。模块二:首页与通用布局 目标 : 使用 Element Plus 构建应用的整体布局,并开发包含轮播图、商品分类和推荐板块的首页。模块三:登录与用户认证 目标 : 实现完整的用户认证流程,精通 Element Plus 的表单校验与 TanStack Query 的 useMutation
应用。模块四:商品分类与列表 目标 : 开发二级分类页面,实现面包屑导航,并通过 TanStack Query 高效获取和渲染筛选后的商品列表。模块五:购物车与商品详情核心功能 目标 : 构建完整的购物车模块,深入实践 Pinia 与 Element Plus 复杂组件(如 Table, InputNumber)的数据联动。总结 : 本次实战将是一次从零到一的、完整的企业级前端项目构建之旅。我们不仅会学习如何使用最新的技术栈,更重要的是,我们将学习如何像架构师一样思考,构建一个结构清晰、代码健壮、易于维护和扩展的现代化 Web 应用。
模块一:专业基石 - 构筑自动化与企业级的开发环境 本模块任务清单 本模块将从零开始,构建一个功能完备、高度规范化的 Vue 3 企业级开发环境。我们将集成并深度配置所有核心依赖,建立一套全自动化的代码质量保障与提交体系。
通过以下 9 个核心任务,您将掌握构建企业级 Vue 3 开发环境的完整流程:
任务模块 任务名称 描述 项目初始化 项目模板创建 使用 pnpm
和 Vite
创建纯净的 Vue + TypeScript
项目模板。 代码规范体系 ESLint & Prettier 集成 集成 ESLint
和 Prettier
,采用 eslint.config.js
范式,统一代码质量与风格。 路径别名配置 开发效率基础设施 配置路径别名,为后续章节的模块引用提供基础支持。 UI 组件库集成 Element Plus 按需导入 安装 Element Plus
,利用 unplugin-vue-components
实现组件按需自动导入。 国际化配置 Element Plus 中文化 将 Element Plus 默认语言从英语切换为简体中文。 SCSS 企业级架构 样式系统构建 集成 SCSS
,建立企业级的样式架构和 Element Plus 主题定制方案。 核心服务集成 基础服务安装与配置 完成 TanStack Query
, Pinia
, Vue Router
和 Axios
的基础安装与初始化配置。 API 自动导入 开发效率优化 配置 API 自动导入,消除重复的 import 语句,提升编码效率。 自动化提交流程 Husky & lint-staged 配置 Husky
与 lint-staged
,在 Git 提交流程中强制执行代码检查。
1.1 项目初始化 我们从最基础的一步开始:创建一个全新的项目。
当前任务 : 1.1 - 项目初始化
任务目标 : 使用业界推荐的包管理器 pnpm
和构建工具 Vite
,快速生成一个标准的 Vue 3 + TypeScript 项目骨架。
1.1.1 设计思路与技术选型 在包管理器方面,我们优先选择 pnpm
,而非 npm
或 yarn
。pnpm
的核心优势体现在以下几个方面:
极致的磁盘空间效率 : pnpm
采用内容寻址和硬链接技术,确保相同版本的依赖在磁盘上只存储一份,极大地节省了存储空间。闪电般的安装速度 : 凭借对本地缓存的优先利用,pnpm
在依赖安装速度上显著优于其竞争者。严格的依赖管理 : pnpm
的非扁平化 node_modules
结构从根本上解决了 “幽灵依赖” 问题,从而增强了项目的稳定性和可维护性。在构建工具方面,Vite
是我们现代 Vue 项目开发的标准配置。其基于原生 ES 模块的开发服务器,提供了无与伦比的冷启动速度和热模块更新(HMR)性能,极大地提升了开发体验。
1.1.2 命令行操作 首先,请确保您的开发环境中已全局安装 pnpm
。如果尚未安装,可以通过 npm
执行以下命令进行安装:
接下来,打开终端并导航至您的工作目录。执行以下命令来启动项目创建流程:
Vite 的脚手架工具将引导您完成一系列项目配置选择。请按照以下配置项进行选择:
Project name : vue3-webShop
Select a framework : Vue
Select a variant : TypeScript
完成上述选择后,Vite 将在 /path/to/your/workspace/vue3-webShop
路径下完成项目骨架的搭建。随后,按照提示进入项目目录并安装所有依赖:
1 2 cd vue3-webShoppnpm install
1.1.3 成果预览:初始项目结构 执行完以上命令后,您将获得一个纯净且标准的 Vite + Vue 3 项目结构,其目录概览如下:
1 2 3 4 5 6 7 8 9 10 11 12 ├── public/ ├── src/ │ ├── assets/ │ ├── components/ │ ├── App.vue │ └── main.ts ├── .gitignore ├── index.html ├── package.json ├── tsconfig.json └── vite.config.ts
至此,项目的 “毛坯房” 已经搭建完毕。这是一个坚实且干净的起点,我们将在其之上,逐步添砖加瓦,构筑我们的应用大厦。
1.2 代码规范体系 在团队协作中,代码不仅是实现功能的工具,更是沟通的媒介。一套统一、自动化的代码规范体系,是保障项目长期可维护性、提升团队协作效率的基石。
当前任务 : 1.2 - 代码规范体系任务目标 : 为项目集成 ESLint
(代码质量检查) 和 Prettier
(代码风格格式化),并采用最新的 eslint.config.js
范式,建立一套专业的、自动化的代码规范标准。
1.2.1 设计思路:质量与风格的分离 在配置之前,我们必须理解两个核心工具的职责边界:
ESLint (代码质量卫士) : 它的核心职责是 发现代码中的潜在错误和不合理的写法 。例如,是否存在未被使用的变量、是否在 switch
语句中缺少 break
等。它关心的是代码的 正确性 和 健壮性 。Prettier (代码风格造型师) : 它的职责非常纯粹,即 统一代码的格式 。例如,是使用单引号还是双引号、行尾是否需要分号、代码行的最大宽度等。它只关心代码的 外观 ,不关心其逻辑。我们的策略是让它们各司其职,并通过配置让它们完美协作:ESLint 负责修复代码质量问题,Prettier 负责最终的格式化,且 Prettier 的优先级更高 ,以避免两者在风格规则上的冲突。
1.2.2 核心依赖安装 现在,我们将为项目安装所有必需的 “纪律委员”。请在项目根目录下执行以下命令:
1 pnpm add -D @eslint/js eslint eslint-config-prettier eslint-plugin-vue globals prettier typescript-eslint
依赖解读 :
eslint
& @eslint/js
: ESLint 本体及其官方核心规则集。typescript-eslint
: 用于让 ESLint 理解和校验 TypeScript 语法的核心插件。eslint-plugin-vue
: 专为 .vue
单文件组件量身打造的规则插件。eslint-config-prettier
: 关键 !用于关闭所有与 Prettier 冲突的 ESLint 规则,确保 Prettier 拥有最终的格式化决定权。prettier
: Prettier 本体。globals
: 用于预设浏览器、Node.js 等环境的全局变量,避免 ESLint 报错。1.2.3 现代化 ESLint 配置 ESLint 的配置已全面转向基于 ES Module 的 eslint.config.js
文件。这种新范式提供了前所未有的灵活性。
现在,我们在项目根目录创建 eslint.config.js
文件,并填入以下内容。让我们来逐块解析它的结构:
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 import globals from "globals" ;import eslintJs from "@eslint/js" ;import tseslint from "typescript-eslint" ;import eslintPluginVue from "eslint-plugin-vue" ;import eslintConfigPrettier from "eslint-config-prettier" ;export default tseslint.config ( { ignores : ["dist" , "node_modules" , "*.config.js" , "public" , ".DS_Store" ], }, { languageOptions : { globals : { ...globals.browser , ...globals.es2021 , }, }, }, eslintJs.configs .recommended , ...tseslint.configs .recommended , ...eslintPluginVue.configs ["flat/recommended" ], eslintConfigPrettier, { files : ["**/*.vue" ], languageOptions : { parserOptions : { parser : tseslint.parser , }, }, rules : { "vue/multi-word-component-names" : "off" , "vue/html-self-closing" : [ "error" , { html : { void : "always" , normal : "never" , component : "always" , }, }, ], "no-console" : process.env .NODE_ENV === "production" ? "warn" : "off" , "no-debugger" : process.env .NODE_ENV === "production" ? "warn" : "off" , }, } );
配置解读 :
ignores
: 全局忽略配置。我们明确告诉 ESLint 不要检查构建产物、依赖、配置文件等,以提升检查效率并避免误报。languageOptions.globals
: 全局变量配置。我们引入了 globals
包中预设的浏览器和 ES2021 环境的全局变量,这样在代码中使用 window
、Promise
等就不会被 ESLint 判为未定义。eslintJs.configs.recommended
: 启用 ESLint 官方的核心推荐规则。...tseslint.configs.recommended
: 启用 typescript-eslint
的推荐规则集,用于检查 TypeScript 代码。...eslintPluginVue.configs["flat/recommended"]
: 启用 eslint-plugin-vue
针对 Vue 3 的推荐规则集。eslintConfigPrettier
: 关键步骤 。此配置会禁用所有与 Prettier 格式化功能冲突的 ESLint 规则。它必须放在配置数组的靠后位置 ,以确保它能覆盖之前所有规则集中的样式规则。自定义规则 : 这是我们项目的专属规则区。files: ["**/*.vue"]
指定了这些规则仅对 .vue
文件生效。parserOptions
: 为 Vue 文件的 <script>
部分指定 TypeScript 解析器。rules
:'vue/multi-word-component-names': 'off'
: 关闭了组件名必须为多词的规则,在某些场景下(如根组件 App.vue
)可以更灵活。'vue/html-self-closing'
: 配置了 HTML 元素的自闭合风格,增强代码一致性。'no-console'
, 'no-debugger'
: 设置了在生产环境下(process.env.NODE_ENV === 'production'
)禁止使用 console
和 debugger
,这是非常重要的生产环境最佳实践。1.3 路径别名配置 为了在后续开发中方便地引用 src
目录下的文件,避免使用复杂的相对路径,我们需要先设置路径别名。这是后续章节正常使用 @/
前缀的基础配置。
当前任务 : 1.3 - 路径别名配置任务目标 : 配置 @
作为 src
目录的别名,为项目建立简洁的模块引用方式。
1.3.1 设计思路 在大型项目中,文件结构往往很深,使用相对路径会导致这样的引用:
1 2 import { apiCall } from "../../../utils/http" ;import UserComponent from "../../../components/User.vue" ;
通过配置路径别名,我们可以将其简化为:
1 2 import { apiCall } from "@/utils/http" ;import UserComponent from "@/components/User.vue" ;
这不仅让代码更简洁,还避免了在移动文件时需要修改引用路径的问题。
1.3.2 Vite 配置 在项目根目录的 vite.config.ts
文件中添加路径别名配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 import { fileURLToPath, URL } from "node:url" ;import { defineConfig } from "vite" ;import vue from "@vitejs/plugin-vue" ;export default defineConfig ({ plugins : [vue ()], resolve : { alias : { "@" : fileURLToPath (new URL ("./src" , import .meta .url )), }, }, });
1.3.3 TypeScript 支持 为了让 TypeScript 理解这个路径别名,我们需要更新 TypeScript 配置。
方法一:如果您的项目使用单一 tsconfig.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "compilerOptions" : { "target" : "ES2020" , "lib" : [ "ES2020" , "DOM" , "DOM.Iterable" ] , "module" : "ESNext" , "moduleResolution" : "bundler" , "strict" : true , "jsx" : "preserve" , "esModuleInterop" : true , "allowSyntheticDefaultImports" : true , "forceConsistentCasingInFileNames" : true , "skipLibCheck" : true , "baseUrl" : "." , "paths" : { "@/*" : [ "src/*" ] } } , "include" : [ "src/**/*.ts" , "src/**/*.d.ts" , "src/**/*.tsx" , "src/**/*.vue" ] }
方法二:如果您的项目使用 tsconfig.app.json
(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "extends" : "@vue/tsconfig/tsconfig.dom.json" , "compilerOptions" : { "tsBuildInfoFile" : "./node_modules/.tmp/tsconfig.app.tsbuildinfo" , "strict" : true , "noUnusedLocals" : true , "noUnusedParameters" : true , "noFallthroughCasesInSwitch" : true , "baseUrl" : "." , "paths" : { "@/*" : [ "src/*" ] } } , "include" : [ "src/**/*.ts" , "src/**/*.d.ts" , "src/**/*.tsx" , "src/**/*.vue" ] }
修改 TypeScript 配置文件后,请务必通过 VS Code 命令面板 (Ctrl+Shift+P
) 执行 TypeScript: Restart TS Server
命令来重启 TypeScript 服务,以确保路径提示生效。
1.3.4 功能测试 配置完成后,在任意 Vue 组件中尝试使用 @/
前缀:
1 2 3 4 5 6 7 8 9 10 <script setup lang ="ts" > import { ref } from "vue" ;</script > <template > <div > 路径别名配置完成</div > </template >
如果 VS Code 能够正确提供路径提示和自动补全,说明配置成功。
1.4 UI 组件库与图标库集成 为项目引入一套成熟的 UI 组件库,是现代前端开发的标准实践。它能极大提升开发效率,保证 UI 的一致性和可访问性,让我们能专注于业务逻辑的实现。
当前任务 : 1.4 - UI 组件库与图标库集成任务目标 : 为项目安装 Element Plus
及其官方图标库,并利用 unplugin-vue-components
、unplugin-auto-import
和 unplugin-icons
插件,实现组件 和图标 的按需自动导入。
1.4.1 设计思路:按需自动导入 在项目中引入 Element Plus 有两种方式:全局引入和按需导入。
全局引入 : 简单直接,但会将所有组件和图标打包,导致最终构建产物体积过大。按需导入 : 只打包代码中实际使用到的组件,是生产环境的最佳实践。为了实现优雅的按需导入,避免在每个文件中手动 import { ElButton } from 'element-plus'
和 import { Edit } from '@element-plus/icons-vue'
,我们借助 unplugin
系列插件来自动化这一过程。
1.4.2 核心依赖安装 我们将安装 element-plus
本体、其官方图标库,以及三个实现自动导入的 unplugin
插件。请在项目根目录下执行以下命令:
1 2 3 4 5 pnpm add element-plus @element-plus/icons-vue pnpm add -D unplugin-vue-components unplugin-auto-import unplugin-icons @iconify-json/ep
依赖解读 :
包名 核心职责 element-plus
Element Plus 组件库本体。 @element-plus/icons-vue
Element Plus 的官方图标库,其中每个图标都是一个独立的 Vue 组件。 unplugin-auto-import
自动导入 API,如 ElMessage
等 JS 调用方法。 unplugin-vue-components
自动导入组件,扫描模板中的标签(如 <el-button>
)并自动导入。 unplugin-icons
一个强大的图标插件,可以与 unplugin-vue-components
协同工作,实现图标的自动导入。 @iconify-json/ep
Element Plus 图标的 Iconify 数据包,为 unplugin-icons 提供图标数据。
1.4.3 Vite 配置文件 (vite.config.ts
) 安装完依赖后,我们需要在 vite.config.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 44 45 import { fileURLToPath, URL } from "node:url" ;import { defineConfig } from "vite" ;import vue from "@vitejs/plugin-vue" ;import AutoImport from "unplugin-auto-import/vite" ;import Components from "unplugin-vue-components/vite" ;import { ElementPlusResolver } from "unplugin-vue-components/resolvers" ;import Icons from "unplugin-icons/vite" ;import IconsResolver from "unplugin-icons/resolver" ;export default defineConfig ({ plugins : [ vue (), AutoImport ({ resolvers : [ElementPlusResolver ()], }), Components ({ resolvers : [ ElementPlusResolver ({ importStyle : "sass" }), IconsResolver ({ prefix : "i" , enabledCollections : ["ep" ], }), ], }), Icons ({ autoInstall : true , }), ], resolve : { alias : { "@" : fileURLToPath (new URL ("./src" , import .meta .url )), }, }, });
配置解读 :
IconsResolver
: 这是专门用于解析图标的解析器。enabledCollections: ['ep']
: 告诉解析器只启用 Element Plus 的图标集 (ep
是其简称)。使用约定 : 我们建立了一个清晰的图标使用约定:所有以 <i-ep-
开头的标签(例如 <i-ep-edit />
),都会被自动识别为 Element Plus 的图标并导入。1.4.4 TypeScript 类型支持 (Volar) 为了让 TypeScript 和 Volar 能够识别自动导入的组件和图标,我们需要确保 tsconfig.json
的 include
配置包含了插件自动生成的类型声明文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 { "include" : [ "src/**/*.ts" , "src/**/*.d.ts" , "src/**/*.tsx" , "src/**/*.vue" , "auto-imports.d.ts" , "components.d.ts" ] }
修改配置后,你可能需要重启 VS Code 或执行 TypeScript: Restart TS Server
命令来让类型提示生效。
1.4.5 功能测试 配置完成后,我们来验证组件和图标是否都已成功集成。打开 src/App.vue
文件,替换为以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <script setup lang ="ts" > </script > <template > <el-button type ="primary" > 我是按钮</el-button > <el-button type ="success" > <i-ep-edit /> 编辑按钮 </el-button > <div > <i-ep-success color ="green" style ="font-size: 2em" /> <i-ep-warning color ="orange" style ="font-size: 2em" /> <i-ep-circle-close color ="red" style ="font-size: 2em" /> </div > </template >
现在,启动开发服务器 (pnpm run dev
)。如果你能看到按钮和图标都正常显示,那么恭喜你,自动导入已成功配置!
1.4.6 深度解析:图标的两种用法 (手动 vs 自动) 你可能会有疑问:为什么我们不使用官方文档中常见的 import { Edit } from '@element-plus/icons-vue'
方式,而是用 <i-ep-edit />
?这是一个非常好的问题,理解它有助于你掌握现代前端的工程化思想。
开发者对话
配置完成后
等等,我平时用图标都是 import { Phone } from '@element-plus/icons-vue'
,然后在模板里用 :icon="Phone"
。你教的 <i-ep-phone />
是什么新语法?
架构师
问得好!你说的 手动导入 是 Vue 最经典、最直观的用法。而我们配置的 自动导入 ,是利用工具链提升效率的 工程化最佳实践 。两者都能用,但后者能让你的代码更简洁。
架构师
手动导入,你是在 运行时 告诉 Vue,“Phone
这个变量是一个组件”。而自动导入,是插件在 编译时 就帮你把 <i-ep-phone />
这个 “暗号” 直接替换成了真正的组件和导入语句。
我明白了!所以自动导入省去了我在 <script>
里写一大堆 import 的麻烦,想用哪个图标,直接在模板里写 “暗号” 就行了。
架构师
完全正确!这就是现代前端工程化的魅力所在——把重复的、机械的工作交给工具,让开发者专注于创造。
方法 A: 手动导入 (传统方式) 这是 Element Plus 官方文档中最常见的用法,依赖关系清晰。
代码示例 :1 2 3 4 5 6 <script setup lang ="ts" > import { Phone } from "@element-plus/icons-vue" ;</script > <template > <el-link :icon ="Phone" > 在线客服</el-link > </template >
优点 : 依赖关系明确,无需额外配置。缺点 : 当使用大量图标时,<script>
部分会变得非常冗长。方法 B: 自动导入 (工程化实践) 这是我们通过插件配置实现的用法,开发体验极致高效。
代码示例 :1 2 3 4 <template > <el-button type ="success" > <i-ep-edit /> 编辑按钮 </el-button > </template >
优点 : <script>
保持干净,开发效率极高,代码更整洁。缺点 : 需要前期配置,对于不熟悉配置的人来说有 “魔法” 成分。特性 手动导入 (传统方式) 自动导入 (工程化实践) 核心思想 运行时 ,开发者手动管理依赖编译时 ,插件自动注入依赖代码整洁度 <script>
部分较长,依赖明确<script>
部分干净,模板即声明开发效率 较低,需要手动导入 极高 ,开箱即用推荐实践 偶尔需要将图标作为变量传递时使用 现代前端工程化的首选方案,适用于绝大部分场景
结论: 在本教程中,我们将 优先使用自动导入 的方式,因为它极大地提升了开发体验,是构建大型企业级项目的首选。理解这两种方式的差异,将让你对现代前端工具链有更深刻的认识。
1.5 国际化 (i18n) 配置 Element Plus 组件库默认的语言是英语。对于面向中文用户的项目,我们需要进行国际化配置,将提示文字、日期格式等转换为中文,以提供符合用户习惯的体验。
当前任务 : 1.5 - 国际化 (i18n) 配置任务目标 : 将 Element Plus 组件的默认语言从英语(en)切换为简体中文(zh-cn)。
1.5.1 设计思路 Element Plus 提供了两种主流的国际化配置方案:
全局配置 : 在应用入口文件 (main.ts
) 中,通过 app.use
传入全局配置对象。这是最常用、最直接的方式,一次配置,全局生效。ElConfigProvider
组件 : 使用 ElConfigProvider
组件包裹根组件或部分组件树,可以实现全局或局部的语言配置。对于我们整个应用都需要使用中文的场景,全局配置 是最佳选择。
1.5.2 全局配置实现 我们将修改 main.ts
文件,导入 Element Plus 的中文语言包并进行全局注册。
现在,让我们整合这段逻辑到 main.ts
文件中。如果架构师的 main.ts
中已经有 app.use(ElementPlus)
,请将其替换为带有配置对象的版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { createApp } from "vue" ;import App from "./App.vue" ;import ElementPlus from "element-plus" ;import "element-plus/dist/index.css" ;import zhCn from "element-plus/es/locale/lang/zh-cn" ;const app = createApp (App );app.use (ElementPlus , { locale : zhCn, }); app.mount ("#app" );
配置解读 :
我们从 element-plus/dist/locale/
目录下导入了 zh-cn.mjs
,这是一个包含了所有组件中文翻译的对象。 在调用 app.use(ElementPlus)
时,我们传入了第二个参数,一个配置对象 { locale: zhCn }
。Element Plus 内部会接收这个对象,并将 zhCn
设为默认语言包。 1.5.3 功能测试 如何验证国际化配置是否生效?我们可以使用一个包含大量文本的组件,例如日期选择器 (ElDatePicker
)。
修改 src/App.vue
进行测试:
1 2 3 4 5 6 7 8 9 10 11 <template > <div class ="test-container" > <el-date-picker type ="date" placeholder ="请选择日期" /> </div > </template > <style scoped > .test-container { padding : 20px ; } </style >
现在,运行项目。当你点击日期选择框时,弹出的面板中的 “一月”、“周一”、“今天” 等文字如果都显示为中文,则证明我们的国际化配置已成功生效。
现在,运行项目。当你点击日期选择框时,弹出的面板中的 “一月”、“周一”、“今天” 等文字如果都显示为中文,则证明我们的国际化配置已成功生效。
1.6 SCSS 企业级架构与主题定制 在企业级项目中,样式的可维护性、可扩展性和一致性至关重要。本节,我们将摒弃零散的样式文件,构建一套专业、分层的 SCSS 架构。我们将学习如何集成并定制 Element Plus,同时建立一套包含全局变量、基础规范、混入(Mixins)的完整体系,并通过一个统一的入口文件进行管理,最终由 Vite 实现高效的全局注入。
当前任务 : 1.6 - 构建企业级 SCSS 架构任务目标 : 建立一套专业的、可扩展的 SCSS 文件结构。通过 Vite additionalData
全局注入编译时工具(变量、混入),并通过 main.ts
导入唯一的全局样式文件,实现最高效的样式管理方案。
1.6.1 设计思路:全局样式与编译工具的分离 一个顶级的样式架构必须清晰地分离两个概念:
全局样式表 (Global Stylesheet) : 这是应用的基础外观,包含 CSS 重置、基础排版 (body
, a
标签等) 和 Element Plus 的定制化样式。这些样式应该被编译成一个单一的 CSS 文件,并在应用入口 (main.ts
) 加载且仅加载一次 。编译时工具 (Compile-Time Tools) : 这是我们在编写组件独有样式时需要的设计令牌和代码片段,如 $GLColor
变量和 flex-center
混入。这些工具本身不产生 CSS,它们需要在 每个组件的 <style>
块中都可用 ,以便我们遵循设计规范。我们将通过 main.scss
文件来组织 全局样式表 ,并通过 Vite 的 additionalData
配置来全局提供 编译时工具 。
1.6.2 依赖安装与文件结构创建 安装 SCSS 编译器与重置库 :
1 pnpm add -D sass scss-reset
创建全新的样式文件结构 : 在 src/
目录下创建 styles
文件夹,并建立以下专业结构:
1 2 3 4 5 6 7 8 9 10 11 src/ └── styles/ ├── abstracts/ │ ├── _variables.scss # 全局变量 │ ├── _mixins.scss # 全局混入 │ └── _utilities.scss # 工具类 ├── base/ │ └── _index.scss # 基础样式规范 ├── element/ │ └── _index.scss # Element Plus 变量覆盖 └── main.scss # 唯一的全局样式总入口
1.6.3 编写抽象层 (Abstracts) 这是我们的 “工具箱”,包含所有不直接输出 CSS 的变量和混入。
1. _variables.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $GLColor : #0155b2 ;$sucColor : #1dc779 ;$warnColor : #ffb302 ;$priceColor : #cf4444 ; $helpColor : #e26237 ;$textColor : #333 ;$textColor-secondary : #666 ;$borderColor : #e4e4e4 ;$bgColor : #f5f5f5 ;$borderRadius : 4px ;$transition-duration : 0.2s ;
2. _mixins.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @mixin flex-center { display : flex; align-items : center; justify-content : center; } @mixin truncate-text { overflow : hidden; text -overflow : ellipsis; white-space : nowrap; }
3. _utilities.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @mixin utilities { .text-center { text -align: center; } .text-left { text -align: left; } .text-right { text -align: right; } }
1.6.4 编写会输出 CSS 的样式层 这两个层级的文件会实际产出 CSS,它们需要显式地导入自己所依赖的变量。
1. base/_index.scss
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 @use "../abstracts/variables" as *;html { font-size : 62.5% ; box-sizing : border-box; } *, *::before , *::after { box-sizing : inherit; } body { font-size : 1.6rem ; font-family : -apple-system, BlinkMacSystemFont, "Segoe UI" , Roboto, sans-serif; line -height : 1.5 ; color : $textColor ; background-color : $bgColor ; -webkit-font-smoothing : antialiased; } a { color : $GLColor ; text -decoration: none; transition : color $transition-duration ; &:hover { color : lighten ($GLColor , 10% ); } }
2. element/_index.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @use "../abstracts/variables" as *;@forward "element-plus/theme-chalk/src/common/var.scss" with ( $colors : ( "primary" : ( "base" : $GLColor , ), "success" : ( "base" : $sucColor , ), "warning" : ( "base" : $warnColor , ), "danger" : ( "base" : $priceColor , ), "error" : ( "base" : $priceColor , ), ) );
1.6.5 编写全局样式入口 main.scss
此文件是 全局样式表的指挥中心 ,它按顺序编排所有会产出 CSS 的样式模块。
1 2 3 4 5 6 7 8 9 10 @use "scss-reset" ;@use "base" ;@use "element" ;
1.6.6 理解工作流:最佳注入策略 开发者日记
开发中
架构师,我们现在有了 main.scss
总入口,还有 _variables.scss
这些工具文件。我们应该如何在 Vite 中配置,才能达到最佳的性能和开发体验?
你
这是一个关键问题。最粗暴的方法是在 additionalData
里注入 main.scss
,但这会导致 scss-reset
和 base
这些全局样式被重复打包进每一个组件的 CSS 中,造成代码冗余。
你
采用 分离策略 。Vite 的 additionalData
只用来注入那些 不产出 CSS 的编译时工具 ——也就是我们的变量和混入。这样,每个组件的 <style>
块都能随时使用 $GLColor
或 @include flex-center
。
那 scss-reset
和 base
这些真正的全局样式呢?
你
它们由 main.scss
统一管理,而我们只需要在项目的 JavaScript 入口,也就是 main.ts
中,import '@/styles/main.scss'
一次。这样 Vite 就会把它们打包成一个独立的、被所有页面共享的 CSS 文件。一次加载,全局生效 。这就是企业级的最佳实践。
1.6.7 实施最终配置 现在,我们将整合所有配置,实施我们讨论出的最佳注入策略。
1. 更新 vite.config.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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import { fileURLToPath, URL } from "node:url" ;import { defineConfig } from "vite" ;import vue from "@vitejs/plugin-vue" ;import AutoImport from "unplugin-auto-import/vite" ;import Components from "unplugin-vue-components/vite" ;import { ElementPlusResolver } from "unplugin-vue-components/resolvers" ;import Icons from "unplugin-icons/vite" ;import IconsResolver from "unplugin-icons/resolver" ;export default defineConfig ({ plugins : [ vue (), AutoImport ({ resolvers : [ElementPlusResolver ()], }), Components ({ resolvers : [ ElementPlusResolver ({ importStyle : "sass" }), IconsResolver ({ prefix : "i" , enabledCollections : ["ep" ], }), ], }), Icons ({ autoInstall : true , }), ], resolve : { alias : { "@" : fileURLToPath (new URL ("./src" , import .meta .url )), }, }, css : { preprocessorOptions : { scss : { additionalData : ` @use "@/styles/abstracts/variables" as *; @use "@/styles/abstracts/mixins" as *; @use "@/styles/abstracts/utilities" as *; ` , }, }, }, });
2. 更新 main.ts
1 2 3 4 5 6 7 8 9 10 11 12 import { createApp } from "vue" ;import App from "./App.vue" ;import ElementPlus from "element-plus" ;import zhCn from "element-plus/es/locale/lang/zh-cn" ;import "@/styles/main.scss" ;const app = createApp (App );app.use (ElementPlus , { locale : zhCn }); app.mount ("#app" );
1.6.8 清理与测试 确认 main.ts
: 确保 import 'element-plus/dist/index.css'
已被彻底删除,因为我们的样式体系已经完美接管。
功能测试 : 重启 Vite 开发服务器。
全局样式 : 页面基础排版(字体、背景色等)应已正确应用。这证明 main.scss
已被成功加载一次。Element Plus 定制 : <el-button type="primary">
按钮应显示为我们定义的 $GLColor
。组件内工具 : 在任意 Vue 组件的 <style lang="scss">
块中,你都可以直接使用 $GLColor
变量或 @include flex-center;
混入来编写该组件的 独有样式 ,而无需任何手动导入。通过这套架构,我们实现了企业级项目的终极目标:一套清晰、高效、可维护的样式体系,它完美平衡了全局一致性与组件独立性。
1.7 核心服务集成 一个应用不仅需要美观的 UI,还需要强大的 “内脏” 来处理数据请求、页面导航和状态管理。本节将为我们的项目集成这些核心服务,搭建起应用的 “神经与循环系统”。
当前任务 : 1.7 - 核心服务集成任务目标 : 为项目安装并配置 Axios
(网络请求)、Vue Router
(路由)、Pinia
(客户端状态) 和 TanStack Query
(服务端状态)。
1.7.1 网络请求层 (Axios) 封装 在任何项目中,直接在组件中调用 axios.get(...)
都是一种不良实践。我们需要一个统一的、可配置的封装层来处理通用逻辑。
设计思路 : 我们将创建一个 axios
实例,并配置通用的 baseURL
和 timeout
。更重要的是,我们将利用 拦截器 (Interceptors) 来实现:请求拦截器 : 未来用于统一注入用户 token。响应拦截器 : 用于统一处理返回数据的结构(例如,直接返回 res.data
)和集中的错误处理(例如,401 未授权拦截)。开发者日记
开发中
架构师,我们现在要开始做 API 请求了。我之前的习惯是在需要请求的组件里直接 import axios from 'axios'
然后就用了,这样做有什么问题吗?
架构师
这是一个很常见的起点,但在大型项目中会很快演变成一场灾难。想象一下,如果后端 API 的 baseURL 换了,或者所有请求都需要加一个统一的 header,你是不是得去几十个文件里一个个修改?
架构师
完全正确。所以,企业级项目的最佳实践,是创建一个全局的、封装好的 axios 实例。我们将所有的基础配置,比如 baseURL、超时时间,都放在这个文件里。更强大的是,我们可以用 “拦截器” 来打造请求的 “自动化流水线”。
架构师
是的。比如,请求拦截器可以在每个请求发出去之前,自动检查并带上用户的 token。响应拦截器可以在收到数据后,自动帮架构师剥离掉外层的 data 包装,或者在遇到 401 这种通用错误时,直接弹出提示并跳转到登录页。这样架构师的业务组件就只需要关心业务本身了。
明白了!单一职责原则,让专业的工具做专业的事。那我们这个封装文件应该怎么写?
架构师
很好,你已经领悟到精髓了。我来给你提供一个非常标准的、具备良好扩展性的基础封装结构。
1. 依赖安装 2. 封装 HTTP 工具模块 在 src/utils/
目录下创建 http.ts
文件。
请将以下代码填入 src/utils/http.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 import axios from "axios" ;const httpInstance = axios.create ({ baseURL : "http://localhost:3001" , timeout : 5000 , }); httpInstance.interceptors .request .use ( (config ) => { return config; }, (e ) => Promise .reject (e) ); httpInstance.interceptors .response .use ( (res ) => res.data , (e ) => { return Promise .reject (e); } ); export default httpInstance;
代码解读 :
axios.create
: 创建一个独立的 axios 实例,避免污染全局 axios。interceptors.request
: 在请求发送前进行拦截。我们在这里预留了未来添加 token 的逻辑。interceptors.response
: 在响应到达 then/catch
之前进行拦截。我们在这里做了两件重要的事:成功回调中 res => res.data
,这可以让我们在后续调用时直接获取数据,无需再 response.data
。 失败回调中预留了统一错误处理逻辑,例如 token 失效后的跳转登录页。 1.7.2 路由系统 (Vue Router) Vue Router 是 Vue 官方的路由管理器,是构建单页应用 (SPA) 的标准配置。
1. 依赖安装 2. 创建路由配置文件 在 src/
目录下创建 router/index.ts
文件。
开发者日记
开发中
架构师,关于路由,我们这个电商项目页面还挺多的。比如有首页、登录页、分类页…我是不是应该给每个页面都创建一个顶级路由,比如 /
、/login
、/category
?
架构师
这是个好问题,它涉及到路由设计的核心思想。你看,首页和分类页,它们是不是都有共同的页头和页脚?
架构师
这就是关键。我们会创建一个 Layout
组件,作为所有 “有公共布局” 页面的父级容器。然后,首页、分类页都作为它的 “子路由” 存在。当用户在这些页面间切换时,实际上只是 Layout
组件内部的一部分在变化,而页头页脚保持不变。这不仅代码复用性好,用户体验也更流畅。登录页则是一个独立的顶级路由。
原来如此!这就是嵌套路由的实际应用场景。那 path
该怎么写?首页的 path
应该是 /home
吗?
架构师
一个小技巧是,当 Layout
的 path
是 /
时,你可以给首页子路由一个空的 path: ''
。这样,访问根路径 /
时,就会自动渲染 Layout
并把首页作为它的默认内容。
请将以下代码填入 src/router/index.ts
文件中,并根据需要创建对应的 .vue
文件 (可以先只写一个 <template><div>...</div></template>
):
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 import { createRouter, createWebHistory } from "vue-router" ;import Layout from "@/views/Layout/index.vue" ;import Login from "@/views/Login/index.vue" ;const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : "/" , component : Layout , children : [ { path : "" , component : () => import ("@/views/Home/index.vue" ), }, { path : "category/:id" , component : () => import ("@/views/Category/index.vue" ), }, ], }, { path : "/login" , component : Login , }, ], }); export default router;
代码解读 :
createWebHistory
: 使用 H5 History 模式,URL 路径更美观。routes
: 路由规则数组。我们定义了一个 嵌套路由 :/
路径会渲染 Layout
组件,它作为所有一级页面的通用布局(包含导航、页头、页脚)。children
数组定义了二级路由。当访问 /
时,由于 path: ''
,会默认在 Layout
组件的 <RouterView />
中渲染 Home
组件。1.7.3 状态管理引擎安装与最终注册 最后,我们安装 Pinia
和 TanStack Query
,并在 main.ts
中完成所有核心服务的最终注册。
1. 依赖安装 1 2 pnpm add pinia @tanstack/vue-query pnpm add pinia-plugin-persistedstate
@tanstack/vue-query
的开发者工具 vue-query-devtools
在生产环境中不会被打包,但为了方便调试,推荐在开发依赖中安装它:pnpm add -D @tanstack/vue-query-devtools
。
2. 在 main.ts
中完成所有服务注册 这是我们 main.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 import { createApp } from "vue" ;import App from "./App.vue" ;import router from "./router" ; import { createPinia } from "pinia" ;import { VueQueryPlugin } from "@tanstack/vue-query" ;import ElementPlus from "element-plus" ;import piniaPluginPersistedstate from "pinia-plugin-persistedstate" ; import zhCn from "element-plus/es/locale/lang/zh-cn" ;import "@/styles/main.scss" ;const app = createApp (App );const pinia = createPinia ();pinia.use (piniaPluginPersistedstate); app.use (pinia); app.use (router); app.use (VueQueryPlugin ); app.use (ElementPlus , { locale : zhCn, }); app.mount ("#app" );
1.8 API 自动导入优化 在【任务 1.3】中,我们已经为项目配置了路径别名,解决了深层相对路径的问题。本节,我们将解决另一个影响开发效率的痛点:在每个 Vue 组件中重复编写 import { ref, computed, ... } from 'vue'
。
当前任务 : 1.8 - API 自动导入优化任务目标 : 配置 unplugin-auto-import
插件,实现对 Vue、Vue Router 等核心库 API 的自动导入,让开发者更专注于业务逻辑。
1.8.1 设计思路:解放 import
语句 unplugin-auto-import
插件的原理是在编译时扫描代码,如果发现你使用了像 ref
, computed
, onMounted
等未被导入的变量,它会自动检查这些变量是否属于预设库(如 ‘vue’, ‘vue-router’)的导出成员。如果是,它会自动为架构师添加导入语句。为了让 TypeScript 理解这些 “凭空出现” 的全局变量,该插件还会生成一个类型声明文件(.d.ts
)。
1.8.2 Vite 配置文件 (vite.config.ts
) 我们将扩展 unplugin-auto-import
的配置,明确告知它需要为哪些库开启自动导入。
开发者日记
开发中
架构师,路径别名确实方便多了。但现在我还是很烦,每个组件都要写一遍 import { ref, computed } from 'vue'
,有没有办法让它也自动化?
架构师
问得好,这正是我们要解决的下一个问题。专业的工作流,就是要消灭一切不必要的重复性劳动。unplugin-auto-import
这个插件,就是为此而生的。
之前我们安装 Element Plus 的时候已经装过它了。
架构师
是的,当时我们只用了它的基础功能。现在,我们要深入配置它,让它不仅处理 Element Plus 的 API,更能处理 Vue 和 Vue Router 的所有核心 API。我们还需要配置类型声明的生成路径,确保 TypeScript 和 IDE 能够正确识别这些 “凭空出现” 的全局变量。
明白了,这样我们就彻底告别手动 import 了!
架构师
完全正确!一旦配置完成,你在任何组件中都可以直接使用 ref
, computed
, useRouter
等 API,就像它们是全局变量一样自然。
现在,让我们扩展 vite.config.ts
中 unplugin-auto-import
的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import AutoImport from "unplugin-auto-import/vite" ;export default defineConfig ({ plugins : [ vue (), AutoImport ({ imports : ["vue" , "vue-router" ], dts : "src/auto-imports.d.ts" , resolvers : [ElementPlusResolver ()], }), Components ({ }), ], });
配置解读 :
imports: ['vue', 'vue-router']
: 我们明确告诉插件,自动扫描并导入 vue
和 vue-router
这两个库导出的所有 API。dts: 'src/auto-imports.d.ts'
: 这是至关重要的一步。插件会创建一个 auto-imports.d.ts
文件,其中包含了所有自动导入 API 的 TypeScript 类型声明。1.8.3 TypeScript 类型支持 我们需要确保 TypeScript 配置文件能够识别这个自动生成的类型声明文件。
在 tsconfig.app.json
文件中,确保 include
数组包含了 auto-imports.d.ts
:
1 2 3 4 5 6 7 8 9 10 11 12 { "include" : [ "src/**/*.ts" , "src/**/*.d.ts" , "src/**/*.tsx" , "src/**/*.vue" , "auto-imports.d.ts" , "components.d.ts" ] }
1.8.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 <script setup lang ="ts" > const count = ref (0 );const doubleCount = computed (() => count.value * 2 );const router = useRouter ();const increment = ( ) => { count.value ++; }; const goHome = ( ) => { router.push ("/" ); }; onMounted (() => { console .log ("组件已挂载,自动导入测试成功!" ); }); </script > <template > <div > <p > Count: {{ count }}</p > <p > Double Count: {{ doubleCount }}</p > <el-button @click ="increment" > 增加</el-button > <el-button @click ="goHome" > 回到首页</el-button > </div > </template >
重启 Vite 开发服务器后,如果上述代码能够正常运行且 VS Code 提供了完整的类型提示,说明自动导入配置成功!
配置成功标志 : 当你输入 ref
、computed
等 API 时,VS Code 会自动提供智能提示,且无需手动导入即可正常使用。同时,你会发现项目根目录生成了 auto-imports.d.ts
文件。
最终完整的 vite.config.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 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 import { fileURLToPath, URL } from "node:url" ;import { defineConfig } from "vite" ;import vue from "@vitejs/plugin-vue" ;import AutoImport from "unplugin-auto-import/vite" ;import Components from "unplugin-vue-components/vite" ;import { ElementPlusResolver } from "unplugin-vue-components/resolvers" ;import Icons from "unplugin-icons/vite" ;import IconsResolver from "unplugin-icons/resolver" ;export default defineConfig ({ plugins : [ vue (), AutoImport ({ imports : ["vue" , "vue-router" ], dts : "src/auto-imports.d.ts" , resolvers : [ElementPlusResolver ()], }), Components ({ resolvers : [ ElementPlusResolver ({ importStyle : "sass" }), IconsResolver ({ prefix : "i" , enabledCollections : ["ep" ], }), ], }), Icons ({ autoInstall : true , }), ], resolve : { alias : { "@" : fileURLToPath (new URL ("./src" , import .meta .url )), }, }, server : { proxy : { "/api" : { target : "http://localhost:3001" , changeOrigin : true , rewrite : (path ) => path.replace (/^\/api/ , "" ), }, "/images" : { target : "http://localhost:3001" , changeOrigin : true , }, }, }, css : { preprocessorOptions : { scss : { additionalData : ` @use "@/styles/abstracts/variables" as *; @use "@/styles/abstracts/mixins" as *; @use "@/styles/abstracts/utilities" as *; ` , }, }, }, });
1.9 自动化提交流程 IDE 级别的配置能提升个人效率,但无法保证团队协作的一致性。我们需要在代码流入版本库的最后一道关卡——git commit
——设立 “自动化哨兵”,强制所有提交都遵循统一的质量和规范标准,确保代码仓库的纯净与专业。
当前任务 : 1.9 - 自动化提交流程任务目标 : 初始化 Git 仓库并推送到 GitHub,然后集成 Husky
, lint-staged
, commitlint
,构建一个在提交代码前自动执行 “代码格式化”、“质量检查” 和 “提交信息规范校验” 的自动化工作流。
1.9.1 设计思路:三道防线 我们将建立三道质量防线:
第一道防线 - 预提交检查 (pre-commit
Hook) : 在 git commit
执行前,自动对暂存区的文件运行 ESLint
和 Prettier
,确保所有即将提交的代码都符合质量和风格标准。第二道防线 - 提交信息校验 (commit-msg
Hook) : 校验提交信息是否遵循约定式提交规范,确保提交历史的专业性和可读性。第三道防线 - 推送前检查 : 在推送到远程仓库前,可以添加额外的检查,如运行完整的测试套件。1.9.2 Git 仓库初始化与远程连接 首先,我们需要为项目创建 Git 仓库并连接到远程仓库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 git init git add . git commit -m "feat: initial project setup with Vue 3 + TypeScript" git remote add origin https://github.com/your-username/vue3-webshop.git git push -u origin main
1.9.3 依赖安装 现在安装所有自动化工具:
1 pnpm add -D husky lint-staged @commitlint/cli @commitlint/config-conventional
依赖解读 :
包名 核心职责 husky
Git Hooks 工具,让我们能轻松地在 pre-commit
、commit-msg
等 Git 事件上挂载自定义脚本。 lint-staged
一个只对 Git 暂存区 内的文件执行任务的工具,极大地提升了检查效率。 @commitlint/cli
commitlint
的命令行工具,用于校验提交信息。@commitlint/config-conventional
commitlint
的一个预设配置包,它规定了我们必须遵循 “约定式提交” 规范。
1.9.4 配置 Husky
与 lint-staged
(代码检查) 初始化 Husky
:
配置 lint-staged
: 在 package.json
中添加 lint-staged
配置:
1 2 3 4 5 6 7 8 { "lint-staged" : { "*.{js,jsx,ts,tsx,vue}" : [ "eslint --fix --cache" , "prettier --write" ] , "*.{json,md,scss,css}" : [ "prettier --write" ] } }
创建预提交钩子 : 修改 .husky/pre-commit
文件:
工作原理解读 :
lint-staged
只会对 Git 暂存区(git add
后的文件)执行检查,大大提升了性能。对于 JS/TS/Vue 文件,先运行 eslint --fix
自动修复问题,再用 prettier
统一格式。 对于配置文件和文档,只运行 prettier
格式化。 1.9.5 配置 commitlint
(提交信息规范) 约定式提交规范说明 : 我们要求所有提交信息都遵循 type(scope): subject
的格式。这不仅使提交历史清晰可读,更是自动化生成 CHANGELOG
和版本管理的基础。
类型 (type
) 含义 feat
新增功能 fix
修复 Bug docs
文档变更 style
代码风格调整(不影响逻辑) refactor
代码重构 perf
性能优化 test
新增或修改测试 chore
构建流程、辅助工具的变更
创建 commitlint.config.js
: 在项目根目录创建配置文件:
1 2 3 4 export default { extends : ["@commitlint/config-conventional" ], };
创建提交信息钩子 : 创建 .husky/commit-msg
文件:
1 2 npx --no-install commitlint --edit $1
1.9.6 添加便捷脚本 为了方便开发,在 package.json
中添加一些实用的脚本:
1 2 3 4 5 6 7 8 9 10 11 { "scripts" : { "dev" : "vite" , "build" : "vue-tsc -b && vite build" , "preview" : "vite preview" , "lint" : "eslint . --fix" , "lint:check" : "eslint ." , "format" : "prettier --write ." } }
1.9.7 完整测试流程 现在让我们测试整个自动化工作流是否正常:
故意制造一些格式问题 : 在任意 .vue
文件中添加一些不规范的代码(如缺少分号、不一致的引号等)。
测试预提交检查 :
1 2 git add . git commit -m "test commit"
你应该看到 lint-staged
自动运行,修复代码格式问题。
测试提交信息校验 :
执行一次 不符合规范 的提交:git commit -m "test"
。
预期结果 : 终端会提示 subject may not be empty, type may not be empty
等错误,提交被 中止 。执行一次 符合规范 的提交:git commit -m "feat: setup automated commit workflow"
。
预期结果 :lint-staged
首先运行,你会看到 eslint
和 prettier
的输出,格式问题被自动修复。commitlint
校验通过。提交成功!成功标志 : 当你能够成功提交一个符合规范的 commit,并且在过程中看到代码被自动格式化,说明整个自动化提交流程已经完美搭建完成!
模块二:通用布局与首页开发 本模块任务清单 产品经理 Amy 走到了我们的工位前:“项目的第一阶段目标很明确:我们要先搭建起整个应用的‘骨架’——也就是通用的头部和底部,然后集中精力打造一个能立刻吸引用户眼球的首页。” UI 设计师 Leo 紧接着在 Figma 中展示了他的最终设计稿: “这是首页的视觉稿,包含了响应式的导航栏、全屏的轮播图,以及一个非对称布局的人气推荐板块。所有组件的间距、颜色和字体都已经标注好了。” 现在,需求已经明确,设计稿也已就绪。作为前端开发者,我们的任务就是将这些静态的设计稿,转化为一个动态的、数据驱动的、交互丰富的真实网页。
本模块将从零开始,完成整个应用的通用布局框架(导航、头部、底部),并开发功能丰富、数据驱动的电商首页。在本模块中,我们将直接应用 Element Plus
核心组件来高效构建 UI,首次深度实践 Pinia
进行全局状态管理,并使用 TanStack Query
以现代化的方式获取首页业务数据。
任务 2.1: 静态布局骨架搭建 任务 2.2: 静态顶部通栏 (LayoutNav
) 开发 任务 2.3: Pinia 实战 - 动态化顶部通栏 任务 2.4: 静态站点头部 (LayoutHeader
) 开发 任务 2.5: Pinia 实战 - 动态渲染头部导航 任务 2.6: 静态站点底部 (LayoutFooter
) 开发 任务 2.7: TanStack Query 实战 - 首页轮播图 (HomeBanner
) 开发 任务 2.8: 首页-人气推荐 (HomeHotProduct
) 板块开发 2.1 静态布局骨架搭建 一个大型应用的许多页面都共享着相同的外部框架,例如页头、页脚等。我们将这些公共部分抽离成一个 Layout
组件,其他页面作为其子路由嵌套在其中。这遵循了 DRY (Don't Repeat Yourself)
原则,是组件化开发的核心思想。
当前任务 : 2.1 - 静态布局骨架搭建文件路径 : src/views/Layout/index.vue
任务目标 : 利用 Element Plus 的布局容器组件,开发 Layout
主组件,并为顶部导航、头部、内容区和底部规划好挂载点。
2.1.1 设计思路:组件化与语义化 我们在【模块一】的路由配置中,已经将 /
路径指向了 Layout
组件,并将 Home
等页面作为其子路由。现在,我们的任务就是构建 Layout
这个父级容器。
通过分析设计图,我们可以认为 Layout/index.vue
的职责是组合 LayoutNav
、LayoutHeader
、LayoutFooter
和 <RouterView />
。为了让这个组合的结构更加清晰和专业,我们将使用 Element Plus 提供的布局容器组件:
<el-container>
: 外层容器。<el-header>
: 顶部容器,我们将在这里放置 LayoutNav
和 LayoutHeader
。<el-main>
: 主要区域容器,用于包裹 <RouterView />
,这是所有子路由组件将被渲染的地方。<el-footer>
: 底部容器,用于放置 LayoutFooter
。使用这些语义化的标签,能让代码的可读性和可维护性大大增强。
2.1.2 编码实现 首先,我们需要在 src/views/Layout/
目录下创建 index.vue
文件,以及其子组件目录 components/
和其中的三个文件 LayoutNav.vue
, LayoutHeader.vue
, LayoutFooter.vue
。(为保证流程,子组件暂时留空即可)
现在,我们来编写 src/views/Layout/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 <script setup > import LayoutNav from './components/LayoutNav.vue' import LayoutHeader from './components/LayoutHeader.vue' import LayoutFooter from './components/LayoutFooter.vue' </script > <template > <el-container > <el-header > <LayoutNav /> <LayoutHeader /> </el-header > <el-main > <RouterView /> </el-main > <el-footer > <LayoutFooter /> </el-footer > </el-container > </template > <style lang ="scss" scoped > // 为 el-main 设置最小高度,确保页脚在内容不足时也能置底 .el-main { min-height : calc (100vh - 281px ); // 281px 是页头和页脚的大致总高度 } // 移除 el-header 和 el-footer 的默认 padding .el-header ,.el-footer { padding : 0 ; height : auto; } </style >
我们如何能在不修改 App.vue
的情况下,看到我们开发的 LayoutNav
组件?这需要我们理解 Vue Router 的核心工作流:
应用入口 (main.js
) : 我们的应用从 main.js
启动,在这一步,我们 createApp(App)
并 app.use(router)
。这使得整个应用具备了路由能力。根组件 (App.vue
) : App.vue
是所有视图的根容器,它的模板中只有一个核心内容:<RouterView />
。这是一个占位符,告诉 Vue Router:“所有匹配到的路由组件都在这里渲染”。路由配置 (router/index.js
) : 我们的路由表 routes
数组中,配置了 path: '/'
对应的组件是 Layout
组件。布局组件 (Layout/index.vue
) : 当我们访问根路径 /
时,Layout
组件就会被渲染到 App.vue
的 <RouterView />
中。而 Layout
组件内部又包含了 LayoutNav
组件。结论 : main.js
-> App.vue
-> RouterView
-> (URL: '/')
-> Layout.vue
-> LayoutNav.vue
。正是这条清晰的渲染链路,保证了我们接下来开发的每一个 Layout
子组件,都能够 在访问首页时被立刻看到 。
文件路径 : src/app.vue
1 2 3 4 5 6 7 <script setup lang ="ts" > </script > <template > <router-view /> </template >
2.2 静态顶部通栏 (LayoutNav
) 开发 顶部通栏是位于页面最顶部的导航区域,通常包含用户的登录状态、快捷链接等。我们首先来开发它的静态结构和样式,即组件的“骨架”与“皮肤”。
当前任务 : 2.2 - 静态顶部通栏 (LayoutNav
) 开发文件路径 : src/views/Layout/components/LayoutNav.vue
任务目标 : 开发一个纯静态的顶部通栏,包含“登录/注册”和“会员中心/退出登录”两种状态下的链接,并为其编写 SCSS 样式。
2.2.1 设计思路:状态分离 一个健壮的组件应该能够清晰地展示其不同状态下的视图。对于顶部通栏,核心的状态有两个:登录状态 和 未登录状态 。
在这一步,我们先不关心状态如何切换,而是把两种状态下的 DOM 结构都完整地构建出来。我们将使用 <template>
标签来包裹这两种不同的视图,为下一步使用 v-if
/v-else
进行动态切换打下基础。
2.2.2 视图层 (<template>
) 实现 我们来分析并编写 src/views/Layout/components/LayoutNav.vue
的代码。为了在开发阶段能清晰地看到登录后的效果,我们暂时将 v-if
的条件硬编码为 true
,有关于类名,我们严格遵守 BEM 规范
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 <template > <nav class ="app-topnav" > <div class ="container" > <ul class ="app-topnav__list" > <template v-if ="true" > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > <i-ep-user app-topnav__icon > </i-ep-user > 用户</a > </li > <li class ="app-topnav__item" > <el-popconfirm title ="确认退出吗?" @confirm ="handleLogout" confirm-button-text ="确认" cancel-button-text ="取消" > <template #reference > <a href ="javascript:;" class ="app-topnav__link" > 退出登录</a > </template > </el-popconfirm > </li > <li class ="app-topnav__item" > <router-link to ="/member/order" class ="app-topnav__link" > 我的订单</router-link > </li > <li class ="app-topnav__item" > <router-link to ="/member" class ="app-topnav__link" > 会员中心</router-link > </li > </template > <template v-else > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > 请先登录</a > </li > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > 帮助中心</a > </li > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > 关于我们</a > </li > </template > </ul > </div > </nav > </template >
代码解读 :
我们使用了 <ul>
和 <li>
构建了一个标准的导航列表。 <el-popconfirm>
是 Element Plus 提供的气泡确认框组件,我们用它来包裹“退出登录”链接,在用户点击时提供二次确认,这是一个非常好的用户体验实践。#reference
是一个插槽,用于指定触发弹框的元素。2.2.3 样式层 (<style>
) 实现 接下来,我们为这个组件编写 SCSS 样式。这里,我们将首次使用在【模块一】中定义的全局颜色变量。
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 <style lang="scss" scoped> .app-topnav { background : #333 ; &__list { display : flex; height : 53px ; justify-content : flex-end; align-items : center; } &__item { ~ .app-topnav__item { .app-topnav__link { border-left : 2px solid #666 ; } } } &__link { padding : 0 15px ; color : #cdcdcd ; line -height : 1 ; display : inline-block; &:hover { color : $GLColor ; } } &__icon { font-size : 14px ; margin-right : 2px ; } } </style>
代码解读 :
&:hover { color: $GLColor; }
: 注意,这里的 $GLColor
并非 CSS 的原生语法。它之所以能生效,是因为我们在【模块一】的 vite.config.js
中,通过 additionalData
配置,将 src/styles/var.scss
文件自动注入到了每一个 SCSS 文件中。这使得 $GLColor
成为了一个我们可以在项目中任何地方直接使用的全局变量。2.3 Pinia 实战 - 动态化顶部通栏 现在,我们将为静态的顶部通栏注入真正的动态能力。我们将 从零开始,以前后端完整联动的专业视角 ,分步骤创建 可编程的模拟 API 、配置开发代理、建立前端请求层、类型定义和 Pinia Store,最终实现由真实的模拟数据驱动的视图动态切换。
当前任务 : 2.3 - Pinia 实战 - 动态化顶部通栏任务目标 : 搭建一个可处理自定义逻辑的 json-server
,配置 Vite 代理解决跨域问题,并建立一个类型安全的“API -> Store -> Component”数据流,实现完整的动态化和退出登录功能。
2.3.1 搭建专业级模拟后端 简单的 db.json
无法模拟如 POST /login
这样的非 RESTful 接口。为此,我们将 json-server
作为一个 Node.js 模块,在 Express 服务中赋予其无限的扩展能力。
开发者日记
开发中
架构师,我们要模拟登录接口,但 POST /login
并不符合 json-server
默认的 RESTful 规则。这该怎么办?
架构师
问得好。这正是我们要从“声明式配置”走向“编程式扩展”的原因。我们将创建一个 server.cjs
文件,把 json-server
当作一个 Express 中间件来使用。这样,我们就能在 json-server
处理请求之前,用我们自己的代码“拦截”并处理特定路由,比如 /login
。
也就是说,我们可以为 /login
单独写一个处理函数,手动验证用户名密码,然后返回 json-server
数据库里对应用户的数据?
架构师
完全正确!这就是 json-server
的终极用法——将它的便捷性和 Express 的灵活性完美结合。同时,我们还会用 @faker-js/faker
来动态生成更逼真的用户数据。
1. 安装核心依赖
1 pnpm add -D json-server@0.17.4 @faker-js/faker
2. 创建模拟数据生成器 (mock/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 const { faker } = require ("@faker-js/faker" );module .exports = () => { const data = { users : [], }; 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; };
3. 创建可编程的服务器 (mock/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 39 40 41 42 const jsonServer = require ("json-server" );const generateData = require ("./generate-data.cjs" );const server = jsonServer.create ();const router = jsonServer.router (generateData ()); const middlewares = jsonServer.defaults ();server.use (middlewares); server.use (jsonServer.bodyParser ); server.post ("/login" , (req, res ) => { const { account, password } = req.body ; const db = router.db ; const user = db.get ("users" ).find ({ account, password }).value (); if (user) { res.status (200 ).json ({ code : "1" , msg : "登录成功" , result : user, }); } else { res.status (401 ).json ({ code : "0" , msg : "用户名或密码错误" , result : null }); } }); server.use (router); const PORT = 3001 ;server.listen (PORT , () => { console .log (`JSON Server is running on http://localhost:${PORT} ` ); });
4. 更新 package.json
启动脚本
1 2 3 4 5 6 "scripts" : { "dev" : "vite" , "mock" : "node mock/server.cjs" , }
5. 启动模拟服务器 打开 一个新的终端窗口 ,运行 pnpm run mock
。我们的专业级模拟后端现在已经启动。
2.3.2 关键一步:配置 Vite 代理 现在,前端(localhost:5173
)和模拟后端(localhost:3001
)运行在不同的端口上,直接通信会遇到浏览器的 跨域(CORS) 限制。最佳解决方案是在开发环境中使用 Vite 内置的代理功能。
开发者日记
开发中
架构师,我在前端用 Axios 请求 http://localhost:3001/login
,浏览器报了 CORS 错误!
架构师
经典的跨域问题。永远不要在前端代码里写死后端的具体地址和端口。我们应该利用开发服务器的代理功能。
架构师
我们在 vite.config.ts
里配置一个代理规则。比如,让所有以 /api
开头的请求,都由 Vite 服务器自动转发给 http://localhost:3001
。前端请求时只需要写 /api/login
,Vite 会在背后帮你完成“跨域”请求,浏览器对此毫不知情。这样既解决了跨域,也让前端代码更干净,将来部署时也无需修改。
1. 配置 vite.config.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { defineConfig } from 'vite' export default defineConfig ({ server : { proxy : { '/api' : { target : 'http://localhost:3001' , changeOrigin : true , rewrite : (path ) => path.replace (/^\/api/ , '' ), }, }, }, })
代码解读 :
'/api'
: 这是一个标识。告诉 Vite,任何看起来像 http://localhost:5173/api/xxx
的请求都需要被代理。target
: 代理要转发到的真实后端地址。changeOrigin: true
: 这是必选项,它会将请求头中的 Origin
字段修改为 target
的地址,以欺骗后端服务器,解决跨域问题。rewrite
: 前端为了触发代理,请求了 /api/login
,但我们的后端接口实际上是 /login
。rewrite
的作用就是在转发前,把路径中的 /api
前缀去掉。2. 更新 HTTP 请求基地址 为了让所有 API 请求都自动带上 /api
前缀,我们需要配置 axios
实例。
1 2 3 4 5 6 7 8 9 10 11 import axios from 'axios' const httpInstance = axios.create ({ baseURL : '/api' , timeout : 5000 }) export default httpInstance
2.3.3 创建 API、类型与 Store 现在,数据链路的前端部分可以安心地基于 /api
前缀来构建了。
1. 创建类型文件 src/types/user.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 export interface UserInfo { id : string ; account : string ; password ?: string ; accessToken : string ; refreshToken : string ; avatar : string ; nickname : string ; mobile : string ; gender : string ; birthday : string ; cityCode : string ; provinceCode : string ; profession : string ; } export interface LoginForm { account : string ; password : string ; }
2. 创建 API 文件 src/apis/user.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 import httpInstance from "@/utils/http" ;import type { LoginForm , UserInfo } from "@/types/user" ;export const loginApi = (data : LoginForm ): Promise <{ result : UserInfo }> => { return httpInstance ({ url : "/login" , method : "POST" , data, }); };
3. 创建 Store 文件 src/stores/user.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 import { defineStore } from "pinia" ;import { ref } from "vue" ;import { loginApi } from "@/apis/user" ; import type { UserInfo , LoginForm } from "@/types/user" ;export const useUserStore = defineStore ( "user" , () => { const userInfo = ref<UserInfo | object >({}); const getUserInfo = async (form : LoginForm ) => { const res = await loginApi (form); userInfo.value = res.result ; }; const clearUserInfo = ( ) => { userInfo.value = {} as object ; }; return { userInfo, getUserInfo, clearUserInfo, }; }, { persist : true , }, );
2.3.4 组件改造与状态绑定 LayoutNav.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 <script setup lang ="ts" > import { useUserStore } from "@/stores/user" ;import { useRouter } from "vue-router" ;import type { UserInfo } from "@/types/user" ;const userStore = useUserStore ();const router = useRouter ();const handleLogout = ( ) => { userStore.clearUserInfo (); router.push ("/login" ); }; </script > <template > <nav class ="app-topnav" > <div class ="container" > <ul class ="app-topnav__list" > <template v-if ="userStore.isLoggedIn" > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > <i-ep-user app-topnav__icon /> {{ (userStore.userInfo as UserInfo).nickname }}</a > </li > <li class ="app-topnav__item" > <el-popconfirm title ="确认退出吗?" confirm-button-text ="确认" cancel-button-text ="取消" @confirm ="handleLogout" > <template #reference > <a href ="javascript:;" class ="app-topnav__link" > 退出登录</a > </template > </el-popconfirm > </li > <li class ="app-topnav__item" > <router-link to ="/member/order" class ="app-topnav__link" > 我的订单</router-link > </li > <li class ="app-topnav__item" > <router-link to ="/member" class ="app-topnav__link" > 会员中心</router-link > </li > </template > <template v-else > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" @click ="$router.push('/login')" > 请先登录</a > </li > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > 帮助中心</a > </li > <li class ="app-topnav__item" > <a href ="javascript:;" class ="app-topnav__link" > 关于我们</a > </li > </template > </ul > </div > </nav > </template >
2.3.5 即时效果验证 现在,我们拥有了完整且真实的前后端联动链路!
确保 pnpm run dev
和 pnpm run mock
都在运行。
要真正测试登录效果,需要开发登录页面并调用 userStore.login
方法。
但我们可以先用 curl
测试 代理是否生效 :
1 2 curl -X POST -H "Content-Type: application/json" -d "{\"account\": \"user1\", \"password\": \"123456\"}" http://localhost:5173/api/login
如果返回了成功的 JSON 数据,说明你的代理配置完全正确!你的前端应用现在已经具备了和后端无缝通信的能力。
站点头部是用户交互的核心区域。在本次实战中,我们将构建一个 智能的、响应式的导航栏 :它能感知当前所在的页面,在首页时默认透明以展示背景,在其他页面则为常规白色背景。当用户向下滚动时,它能平滑地切换为不透明的吸顶状态,确保导航始终可用。
当前任务 : 2.4 - 响应式站点头部 (LayoutHeader
) 开发任务目标 : 建立可复用的动画样式,采用简洁的 Layout
布局 ,并开发一个能根据 当前路由 和 滚动位置 动态改变样式的 LayoutHeader
组件。
2.4.1 前置步骤:创建可复用的动画工具 (_utilities.scss
) 在开发交互复杂的组件前,最佳实践是 将可复用的 CSS 动画抽象成独立的工具 。
1. 创建 _utilities.scss
文件 在 src/styles/abstracts/
目录下,创建一个新文件 _utilities.scss
。
2. 编写动画工具代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @keyframes slideDown { from { transform : translateY (-100% ); opacity : 0 ; } to { transform : translateY (0 ); opacity : 1 ; } } %slide-down-animation { animation : slideDown 0.3s ease-out forwards; }
3. 技术解读:@mixin
vs @extend
(占位符选择器)
开发者日记
开发中
架构师,我看到这里用了一个 %slide-down-animation
,这是什么语法?它和 @mixin
有什么区别?
架构师
问得好。%
定义的是一个“占位符选择器”,通过 @extend
来使用。它和 @mixin
都是 SCSS 中实现代码复用的方式,但底层原理完全不同,适用于不同场景。
架构师
@mixin
是将代码块 复制 到每一个调用它的地方。如果 10 个类都 @include
同一个 mixin,编译后的 CSS 里就会有 10 份重复的代码。而 @extend
则是将所有使用它的选择器(比如 .class-a
, .class-b
)聚合 到一起,共用一个样式块。最终编译出来的 CSS 可能是 .class-a, .class-b { ... }
,代码 只有一份 。
我明白了。所以对于这种通用的、无参数的动画效果,用 @extend
更高效,因为它能显著减少最终 CSS 文件的体积。
架构师
完全正确。这就是选择 @extend
的核心原因——性能优化和代码优雅。
4. Vite 自动化注入 最后,让这个新的工具文件能被全局自动注入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 css : { preprocessorOptions : { scss : { additionalData : ` @use "@/styles/abstracts/variables" as *; @use "@/styles/abstracts/mixins" as *; @use "@/styles/abstracts/utilities" as *; // 新增这一行 ` , }, }, },
2.4.2 步骤一:搭建组件基础结构 (Template) 我们的第一步是定义组件的 HTML 骨架。在这个阶段,我们只关心“组件里有什么”,比如 Logo、导航列表和功能按钮,并使用 v-for
配合一个 临时的静态数据 来渲染导航项。
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 <template > <header :class ="{ 'app-header': true, 'app-header-sticky': y > 100 }" > <div class ="container" > <div class ="app-header__logo" > <RouterLink to ="/" > <img src ="@/assets/images/logo.png" alt ="格力专卖店" class ="app-header__logo-img" > </RouterLink > </div > <ul class ="app-header__nav" > <li class ="app-header__nav-item" v-for ="item in navigatorList" :key ="item.text" > <RouterLink :to ="item.to" class ="app-header__nav-link" > {{ item.text }} </RouterLink > </li > </ul > <div class ="app-header__actions" > <div class ="app-header__search" > <i-ep-search /> <button class ="app-header__search-btn" > 查询</button > </div > <div class ="app-header__lang" > <a href ="javascript:;" class ="app-header__lang-link" > EN</a > </div > <LayoutCart /> </div > </div > </header > </template >
代码解读 :
关注点 : 我们只定义了 HTML 结构,使用了 div
, ul
, li
, RouterLink
等标签。静态数据驱动 : <li v-for="item in navigatorList" ...>
表明导航列表是由一个名为 navigatorList
的数组驱动的。这个数组我们将在步骤三的 <script>
部分定义。为交互预留接口 : <header :class="{...}">
已经为后续的动态样式切换做好了准备。2.4.3 步骤二:添加组件样式 (Style) 结构完成后,我们用 SCSS 来美化组件,定义它的布局、颜色和外观。
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 <style lang="scss" scoped> .app-header { background : #fff ; height : 70px ; box-shadow : 0px 0px 3px 0px rgba (0 , 0 , 0 , 0.1 ); .container { @include flex-center; margin-left : 40px ; justify-content : space-between; height : 70px ; } } .app-header-sticky { position : fixed; top : 0 ; left : 0 ; width : 100% ; z-index : 999 ; @extend %slide-down-animation; } .app-header__logo { width : 160px ; a { display : block; height : 40px ; width : 100% ; } &-img { height : 40px ; width : auto; max-width : 100% ; object -fit: contain; } } .app-header__nav { display : flex; align-items : center; justify-content : flex-start; position : relative; z-index : 998 ; flex : 1 ; padding-left : 40px ; &-item { margin-right : 0 ; position : relative; &:not (:last-child) { border-right : 1px solid $borderColor ; margin-right : 32px ; padding-right : 32px ; } } &-link { font-size : 1.6rem ; line -height : 3.2rem ; height : 3.2rem ; padding : 0.8rem 1.2rem ; display : inline-block; color : $textColor-secondary ; text -decoration: none; transition : all $transition-duration ease; @include truncate-text; position : relative; &:hover { color : $GLColor ; background-color : $bgColor ; } &.router-link-exact-active { color : $GLColor ; font-weight : 500 ; } } } .app-header__actions { display : flex; align-items : center; gap : 20px ; } .app-header__search { &-btn { font-size : 1.6rem ; color : $textColor-secondary ; background : none; border : none; padding : 0.8rem 1.2rem ; cursor : pointer; transition : color $transition-duration ease; &:hover { color : $GLColor ; } } } .app-header__lang { &-link { font-size : 1.6rem ; color : $textColor-secondary ; text -decoration: none; padding : 0.8rem 1.2rem ; transition : color $transition-duration ease; &:hover { color : #004098 ; } } } </style>
代码解读 :
默认与吸顶分离 : 我们定义了 .app-header
的默认样式,以及一个独立的 .app-header-sticky
类来专门处理吸顶后的样式。这种分离使得逻辑非常清晰。Flexbox 布局 : 再次使用 Flexbox 来高效地实现横向排列和对齐。动画占位符 : @extend %slide-down-animation;
应用了我们在 _utilities.scss
中定义的动画,当 .app-header-sticky
类被激活时,这个动画就会播放。2.4.4 步骤三:定义静态数据与实现吸顶交互 (Script) 最后,我们编写 <script>
部分。在这里,我们将 定义临时的导航数据 ,并 引入 @vueuse/core
来实现吸顶的动态效果 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <script setup lang="ts" > import LayoutCart from './LayoutCart.vue' import { useScroll } from '@vueuse/core' const navigatorList = [ { text : '首页' , to : '/' }, { text : '家用空调' , to : '/category/1' }, { text : '中央空调' , to : '/category/2' }, { text : '生活家电' , to : '/category/3' }, { text : '冰箱' , to : '/category/4' }, { text : '洗衣机' , to : '/category/5' }, ] const { y } = useScroll (window )</script>
代码解读与交互连接 :
定义虚拟数据 (navigatorList
) : 我们在 <script>
内部创建了一个名为 navigatorList
的常量数组。模板中的 v-for
会遍历这个数组,从而将导航链接渲染到页面上。这完美地模拟了有数据时的情景,同时又将数据获取的复杂性留到了后续章节。
实现吸顶逻辑 (useScroll
) :
我们从 @vueuse/core
库中导入 useScroll
函数。 const { y } = useScroll(window)
会创建一个响应式变量 y
,它实时反映了页面垂直滚动的距离。联动效应 : 这个 y
变量就是连接 <script>
逻辑和 <template>
样式的桥梁。回到模板中的 :class="{ 'app-header-sticky': y > 100 }"
。 当页面在顶部时,y
是 0
,y > 100
为 false
,所以 app-header-sticky
类 不 会被添加。 当用户向下滚动,y
的值超过 100
时,y > 100
变为 true
,Vue 会自动为 <header>
元素 添加 app-header-sticky
类。 这个类的添加会触发我们在步骤二中写好的 position: fixed
等样式,从而实现吸顶效果,并播放滑入动画。 通过这三个步骤,我们清晰地分离了结构、样式和行为,首先用静态数据构建了一个完整的、外观正确的组件,然后无缝地为其增加了核心的吸顶交互功能,完全符合当前笔记章节的目标。
2.5 Pinia 实战 - 动态导航与本地化静态资源 静态的占位导航无法满足我们电商项目的需求。现在,我们将 以前后端完整联动的专业视角 ,为头部导航注入动态数据。我们将分步升级模拟后端,使其能够提供一份 可控的静态 JSON 数据 并 托管本地图片资源 。随后,我们将创建前端的 API 层、类型定义和 Pinia Store,最终实现导航数据的动态渲染。
当前任务 : 2.5 - Pinia 实战 - 动态导航与本地化静态资源任务目标 : 搭建一个能同时提供 API 和静态文件服务的 json-server
,并建立一个类型安全的“API -> Store -> Component”数据流,用真实的、图片本地化的模拟数据替换静态导航。
第一步:升级模拟后端 (Mock Server) 为了让开发环境完全自给自足,摆脱对外部链接的依赖,我们需要对 json-server
进行两项关键升级:1. 提供固定的、来自 JSON 文件的分类数据。 2. 兼任静态文件服务器,托管导航所需的本地图片。
1. 准备静态资源 (数据与图片)
首先,在 mock/
目录下创建 mock-data.json
文件,用于存放导航分类的静态数据。
点击查看 mock-data.json 内容 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 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 { "categories" : [ { "id" : "new" , "name" : "新品" , "icon" : "/src/assets/icons/icon-New-product.png" , "products" : [ { "id" : 1 , "name" : "自然又自在" , "desc" : "格力·至尊 家居生活新中心" , "picture" : "/images/new/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "循环风扇" , "desc" : "臻品工艺 拂动盛夏" , "picture" : "/images/new/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "空气净化器" , "desc" : "森林级空气管家" , "picture" : "/images/new/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "晶弘魔法冰箱" , "desc" : "鲜嫩两星期 轻触一刀切" , "picture" : "/images/new/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "热泵洗衣机" , "desc" : "37℃烘干不伤衣" , "picture" : "/images/new/product5.jpg" , "type" : "tall" } ] } , { "id" : "home-air-conditioner" , "name" : "家用空调" , "icon" : "/src/assets/icons/icon_Air-Conditioner-02@2x.png" , "products" : [ { "id" : 1 , "name" : "家居的美学绅士" , "desc" : "格力雨索系列,时光淬炼" , "picture" : "/images/home-ac/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "格力·金眠空调" , "desc" : "静享好眠 美梦甜甜" , "picture" : "/images/home-ac/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "格力·高温王空调" , "desc" : "挑战65℃酷暑制冷不衰减" , "picture" : "/images/home-ac/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "格力艺术空调" , "desc" : "科技美学 风华绝代" , "picture" : "/images/home-ac/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "格力新风空调" , "desc" : "双向新风 恒氧新居" , "picture" : "/images/home-ac/product5.jpg" , "type" : "tall" } ] } , { "id" : "central-air-conditioner" , "name" : "中央空调" , "icon" : "/src/assets/icons/icon_Home-central-air-conditioning-02@2x.png" , "products" : [ { "id" : 1 , "name" : "用电省一半" , "desc" : "格力智睿新一代家庭中央空调" , "picture" : "/images/central-ac/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "厨享" , "desc" : "不沾油烟的空调" , "picture" : "/images/central-ac/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "寐享" , "desc" : "地毯式制热,淋浴式制冷" , "picture" : "/images/central-ac/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "铂韵" , "desc" : "低温制热温暖,高温制冷舒适" , "picture" : "/images/central-ac/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "舒睿" , "desc" : "低温制热温暖,高温制冷舒爽" , "picture" : "/images/central-ac/product5.jpg" , "type" : "tall" } ] } , { "id" : "home-appliances" , "name" : "生活电器" , "icon" : "/src/assets/icons/icon_home-devices-02@2x.png" , "products" : [ { "id" : 1 , "name" : "净云星抽油烟机" , "desc" : "内腔6年免清洗" , "picture" : "/images/appliances/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "循环扇" , "desc" : "循环鲜风 全屋瞬爽" , "picture" : "/images/appliances/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "百香煲" , "desc" : "地道柴火饭,香郁好滋味" , "picture" : "/images/appliances/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "嵌入式洗碗机" , "desc" : "双效烘干,洁净一体" , "picture" : "/images/appliances/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "净化器" , "desc" : "高效净化 畅享鲜氧" , "picture" : "/images/appliances/product5.jpg" , "type" : "tall" } ] } , { "id" : "refrigerator" , "name" : "冰箱" , "icon" : "/src/assets/icons/icon_refrigerator-02@2x.png" , "products" : [ { "id" : 1 , "name" : "晶弘魔法冰箱" , "desc" : "鲜嫩两星期,轻触一刀切" , "picture" : "/images/refrigerator/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "十字养鲜系列" , "desc" : "长效净味 干湿分储" , "picture" : "/images/refrigerator/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "无霜保鲜系列" , "desc" : "无霜保鲜 鲜活原味" , "picture" : "/images/refrigerator/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "海蕴藏鲜系列" , "desc" : "微晶-5℃,广域广净广鲜" , "picture" : "/images/refrigerator/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "独立储鲜系列" , "desc" : "抽屉专储 原味保鲜" , "picture" : "/images/refrigerator/product5.jpg" , "type" : "tall" } ] } , { "id" : "washing-machine" , "name" : "洗衣机" , "icon" : "/src/assets/icons/icon_washing-machine-02@2x.png" , "products" : [ { "id" : 1 , "name" : "格力净护洗衣机" , "desc" : "洗衣 我想净静" , "picture" : "/images/washer/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "共享洗衣机" , "desc" : "扫码可用 洗烘生香" , "picture" : "/images/washer/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "净静洗衣机" , "desc" : "净享洁净 静享生活" , "picture" : "/images/washer/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "净柔洗衣机" , "desc" : "健康活水 柔护衣物" , "picture" : "/images/washer/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "热泵洗衣机" , "desc" : "37℃烘干不伤衣" , "picture" : "/images/washer/product5.jpg" , "type" : "tall" } ] } , { "id" : "water-heater" , "name" : "热水器" , "icon" : "/src/assets/icons/icon_Water-heater-02@2x.png" , "products" : [ { "id" : 1 , "name" : "24小时不间断热水供应" , "desc" : "沐鑫空气能热水器" , "picture" : "/images/heater/product1.jpg" , "type" : "large" } , { "id" : 2 , "name" : "安沐星" , "desc" : "安全沐浴守护星" , "picture" : "/images/heater/product2.jpg" , "type" : "normal" } , { "id" : 3 , "name" : "舒铂热水器" , "desc" : "全能速热,舒心浴上" , "picture" : "/images/heater/product3.jpg" , "type" : "normal" } , { "id" : 4 , "name" : "舒沐享燃气热水器" , "desc" : "四季舒享 恒温沐浴" , "picture" : "/images/heater/product4.jpg" , "type" : "wide" } , { "id" : 5 , "name" : "水之沁" , "desc" : "高效节能,多重防护" , "picture" : "/images/heater/product5.jpg" , "type" : "tall" } ] } ] }
接下来,在项目根目录的 public/
文件夹下,创建 images
目录及相应的子目录(如 new
, home-ac
等),并将所有商品图片按 mock-data.json
中 picture
字段指定的路径存放。
2. 配置 json-server
托管静态文件
打开 package.json
,为 mock
启动脚本添加 --static
标志,指定 public
目录为静态资源根目录。
1 2 3 4 5 6 7 8 9 { "scripts" : { "dev" : "vite" , "mock" : "node mock/server.cjs --port 3001 --static ./public" } }
代码解读 :
--static ./public
: 此参数告知 json-server
,将 ./public
目录作为静态文件服务的根目录。现在,当浏览器请求 http://localhost:3001/images/new/product1.jpg
时,服务器会直接返回 public/images/new/product1.jpg
这个文件。3. 从文件加载静态数据
修改 mock/generate-data.cjs
,使其不再使用 Faker 生成分类数据,而是从我们刚刚创建的 mock-data.json
文件中读取。
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 const { faker } = require ("@faker-js/faker" );const fs = require ("fs" );const path = require ("path" );module .exports = () => { const staticDataPath = path.join (__dirname, "mock-data.json" ); const staticData = JSON .parse (fs.readFileSync (staticDataPath, "utf-8" )); const data = { users : [], categories : staticData.categories , }; for (let i = 1 ; i <= 20 ; i++) { data.users .push ({ id : i, name : faker.person .fullName (), email : faker.internet .email (), }); } return data; };
代码解读 :
我们引入了 Node.js 的 fs
和 path
模块来处理文件读写和路径。 fs.readFileSync
同步读取 mock-data.json
的内容。JSON.parse
将文件内容从字符串解析为 JavaScript 对象。最终,返回数据中的 categories
字段被替换为来自文件的静态数据,实现了数据的可控性。 4. 自定义 API 路由 为了让后端接口更符合企业级开发规范(例如,返回带有状态码和消息的统一结构),我们可以打开 mock/server.cjs
文件,在 server.use(router)
之前添加一个自定义路由。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 server.get ("/categories" , (req, res ) => { const db = router.db ; const categories = db.get ("categories" ).value (); res.status (200 ).json ({ code : "200" , msg : "操作成功" , result : categories, }); });
第二步:构建前端数据流 (API -> Store -> Component) 后端准备就绪后,我们开始搭建前端的“数据管道”。
1. 定义 TypeScript 类型 根据 mock-data.json
的数据结构,在 src/types/
目录下创建 category.ts
文件,定义精确的类型接口。这能为我们提供强大的代码提示和类型安全。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export interface Product { id : string ; name : string ; desc : string ; picture : string ; type : "large" | "normal" | "wide" | "tall" ; } export interface CategoryItem { id : string ; name : string ; icon : string ; products : Product []; children ?: CategoryItem []; }
2. 创建 API 请求函数 在 src/apis/
目录下创建 layout.ts
,用于统一管理布局相关的 API 请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import httpInstance from "@/utils/http" ;import type { CategoryItem } from "@/types/category" ;interface ApiResponse { code : string ; msg : string ; result : CategoryItem []; } export const getCategoryAPI = (): Promise <ApiResponse > => { return httpInstance ({ url : "/categories" , }); };
3. 创建 Pinia Store 在 src/stores/
目录下创建 categoryStore.ts
,用于获取并存储全局共享的导航分类数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { defineStore } from "pinia" ;import { ref } from "vue" ;import { getCategoryAPI } from "@/apis/layout" ;import type { CategoryItem } from "@/types/category" ;export const useCategoryStore = defineStore ("category" , () => { const categoryList = ref<CategoryItem []>([]); const getCategory = async ( ) => { const res = await getCategoryAPI (); categoryList.value = res.result ; }; return { categoryList, getCategory, }; });
第三步:组件改造与动态渲染 万事俱备,现在我们改造 LayoutHeader.vue
组件,让它从 Pinia Store 中获取数据并动态渲染导航。
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 <script setup lang ="ts" > import { useScroll } from '@vueuse/core' import { useCategoryStore } from '@/stores/categoryStore' import { onMounted } from 'vue' const categoryStore = useCategoryStore ()const { categoryList } = storeToRefs (categoryStore)onMounted (() => { categoryStore.getCategory () }) </script > <template > <ul class ="app-header__nav" > <li class ="app-header__nav-item" v-for ="item in categoryList" :key ="item.id" > <RouterLink :to ="`/category/${item.id}`" class ="app-header__nav-link" > {{ item.name }} </RouterLink > </li > </ul > </template >
第四步:端到端验证 启动服务 : 确保终端中 pnpm run dev
(前端) 和 pnpm run mock
(后端) 两个命令都在运行。验证 API : 在浏览器中访问 http://localhost:3001/categories
。您应该能看到 mock-data.json
中的内容被一个包含 code
, msg
, result
的对象包裹着返回。验证静态资源 : 复制 mock-data.json
中任一 picture
路径 (例如 /images/new/product1.jpg
),然后在浏览器中访问 http://localhost:3001/images/new/product1.jpg
,确认能看到对应的图片。验证前端渲染 : 刷新或打开 http://localhost:5173/
页面。此时,您的头部导航栏应该已经不再是静态文字,而是被 mock-data.json
中的 name
字段动态渲染出来了。站点底部是应用信息架构的重要组成部分。在这一节,我们将构建一个结构清晰、样式简洁的静态页脚,专注于基础的 HTML 结构和精准的 SCSS 样式控制。
当前任务 : 2.6 - 站点底部 (LayoutFooter
) 组件化开发文件路径 : src/views/Layout/components/LayoutFooter.vue
任务目标 : 开发一个干净、经典的静态页脚,重点掌握 BEM 命名规范和 SCSS 的 &:not()
选择器技巧。
2.6.1 数据层 (<script>
): 定义链接内容 尽管我们的页脚很简单,但遵循“数据与视图分离”的原则总是一个好习惯。我们将页脚需要展示的链接定义在一个数组中,这样未来修改链接时会非常方便。
1 2 3 4 5 6 7 8 9 10 11 <script setup lang ="ts" > const footerLinks = [ { text : '关于我们' , href : '#' }, { text : '帮助中心' , href : '#' }, { text : '售后服务' , href : '#' }, { text : '配送与验收' , href : '#' }, { text : '商务合作' , href : '#' }, { text : '搜索推荐' , href : '#' }, ] </script >
代码解读 :
我们创建了一个 footerLinks
数组来统一管理页脚的导航链接。 这样做的好处是,当需要增删或修改链接时,我们只需要操作这个数组,而无需改动下面的模板 (<template>
) 代码,使维护变得简单。 2.6.2 视图层 (<template>
): 搭建基本结构 接下来,我们编写模板。这里将使用标准的 HTML 标签,并通过 v-for
指令将我们定义好的数据动态渲染出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <template > <footer class ="app-footer" > <div class ="container" > <div class ="app-footer__content" > <p class ="app-footer__links" > <a v-for ="(link, index) in footerLinks" :key ="index" :href ="link.href" class ="app-footer__link" > {{ link.text }} </a > </p > <p class ="app-footer__copyright-text" > CopyRight © 格力商城</p > </div > </div > </footer > </template >
2.6.3 样式层 (<style>
): 添加 SCSS 样式 最后,我们为页脚添加样式。这里的重点是使用 SCSS 的嵌套语法和 :not()
选择器来实现链接之间的分隔线效果。
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 <style scoped lang='scss'> .app-footer { background-color : #333 ; .app-footer__content { height : 170px ; padding-top : 40px ; text -align: center; color : #999 ; font-size : 15px ; .app-footer__links { margin-bottom : 20px ; } .app-footer__copyright-text { margin-bottom : 20px ; } .app-footer__link { color : #999 ; padding : 0 10px ; text -decoration: none; &:not (:first-child) { border-left : 1px solid #999 ; } } } } </style>
代码解读 :
分隔线技巧 : &:not(:first-child)
是一个非常实用的伪类选择器。&
指代的是当前选择器,也就是 .footer-link
。:not(:first-child)
的意思是“选择所有不是其父元素的第一个子元素的 .footer-link
”。组合起来,就实现了为第二个、第三个…直到最后一个链接都添加 border-left
,而第一个链接则不受影响,从而完美地创建了链接之间的分隔线。 2.7 TanStack Query 实战 - 首页轮播图 (HomeBanner
) 开发 首页轮播图是吸引用户眼球、转化流量的核心入口。在本节中,我们将首次引入强大的异步状态管理库——TanStack Query ,来取代传统的 onMounted
+ ref
数据获取模式,并结合 Element Plus 组件,开发一个功能完整、体验优雅的轮播图。
当前任务 : 2.7 - TanStack Query 实战 - 首页轮播图 (HomeBanner
) 开发任务目标 : 扩展模拟后端以支持轮播图 API,使用 TanStack Query
(useQuery
) 获取数据,并用 ElCarousel
和 ElSkeleton
构建一个带加载占位效果的动态轮播图组件。
2.7.1 升级模拟后端:支持轮播图 API 与上一节类似,我们的第一步是让模拟后端具备提供轮播图数据的能力。
1. 准备静态资源 (数据与图片) 首先,在 mock/
目录下打开 mock-data.json
文件,在其中新增一个 banners
数组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 banners: [ { id: "banner-001" , imgUrl: "/images/carousel/carousel1.jpg" , hrefUrl: "/category/cat-001" , } , { id: "banner-002" , imgUrl: "/images/carousel/carousel2.jpg" , hrefUrl: "/category/cat-002" , } , { id: "banner-003" , imgUrl: "/images/carousel/carousel3.jpg" , hrefUrl: "/category/cat-003" , } , ] ,
请确保 您已在 public/images/
目录下创建了 carousel
文件夹,并放入了 carousel1.jpg
到 carousel4.jpg
四张图片。
2. 在 server.cjs
中添加自定义路由 打开 mock/server.cjs
,为 /home/banner
这个 API 端点添加一个自定义的 GET 路由。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 server.get ("/home/category/head" , (req, res ) => { }); 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.use (router);
3. 重启并验证 Mock 服务
停止 (Ctrl+C
) 并重新运行 pnpm run mock
。 在浏览器中访问 http://localhost:3001/home/banner
。 预期效果 : 你应该能看到包含 4 个轮播图对象的 result
数组,并且其中的 imgUrl
都是我们本地的路径。2.7.2 创建前端数据流 (API -> Type -> Component) 开发者日记
开发中
架构师,后端接口准备好了。按照之前的经验,我是不是要去 onMounted
里调用 API,然后用一个 ref
来存数据?
架构师
这是一种可行的方式,但也是我们今天要“革命”的传统模式。我们将引入 TanStack Query
。
架构师
好处是颠覆性的。你不再需要手动管理 isLoading
, error
这些状态,TanStack Query
会自动为你提供。它还会自动缓存数据,当组件再次挂载时,会立即从缓存中显示旧数据,同时在后台“静默”地请求新数据,用户体验极佳。
架构师
核心就是 useQuery
这个 hook。你只需要给它两样东西:一个唯一的“查询键”(queryKey
),用来标识这份数据;一个“查询函数”(queryFn
),也就是我们即将封装的 getBannerApi
。剩下的所有事情,TanStack Query
都会帮你优雅地处理好。
1. 创建类型文件 src/types/home.ts
1 2 3 4 5 6 7 8 export interface BannerItem { id : string ; imgUrl : string ; hrefUrl : string ; }
2. 创建 API 文件 src/apis/home.ts
1 2 3 4 5 6 7 8 9 import httpInstance from "@/utils/http" ;import type { BannerItem } from "@/types/home" ;export const getBannerApi = (): Promise <{ result : BannerItem [] }> => { return httpInstance ({ url : "/home/banner" , }); };
3. 开发 HomeBanner
组件 现在,我们创建 src/views/Home/components/HomeBanner.vue
,并在这里实战 useQuery
。
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 <script setup lang ="ts" > import { useQuery } from '@tanstack/vue-query' import { getBannerApi } from '@/apis/home' const { data : bannerList, isLoading } = useQuery ({ queryKey : ['homeBanner' ], queryFn : async () => { const res = await getBannerApi () return res.result } }) </script > <template > <div class ="home-banner" > <el-skeleton style ="width: 100%; height: 500px" :loading ="isLoading" animated > <template #template > <el-skeleton-item variant ="image" style ="width: 100%; height: 100%;" /> </template > <template #default > <el-carousel height ="500px" > <el-carousel-item v-for ="item in bannerList" :key ="item.id" > <img :src ="item.imgUrl" :alt ="item.id" > </el-carousel-item > </el-carousel > </template > </el-skeleton > </div > </template > <style scoped lang ='scss' > .home-banner { width : 100% ; max-width : none; height : 500px ; margin : 0 ; img { width : 100% ; height : 100% ; object-fit : cover; // 确保图片完全填充容器,保持比例 object-position : center; // 居中显示 display : block; // 消除图片底部的默认间距 } } </style >
代码解读 :
useQuery({ queryKey: ['homeBanner'], queryFn: ... })
: 我们调用 useQuery
,并解构出 data
(我们重命名为 bannerList
) 和 isLoading
。ElSkeleton
: 我们使用了 Element Plus 的骨架屏组件,并将其 loading
属性与 useQuery
返回的 isLoading
状态绑定。#template
和 #default
插槽: 这是 ElSkeleton
的用法,#template
定义了加载时骨架屏的样式,#default
定义了加载完成后要显示的内容。2.7.3 集成到首页 最后,在 src/views/Home/index.vue
中引入并使用我们刚刚创建的 HomeBanner
组件。
1 2 3 4 5 6 7 8 9 10 <script setup lang ="ts" > import HomeBanner from './components/HomeBanner.vue' </script > <template > <HomeBanner /> </template > <style scoped lang ="scss" > </style >
2.8 首页-人气推荐 (HomeHotProduct
) 板块开发 2.8.1 第一步:构建全局原子组件 (ProductCard.vue
) 本节目标 : 我们将从零开始,采用“视觉优先”的开发流程,完整地构建一个全局通用的 ProductCard.vue
组件。我们将先实现其静态视觉效果,然后通过重构优化样式,最后为其添加 Props
和 Emits
使其成为一个可复用的动态组件。
1. 搭建静态模板与初始样式 我们的第一步是创建一个视觉上完整的静态组件,暂时不考虑数据复用和逻辑。
文件路径 : src/components/ProductCard.vue
任务目标 : 创建组件文件,并编写包含硬编码内容的模板和足以实现完整视觉效果(包括悬停动画)的初始 SCSS 样式。
1.1 模板 (<template>
)
请在 src/components/
目录下创建 ProductCard.vue
文件,并写入以下模板代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <template > <div class ="product-card" > <div class ="product-card__image-wrapper" > <img src ="/images/new/product2.jpg" alt ="循环风扇" class ="product-card__image" /> <div class ="product-card__mask" > <h3 class ="product-card__mask-title" > 循环风扇 <span class ="product-card__mask-desc" > 臻品工艺 拂动盛夏</span > </h3 > <button class ="product-card__btn" > 了解更多</button > </div > </div > <div class ="product-card__info" > <h3 class ="product-card__name" > 循环风扇 <span class ="product-card__desc" > 臻品工艺 拂动盛夏</span > </h3 > </div > </div > </template >
1.2 初始样式 (<style>
)
接下来,我们编写实现设计效果所需的所有 SCSS 代码。请注意,此时代码中会存在一些重复。
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 <style lang="scss" scoped> .product-card { position : relative; width : 100% ; height : 100% ; overflow : hidden; cursor : pointer; &:hover { .product-card__image { transform : scale (1.1 ); } .product-card__mask { opacity : 1 ; } .product-card__mask-title , .product-card__btn { opacity : 1 ; transform : translateY (0 ); } .product-card__info { opacity : 0 ; } } } .product-card__image-wrapper { position : relative; width : 100% ; height : 100% ; overflow : hidden; } .product-card__image { width : 100% ; height : 100% ; object -fit: cover; transition : transform 1.5s ease; } .product-card__mask { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; background : rgba (0 , 0 , 0 , 0.5 ); display : flex; flex-direction : column; justify-content : center; align-items : center; opacity : 0 ; transition : all 0.4s ease-in-out; } .product-card__mask-title { font-size : 24px ; font-weight : 400 ; color : #fff ; text -align: center; margin : 0 ; transform : translateY (-100px ); transition : all 0.2s ease-in-out; } .product-card__mask-desc { display : block; font-size : 14px ; font-weight : 400 ; color : #fff ; line -height : 20px ; margin-top : 8px ; } .product-card__btn { padding : 0 20px ; height : 36px ; line -height : 36px ; color : #fff ; font-size : 14px ; border : 1px solid #fff ; border-radius : 36px ; background : transparent; cursor : pointer; opacity : 0 ; transform : translateY (100px ); transition : all 0.2s ease-in-out 0.1s ; &:hover { background : rgba (255 , 255 , 255 , 0.1 ); } } .product-card__info { position : absolute; bottom : 0 ; left : 0 ; width : 100% ; padding : 20px ; background : linear-gradient (transparent, rgba (0 , 0 , 0 , 0.6 )); transition : opacity 0.3s ease-in-out; } .product-card__name { font-size : 24px ; font-weight : 400 ; color : #111 ; text -align: center; margin : 0 ; } .product-card__desc { display : block; font-size : 14px ; font-weight : 400 ; color : #111 ; line -height : 20px ; margin-top : 8px ; } </style>
2. 样式重构:提炼 Mixin 在完成初步视觉实现后,我们审查代码,发现 .product-card__mask
的样式定义和 .product-card__mask-title
/ .product-card__name
的文字样式存在明显重复。为提高代码质量和可维护性,我们将其提取为 @mixin
。
2.1 创建 Mixin
请打开 src/styles/abstracts/_mixins.scss
文件,并添加以下两个 mixin。
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 @mixin product-mask { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; background : rgba (0 , 0 , 0 , 0.5 ); display : flex; flex-direction : column; justify-content : center; align-items : center; opacity : 0 ; transition : all 0.4s ease-in-out; } @mixin product-text-style($color : #111 ) { font-size : 24px ; font-weight : 400 ; color : $color ; text -align: center; margin : 0 ; }
2.2 应用 Mixin
回到 ProductCard.vue
,我们用 @include
替换掉之前重复的样式代码,我们之前已经全局引入了 Mixin,所以无需再引入
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 <style lang="scss" scoped> .product-card { } .product-card__mask { @include product-mask; } .product-card__mask-title { @include product-text-style(#fff ); transform : translateY (-100px ); transition : all 0.2s ease-in-out; } .product-card__info { } .product-card__name { @include product-text-style; } .product-card__desc { } </style>
现在,我们的样式代码更加简洁和可维护。
3. 组件化改造:添加 Props 与 Emits 当前组件是静态的。为了让它可以被复用并显示不同商品的数据,我们需要为其定义 props
。同时,为了让父组件能响应卡片上的用户操作,我们需要定义 emits
。
3.1 添加 <script setup>
逻辑
在 ProductCard.vue
文件中,添加完整的 <script 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 <script setup lang ="ts" > import { Product } from '@/types/category' interface Props { product : Product index : number showMask?: boolean showInfo?: boolean } interface Emits { (e : 'click' , product : Product ): void (e : 'hover' , product : Product ): void (e : 'button-click' , product : Product ): void } const props = withDefaults (defineProps<Props >(), { showMask : true , showInfo : true }) const emit = defineEmits<Emits >()const handleClick = ( ) => { emit ('click' , props.product ) } const handleHover = ( ) => { emit ('hover' , props.product ) } const handleButtonClick = (event: Event ) => { event.stopPropagation () emit ('button-click' , props.product ) } </script >
3.2 更新 <template>
最后,我们将模板中的硬编码内容替换为从 props
中获取的动态数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <template > <div class ="product-card" @click ="handleClick" > <div class ="product-card__image-wrapper" > <img :src ="product.picture" :alt ="product.name" class ="product-card__image" /> <div class ="product-card__mask" :class ="{ 'product-card__mask--no-text': index === 0 }" > <h3 v-if ="index !== 0" class ="product-card__mask-title" > {{ product.name }} <span class ="product-card__mask-desc" > {{ product.desc }}</span > </h3 > <button class ="product-card__btn" @click ="handleButtonClick" > 了解更多</button > </div > </div > <div v-if ="index !== 0" class ="product-card__info" > <h3 class ="product-card__name" > {{ product.name }} <span class ="product-card__desc" > {{ product.desc }}</span > </h3 > </div > </div > </template >
本节小结 : 我们遵循“先视觉,后重构,再逻辑”的自然开发流程,成功地从零构建了一个视觉精美、代码健壮、高度可复用的 ProductCard.vue
全局组件。它现在已经准备好被用作我们应用中的基础“零件”。
2.8.2 搭建人气推荐主体框架 (HomeHotProduct.vue
) 本节目标 : 我们将从零创建 HomeHotProduct.vue
组件并将其集成到首页,以获得即时视觉反馈。您将学习如何识别并提取可复用的子组件(如标题区),并深入掌握如何应用 Element Plus
的 ElTabs
组件,通过 插槽 和 :deep()
选择器 ,将其深度定制成符合我们设计稿的专业导航样式。
1. 创建主组件“画布”并集成到首页 一个高效的开发流程始于快速建立一个可以看到成果的“画布”。因此,我们的第一步不是埋头于子组件的细节,而是先创建主组件文件,并立即在首页中引用它。
1.1 创建 HomeHotProduct.vue
文件 请在 src/views/Home/components/
目录下创建 HomeHotProduct.vue
文件,并填入一个简单的占位内容。
1 2 3 4 5 <template > <div class ="hot-product" > 人气推荐模块 </div > </template >
1.2 在首页 (Home/index.vue
) 中引用 现在,打开 src/views/Home/index.vue
,引入并使用我们刚刚创建的组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <script setup lang ="ts" > import HomeBanner from './components/HomeBanner.vue' import HomeHotProduct from './components/HomeHotProduct.vue' </script > <template > <div class ="container" > <HomeBanner /> </div > <HomeHotProduct /> </template > <style lang ="scss" scoped > .container { width : 100% ; max-width : none; } </style >
现在启动项目 (pnpm run dev
),您应该能在轮播图下方看到“人气推荐模块”这几个字。这个即时的反馈回路,正是我们高效开发的基础。
2. 提取头部为独立组件 观察 HomeHotProduct.vue
的设计稿,我们能立刻识别出“热销产品”和“核心科技 品质精选”这部分在视觉上是一个独立的整体。为了保持 HomeHotProduct.vue
的整洁,我们将其提取为一个本地的、纯展示组件。
2.1 创建 HotProductHeader.vue
在 src/views/Home/components/components/
目录下创建 HotProductHeader.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 <script setup lang ="ts" > defineProps<{ title: string, slogan: string }>() </script > <template > <div class ="header" > <h2 class ="title" > {{ title }}</h2 > <p class ="slogan" > {{ slogan }}</p > </div > </template > <style lang ="scss" scoped > .header { text-align : center; margin-bottom : 40px ; } .title { font-size : 48px ; font-weight : 600 ; color : #111 ; line-height : 67px ; margin-bottom : 10px ; } .slogan { font-size : 32px ; font-weight : 400 ; color : #666 ; line-height : 45px ; margin : 0 ; } </style >
2.2 在 HomeHotProduct.vue
中使用 现在,我们回到 HomeHotProduct.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 <script setup lang ="ts" > import HotProductHeader from './components/HotProductHeader.vue' </script > <template > <div class ="hot-product" > <div class ="container" > <HotProductHeader title ="热销产品" slogan ="核心科技 品质精选" /> </div > </div > </template > <style lang ="scss" scoped > .hot-product { padding : 32px 0 ; background : #fff ; .container { max-width : 1200px ; margin : 0 auto; padding : 0 20px ; } } </style >
刷新浏览器,您将看到样式精美的标题区已经出现了。
3. 使用并深度定制 ElTabs
组件 这是本节的核心教学点。面对设计稿中的 Tabs 导航,我们作为“务实的构建者”,首要思路就是利用 Element Plus
提供的能力。
3.1 引入并搭建 ElTabs
基础结构 我们先引入 ElTabs
,并用静态数据快速搭建出基础的 Tabs 结构。
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 <script setup lang ="ts" > import { ref } from 'vue' import HotProductHeader from './components/HotProductHeader.vue' const activeTab = ref ('new' )const categories = [ { id : 'new' , name : '新品' , icon : '/src/assets/icons/icon_New product-02@2x.png' , products : [ { id : 1 , name : '自然又自在' , desc : '格力·至尊 家居生活新中心' , picture : '/images/new/product1.jpg' , type : 'large' }, { id : 2 , name : '循环风扇' , desc : '臻品工艺 拂动盛夏' , picture : '/images/new/product2.jpg' , type : 'normal' }, { id : 3 , name : '空气净化器' , desc : '森林级空气管家' , picture : '/images/new/product3.jpg' , type : 'normal' }, { id : 4 , name : '热泵洗衣机' , desc : '37℃烘干不伤衣' , picture : '/images/new/product4.jpg' , type : 'wide' }, { id : 5 , name : '晶弘魔法冰箱' , desc : '鲜嫩两星期 轻触一刀切' , picture : '/images/new/product5.jpg' , type : 'tall' } ] }, { id : 'home-ac' , name : '家用空调' , icon : '/src/assets/icons/icon_Air-Conditioner-02@2x.png' , products : [] }, { id : 'central-ac' , name : '中央空调' , icon : '/src/assets/icons/icon_Home-central-air-conditioning-02@2x.png' , products : [] }, { id : 'appliances' , name : '生活电器' , icon : '/src/assets/icons/icon_home-devices-02@2x.png' , products : [] }, { id : 'refrigerator' , name : '冰箱' , icon : '/src/assets/icons/icon_refrigerator-02@2x.png' , products : [] }, { id : 'washer' , name : '洗衣机' , icon : '/src/assets/icons/icon_washing-machine-02@2x.png' , products : [] }, { id : 'heater' , name : '热水器' , icon : '/src/assets/icons/icon_Water-heater-02@2x.png' , products : [] } ] </script > <template > <div class ="hot-product" > <div class ="container" > <HotProductHeader title ="热销产品" slogan ="核心科技 品质精选" /> <div class ="hot-product__category" > <el-tabs v-model ="activeTab" class ="hot-product__tabs" > <el-tab-pane v-for ="category in categories" :key ="category.id" :name ="category.id" > </el-tab-pane > </el-tabs > </div > </div > </div > </template >
3.2 使用 #label
插槽自定义内容 默认的 ElTabs
只显示文字标题。要实现“图标+文字”的复杂结构,我们需要使用它的 #label
插槽。
“务实的构建者”思路 : “默认效果不满足需求?我应该去查阅 Element Plus 关于 Tabs 组件的文档,看看它是否提供了自定义标题的 API。” —— 很快,你就会在文档的“插槽”部分找到 #label
。
我们来更新 <template>
以使用这个插槽:
1 2 3 4 5 6 7 8 9 10 11 12 <el-tabs v-model ="activeTab" class ="hot-product__tabs" > <el-tab-pane v-for ="category in categories" :key ="category.id" :name ="category.id" > <template #label > <div class ="hot-product__tab-label" > <div class ="hot-product__tab-icon" > <img :src ="category.icon" :alt ="category.name" /> </div > <span class ="hot-product__tab-text" > {{ category.name }}</span > </div > </template > </el-tab-pane > </el-tabs >
3.3 使用 :deep()
深度定制样式 现在结构对了,但样式还是 Element Plus
默认的。为了匹配设计稿,我们需要覆盖它的内部样式。
“务实的构建者”思路 : “我在 <style scoped>
里写的 .el-tabs__item
样式不生效。我知道 scoped
会隔离样式,而 .el-tabs__item
是子组件的内部元素。因此,我需要使用 Vue 提供的 :deep()
伪类来‘穿透’这个隔离。”
我们来补全 <style>
部分,完成对 ElTabs
的美化。
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 <style lang="scss" scoped> .hot-product { padding : 32px 0 ; background : #fff ; .container { max-width : 1200px ; margin : 0 auto; padding : 0 20px ; } &__category { margin-top : 40px ; } &__tabs { :deep (.el-tabs__header) { margin-bottom : 40px ; } :deep (.el-tabs__nav-wrap) { &::after { display : none; } } :deep (.el-tabs__nav) { display : flex; width : 100% ; border-bottom : 1px solid $borderColor ; } :deep (.el-tabs__active-bar) { display : none; } :deep (.el-tabs__item) { flex : 1 ; height : 130px ; padding : 0 ; position : relative; &.is-active , &:hover { &::after { content : '' ; position : absolute; bottom : -1px ; left : 50% ; transform : translateX (-50% ); width : 80px ; height : 2px ; background : $GLColor ; } .hot-product__tab-text { font-weight : 600 ; color : #111 ; } } } } &__tab-label { display : flex; flex-direction : column; align-items : center; text -align: center; } &__tab-icon { width : 64px ; height : 64px ; margin-bottom : 18px ; img { width : 100% ; height : 100% ; object -fit: contain; } } &__tab-text { font-size : 18px ; font-weight : 400 ; color : #333 ; line -height : 27px ; } } </style>
2.8.3 构建布局容器 (HotProductContent.vue
) 与最终组装 本节目标 : 我们将创建一个专门负责 布局 的子组件 HotProductContent.vue
。您将深入学习如何运用 CSS Grid 来实现复杂的非对称网格。最后,我们将所有“零件” (HotProductHeader
, ElTabs
, HotProductContent
) 在主组件 HomeHotProduct.vue
中完成组装,得到 2 完整的 静态 成品。
1. 创建布局容器组件 (HotProductContent.vue
) 遵循“单一职责原则”,HomeHotProduct.vue
负责管理 Tabs 和数据状态,而商品列表的 具体排列方式 这个纯视觉任务,应该交给一个专门的子组件来处理。
文件路径 : src/views/Home/components/components/HotProductContent.vue
任务目标 : 创建一个接收 products
数组作为 prop 的“布局”组件,其唯一职责就是使用 CSS Grid 和我们之前创建的 ProductCard.vue
,将商品数据渲染为非对称网格。
1.1 完整代码实现
这个组件的核心在于它的 <style>
部分,即 CSS Grid 的具体实现。
请在 src/views/Home/components/components/
目录下创建 HotProductContent.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 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 <script setup lang ="ts" > import { Product } from '@/types/category' import ProductCard from '@/components/ProductCard.vue' interface Props { products : Product [] showMask?: boolean showInfo?: boolean } interface Emits { (e : 'product-click' , product : Product ): void (e : 'product-hover' , product : Product ): void (e : 'product-button-click' , product : Product ): void } const props = withDefaults (defineProps<Props >(), { showMask : true , showInfo : true }) const emit = defineEmits<Emits >()const handleProductClick = (product: Product ) => { emit ('product-click' , product) } const handleProductHover = (product: Product ) => { emit ('product-hover' , product) } const handleProductButtonClick = (product: Product ) => { emit ('product-button-click' , product) } </script > <template > <div class ="hot-product-content" > <div class ="hot-product__grid" > <div v-for ="(product, index) in products" :key ="product.id" :class ="[ 'hot-product__item', `hot-product__item--${product.type}`, `hot-product__item--${index + 1}` ]" > <ProductCard :product ="product" :index ="index" :show-mask ="showMask" :show-info ="showInfo" @click ="handleProductClick" @hover ="handleProductHover" @button-click ="handleProductButtonClick" /> </div > </div > </div > </template > <style lang ="scss" scoped > // 产品网格布局 .hot-product__grid { display : grid; grid-template-columns : repeat (4 , 283px ); grid-template-rows : repeat (2 , 283px ); gap : 10px ; padding : 3px ; justify-content : center; } .hot-product__item { position : relative; overflow : hidden; // 大型项目(左上角) &--large { grid-row : span 2 ; } // 普通项目(顶部中间、右侧) &--normal { grid-row : span 1 ; } // 高项目(右侧,跨2 行) &--tall { grid-row : span 2 ; } // 宽项目(底部,跨2 列) &--wide { grid-column : span 2 ; width : 576px ; } // 特定位置 &--1 { // 大型项目 grid-column : 1 ; grid-row : 1 / 3 ; width : 283px ; height : 576px ; } &--2 { // 普通项目1 grid-column : 2 ; grid-row : 1 ; width : 283px ; height : 283px ; } &--3 { // 普通项目2 grid-column : 3 ; grid-row : 1 ; width : 283px ; height : 283px ; } &--4 { // 宽项目 grid-column : 2 / 4 ; grid-row : 2 ; width : 576px ; height : 283px ; } &--5 { // 高项目 grid-column : 4 ; grid-row : 1 / 3 ; width : 283px ; height : 576px ; } } </style >
CSS Grid 布局解读 :
display: grid
: 将容器声明为网格布局。grid-template-columns: repeat(4, 283px)
: 定义了网格有 4 列,每列宽度为 283px。grid-template-rows: repeat(2, 283px)
: 定义了网格有 2 行,每行高度为 283px。gap: 10px
: 定义了网格项之间的间距。grid-row: span 2
/ grid-column: span 2
: 让一个网格项占据两行或两列。grid-column: 1 / 3
: 让一个网格项从第一条列网格线开始,到第三条列网格线结束,即占据第 1、2 列。核心 : 我们通过 :class
动态绑定了来自数据的 type
和 index
,CSS 再利用这些类名,将每个网格项精确地“安放”到预设的网格位置上,从而实现了这种复杂的非对称布局。2. 在主组件 (HomeHotProduct.vue
) 中完成最终组装 现在,我们所有的“零件”都已备齐,是时候在“总装车间” HomeHotProduct.vue
中将它们组合起来了。
2.1 更新 <script setup>
我们需要引入新创建的 HotProductContent
组件,并添加一个计算属性,用于根据当前激活的 Tab 筛选出需要展示的商品列表。
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 <script setup lang="ts" > import { ref, computed } from 'vue' import type { Product } from '@/types/category' import HotProductHeader from './components/HotProductHeader.vue' import HotProductContent from './components/HotProductContent.vue' const activeTab = ref ('new' )const categories = [ { id : 'new' , name : '新品' , icon : '/src/assets/icons/icon_New product-02@2x.png' , products : [ { id : 1 , name : '自然又自在' , desc : '格力·至尊 家居生活新中心' , picture : '/images/new/product1.jpg' , type : 'large' }, { id : 2 , name : '循环风扇' , desc : '臻品工艺 拂动盛夏' , picture : '/images/new/product2.jpg' , type : 'normal' }, { id : 3 , name : '空气净化器' , desc : '森林级空气管家' , picture : '/images/new/product3.jpg' , type : 'normal' }, { id : 4 , name : '热泵洗衣机' , desc : '37℃烘干不伤衣' , picture : '/images/new/product4.jpg' , type : 'wide' }, { id : 5 , name : '晶弘魔法冰箱' , desc : '鲜嫩两星期 轻触一刀切' , picture : '/images/new/product5.jpg' , type : 'tall' } ] }, { id : 'home-ac' , name : '家用空调' , icon : '/src/assets/icons/icon_Air-Conditioner-02@2x.png' , products : [] }, ] const activeProducts = computed (() => { const activeCategory = categories.find (cat => cat.id === activeTab.value ) return activeCategory?.products || [] }) const handleProductClick = (product : Product ) => { console .log ('在 HomeHotProduct 组件中捕获到点击事件:' , product) } const handleProductButtonClick = (product : Product ) => { console .log ('在 HomeHotProduct 组件中捕获到按钮点击事件:' , product) } </script>
2.2 更新 <template>
最后,在 ElTabPane
内部使用 HotProductContent
组件,并将计算属性和事件处理函数绑定上去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <template > <div class ="hot-product" > <div class ="container" > <HotProductHeader title ="热销产品" slogan ="核心科技 品质精选" /> <div class ="hot-product__category" > <el-tabs v-model ="activeTab" class ="hot-product__tabs" > <el-tab-pane v-for ="category in categories" :key ="category.id" :name ="category.id" > <template #label > </template > <HotProductContent :products ="activeProducts" @product-click ="handleProductClick" @product-button-click ="handleProductButtonClick" /> </el-tab-pane > </el-tabs > </div > </div > </div > </template >
2.8.4 接入真实 API 数据 (动态化) 本节目标 : 我们将移除组件内的本地静态数据,使用 TanStack Query
从我们已有的 /categories
接口获取数据,并利用 ElSkeleton
组件添加优雅的加载状态,最终完成一个企业级的、完全由后端数据驱动的动态组件。
1. 设计思路:为何选择 TanStack Query
? 我们完全可以用 onMounted
钩子配合 axios
来获取数据,但“务实的构建者”会寻求更专业的解决方案。TanStack Query
正是为此而生。
技术选型
动态化改造前
好了,要从后端拿数据了。最直接的办法就是在 onMounted
里 await getCategoryAPI()
,然后把结果赋给一个 ref
,对吗?
你
完全正确,这是 Vue 开发的“标准答案”。但它需要我们手动管理很多状态,比如 isLoading
、isError
等。
你
这就是我们引入 TanStack Query
的原因。你只需要告诉它用哪个 key
缓存数据,以及用哪个函数 (queryFn
) 去获取数据,它就会自动处理剩下的所有事:loading 状态、error 状态、数据缓存、甚至重新聚焦窗口时的自动刷新 … 它把所有繁琐的异步数据逻辑都封装好了,让我们的组件代码极其纯净。
明白了,这就是“用最好的轮子造更好的车”。我只需要关心“拿数据”这个动作本身,而不用关心拿数据的过程。
2. API 与类型准备 幸运的是,我们之前的工作已经为这一刻铺好了路。
API 接口 : 我们将直接复用在 2.5 节 为头部导航创建的 getCategoryAPI
函数 (src/apis/layout.ts
)。它请求的 /categories
接口返回的正是我们需要的、包含 products
数组的完整分类数据。TanStack Query
的缓存机制甚至可能会让这次请求直接命中缓存,瞬间完成!类型定义 : 请确保 src/types/category.ts
中的 Product
类型定义与我们的 mock-data.json
完全一致,特别是包含了 type
字段。1 2 3 4 5 6 7 8 export interface Product { id : number ; name : string ; desc : string ; picture : string ; type : 'large' | 'normal' | 'wide' | 'tall' ; }
3. 使用 TanStack Query
重构 HomeHot.vue
这是本节的核心,我们将对 HomeHotProduct.vue
的 <script setup>
部分进行“大换血”。
3.1 引入 useQuery
并移除静态数据
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 <script setup lang="ts" > import { ref, computed } from 'vue' import type { Product } from '@/types/category' import HotProductHeader from './components/HotProductHeader.vue' import HotProductContent from './components/HotProductContent.vue' import { useQuery } from '@tanstack/vue-query' import { getCategoryAPI } from '@/apis/layout' const activeTab = ref ('new' )const { data : categories, isLoading } = useQuery ({ queryKey : ['categories' ], queryFn : getCategoryAPI, select : (data ) => data.result }) const activeProducts = computed (() => { return categories.value ?.find (cat => cat.id === activeTab.value )?.products || [] }) const handleProductClick = (product : Product ) => { console .log ('点击产品:' , product) } const handleProductButtonClick = (product : Product ) => { console .log ('点击了解更多按钮:' , product) } </script>
useQuery
解读 :
queryKey: ['categories']
: 这是该份数据在 TanStack Query
缓存中的唯一标识。queryFn: getCategoryAPI
: 指定了获取数据的异步函数。select: (data) => data.result
: 这是一个非常有用的转换器。我们的 API 返回的是 { code, msg, result }
结构,通过 select
,我们可以直接将 result
属性提取出来,赋值给 data
(也就是我们重命名的 categories
),让后续使用更方便。const { data: categories, isLoading }
: 我们从 useQuery
的返回结果中解构出 data
并重命名为 categories
,同时解构出 isLoading
状态,用于控制加载效果。2.9 模块提交与总结 至此,我们已经完成了 vue3-webShop
项目的通用布局和核心首页的开发。我们不仅构建了静态骨架,还通过 Pinia
和 TanStack Query
成功注入了动态数据,为应用打下了坚实的业务基础。现在,是时候将我们本模块的成果提交到版本库了。
当前任务 : 2.9 - 模块成果提交任务目标 : 将模块二中完成的所有通用布局与首页功能,作为一个完整的特性提交到 Git 仓库。
命令行操作 打开您的终端,确保位于项目根目录下,然后执行以下命令:
将所有已修改和新建的文件添加到 Git 暂存区:
提交代码,并附上符合“约定式提交”规范的 message:
1 git commit -m "feat(layout, home): build main layout and dynamic homepage"
Commit Message 解读 :
feat
: 表示这是一个新功能 (feature) 的提交。(layout, home)
: 指明了本次提交影响的主要范围是“布局”和“首页”模块。build main layout and dynamic homepage
: 简明扼要地描述了我们完成的具体工作:构建了主布局和动态化的首页。提交成功后,您的项目就有了一个清晰的、代表“首页开发完成”的历史节点。我们已经准备好进入下一个模块,继续构建登录与用户认证功能。
模块三:登录与用户认证 本章概述 : 欢迎来到模块三。在本章中,我们将构建一个 真正企业级 的用户认证系统。我们将从零开始,在 Mock 后端实现 双 Token 认证机制 。在前端,我们将深度封装 Axios
,通过 请求和响应拦截器 实现 Access Token 的无感刷新。最后,我们将结合 ElForm
的强大校验功能和 useMutation
,完成一个健壮、安全、用户体验极佳的登录流程,并为应用添加路由守卫,实现完整的认证闭环。
本模块任务清单 任务模块 任务名称 核心目标与学习要点 后端改造 升级 Mock 后端以支持双 Token 改造 /login
接口返回双 Token,并新增 /token/refresh
接口用于无感续期。 状态管理 升级 Pinia (userStore
) 改造 userStore
,使其能分别存储和管理用户信息、accessToken
和 refreshToken
。 【核心】网络层 深度封装 Axios 拦截器 实现请求拦截器自动注入 Token,以及响应拦截器处理 401
错误、无感刷新 Token 和重试请求。 UI 与校验 构建登录 UI 与表单校验 使用 ElForm
构建登录表单,配置声明式校验规则,并为未来的 QQ/手机号登录预留入口。 【核心】数据变更 整合 useMutation
完成登录 使用 useMutation
处理登录提交,并在 onSuccess
回调中,调用 userStore
的 action
将双 Token 持久化。 应用安全 实现路由守卫与退出登录 使用 vue-router
的导航守卫保护需登录页面,并实现一个能清除所有状态的退出登录功能。
3.1 任务规划与设计思路 一个优秀的前端认证系统,其核心在于 对 Token 的自动化管理 ,对用户应该是几乎无感的。
认证架构设计
架构设计阶段
在企业级项目中,为什么认证方案通常需要两个 Token?一个 accessToken
不够吗?
架构师
这是一个经典的安全与体验的权衡问题。Access Token
用来访问受保护资源,它的有效期通常很短(比如 30 分钟),即使被截获,风险也有限。但如果让用户每半小时就重新登录一次,体验会非常糟糕。
所以 Refresh Token
就派上用场了,用来换新的 Access Token
。
架构师
完全正确。Refresh Token
的有效期很长(比如 7 天),它 唯一的作用 就是去换取一个新的、短期的 Access Token
。当 Access Token
过期时,我们的前端应用应该能自动、在后台用 Refresh Token
去换一个新的 Access Token
,然后无缝地继续之前的操作,用户完全感觉不到这个过程。
架构师
这正是我们要深度封装 Axios
拦截器的原因。我们将打造一个“自动化流水线”:请求发出时,自动带上 Access Token
;当收到 401
(未授权) 响应时,自动暂停当前请求,去刷新 Token,成功后再自动重试刚才失败的请求。这套机制,就是企业级认证的核心。
我们的搭建路线图将围绕这套企业级认证体系展开:
升级 Mock 后端 : 实现双 Token 认证机制。升级 Pinia (userStore
) : 使其能够管理双 Token。【核心】深度封装 Axios 拦截器 : 实现 Token 的自动注入与无感刷新。构建登录页面 UI 与表单校验 : 搭建视图并实现 ElForm
校验。整合 useMutation
完成登录 : 处理表单提交与回调。3.2 编码实现:升级 Mock 后端以支持双 Token 要实现前端的无感刷新,后端必须提供相应的支持。我们需要改造 Mock Server,使其从一个简单的数据提供者,升级为一个具备签发和刷新 Token 能力的、更真实的模拟认证服务器。
涉及文件 : mock/generate-data.cjs
, mock/server.cjs
任务目标 :
让用户数据包含 refreshToken
。 改造 /login
接口,使其在登录成功后返回带有过期时间的 accessToken
和一个长期有效的 refreshToken
。 新增 /token/refresh
接口,用于根据 refreshToken
换取新的 accessToken
。 新增一个中间件,用于模拟 accessToken
的过期校验。 1. 更新数据生成器 首先,我们需要在生成模拟用户数据时,为每个用户添加一个 refreshToken
字段。
请打开 mock/generate-data.cjs
并更新 users
的生成逻辑:
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 const { faker } = require ("@faker-js/faker" );const fs = require ('fs' );const path = require ('path' );module .exports = () => { const staticDataPath = path.join (__dirname, 'mock-data.json' ); const staticData = JSON .parse (fs.readFileSync (staticDataPath, 'utf-8' )); const data = { ...staticData, users : [], }; 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 (), 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; };
2. 改造 Mock Server 接下来是核心改造。我们将用 Express 中间件的方式,为 Mock Server 添加自定义的认证逻辑。
请打开 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 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 const jsonServer = require ("json-server" );const { faker } = require ("@faker-js/faker" );const generateData = require ("./generate-data.cjs" );const server = jsonServer.create ();const router = jsonServer.router (generateData ());const middlewares = jsonServer.defaults ();server.use (middlewares); server.use (jsonServer.bodyParser ); server.use ((req, res, next ) => { if (req.path === "/login" || req.path === "/token/refresh" ) { return next (); } const authHeader = req.headers .authorization ; if (authHeader && authHeader.startsWith ("Bearer " )) { const token = authHeader.substring (7 ); const parts = token.split ("_" ); if (parts.length === 2 ) { const expirationTime = parseInt (parts[1 ], 10 ); const currentTime = Date .now (); if (currentTime > expirationTime) { return res.status (401 ).json ({ message : "Access Token 已过期" , code : "401" , }); } } } next (); }); server.post ("/login" , (req, res ) => { const { account, password } = req.body ; const db = router.db ; const user = db.get ("users" ).find ({ account, password }).value (); if (user) { const { password, ...userInfo } = user; const accessToken = `${faker.string.uuid()} _${Date .now() + 1 * 60 * 1000 } ` ; res.status (200 ).json ({ code : "200" , msg : "操作成功" , result : { ...userInfo, accessToken, }, }); } else { res.status (400 ).json ({ message : "用户名或密码错误" , code : "400" , }); } }); server.post ("/token/refresh" , (req, res ) => { const { refreshToken } = req.body ; const db = router.db ; const user = db.get ("users" ).find ({ refreshToken }).value (); if (user && refreshToken) { const newAccessToken = `${faker.string.uuid()} _${Date .now() + 30 * 60 * 1000 } ` ; res.status (200 ).json ({ code : "200" , msg : "刷新成功" , result : { accessToken : newAccessToken, }, }); } else { res.status (401 ).json ({ message : "登录状态无效,请重新登录" , code : "401" , }); } }); 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.use (router); server.listen (3001 , () => { console .log ("JSON Server is running on http://localhost:3001" ); });
3. 代码解读与验证 Token 过期模拟 : 我们添加了一个 Express 中间件。对于除登录和刷新外的所有请求,它会检查 Authorization
头。我们巧妙地将过期时间戳用 _
连接在模拟 Token 的后面。中间件解析出这个时间戳,并与当前时间比较,如果超时,就返回 401
状态码。这完美地模拟了真实世界中的 Token 过期场景。/login
接口 : 登录成功后,会生成一个 1 分钟后过期 的 accessToken
。这便于我们快速测试无感刷新功能。/token/refresh
接口 : 接收 refreshToken
,如果有效,就返回一个有效期为 30 分钟的 全新 accessToken
。在继续前端改造之前,请务必重启 Mock Server (pnpm run mock
)。我们的后端现在已经具备了完整的、企业级的双 Token 签发和刷新能力,这是构建前端无感刷新功能坚实的 后端基础 。
3.3 编码实现:升级 Pinia 以管理双 Token Pinia
作为我们应用的“唯一事实来源”,必须能够准确地反映用户的完整认证状态。这不仅仅包括用户信息,更核心的是认证所需的 accessToken
和 refreshToken
。
文件路径 : src/stores/user.ts
核心目标 : 重构 userStore
,使其能统一存储用户信息和双 Token。我们将添加专门的 computed
属性来安全地获取 accessToken
和 refreshToken
,并利用持久化插件确保登录状态的稳定。
1. 设计思路 一个健壮的企业级 userStore
应该具备以下特点:
状态统一管理 : 将用户的基本信息与认证令牌统一存储在一个 userInfo
对象中。这样做权责清晰,当获取到新的登录数据或刷新 Token 后,可以作为一个整体进行更新。安全的访问器 (Getters) : 不在组件中直接访问 store.userInfo.accessToken
,而是通过 computed
属性(Pinia 中的 Getters)来获取。这层抽象让组件无需关心 userInfo
的内部结构,也便于在 Getter 中处理 null
或 undefined
等边界情况。原子化操作 : 提供统一的 action
(setUserInfo
) 来一次性更新所有登录相关状态,避免状态不一致。同理,clearUserInfo
方法用于安全退出,确保所有认证信息被完整清除。2. 完整代码实现 现在,我们将对 src/stores/user.ts
文件进行全面升级。
请打开 src/stores/user.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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import { defineStore } from "pinia" ;import { ref, computed } from "vue" ;import { loginApi } from "@/apis/user" ;import type { UserInfo , LoginForm } from "@/types/user" ;export const useUserStore = defineStore ( "user" , () => { const userInfo = ref<UserInfo | object >({}); const getUserInfo = async (form : LoginForm ) => { const res = await loginApi (form); userInfo.value = res.result ; }; const setUserInfo = (newUserInfo : UserInfo ) => { userInfo.value = newUserInfo; }; const clearUserInfo = ( ) => { userInfo.value = {}; }; const isLoggedIn = computed (() => { const info = userInfo.value as UserInfo ; return !!info.accessToken ; }); const getAccessToken = computed (() => { const info = userInfo.value as UserInfo ; return info.accessToken || null ; }); const getRefreshToken = computed (() => { const info = userInfo.value as UserInfo ; return info.refreshToken || null ; }); return { userInfo, getUserInfo, setUserInfo, clearUserInfo, isLoggedIn, getAccessToken, getRefreshToken, }; }, { persist : true , } );
3. 验证持久化效果 虽然我们还未集成登录页面,但可以手动进行验证。
在任意组件(例如 App.vue
)的 <script setup>
中,临时添加以下测试代码:1 2 3 4 5 6 7 8 9 10 11 import { useUserStore } from '@/stores/user' const userStore = useUserStore ()userStore.setUserInfo ({ id : 'test-id' , account : 'test-user' , nickname : 'Tester' , avatar : '' , accessToken : 'dummy-access-token-123' , refreshToken : 'dummy-refresh-token-456' })
运行项目,并打开浏览器的开发者工具。 在 “Application” (应用) -> “Local Storage” (本地存储空间) 中,你应该能看到一个名为 user
的条目,其内容是一个包含了我们刚刚设置的所有信息的 JSON 字符串。 现在,注释掉或删除 刚刚添加的测试代码并 刷新页面 。你会发现 Pinia 状态依然存在,因为它已成功从 Local Storage 中恢复。 我们的 Pinia store 现已升级为一个能够妥善管理企业级认证状态的“保险箱”。它不仅结构清晰、类型安全,而且借助持久化插件,确保了用户登录状态的稳定可靠。
3.4 【核心】编码实现:深度封装 Axios 拦截器 本章目标 : 对我们全局的 Axios 实例进行深度封装。我们将实现一个 请求拦截器 来自动为请求注入 accessToken
,以及一个更复杂的 响应拦截器 。这个响应拦截器将能自动捕获 401
(未授权) 错误,并在后台使用 refreshToken
无感地刷新 accessToken
,然后 自动重试 之前失败的请求,为用户提供无缝的登录体验。
1. 设计思路:打造“自愈式”的请求流水线 在开始编码前,让我们先进行一次技术对话,明确我们的目标。
封装前的思考
准备封装网络层
架构师,现在后端接口和 Pinia Store 都好了。我是不是可以在需要 Token 的 API 请求函数里,手动从 Pinia 取 Token 然后加到 Header 里?
架构师
技术上可以,但这是企业级项目的大忌。你的每个 API 请求函数都要重复写一遍这个逻辑。更糟糕的是,如果 accessToken
过期了怎么办?在每个发起请求的组件里都写一遍 if (error.status === 401)
的判断吗?
架构师
完全正确。所以,我们要打造一个“中央处理中心”——也就是深度封装的 axios
实例。我们将用它的 拦截器 (Interceptors) 功能,打造一条自动化流水线。
架构师
请求拦截器 负责在每个请求发出前,自动检查并带上 accessToken
。响应拦截器 负责在收到响应后,自动检查是不是 401
错误。如果是,它会 暂停 当前失败的请求,在后台悄悄用 refreshToken
换一个新的 accessToken
,成功后再用新 Token 自动重试 刚才失败的请求。
哇!这样一来,我的业务组件就只需要关心“发请求”和“拿数据”,完全不用知道 Token 的存在和过期问题了!
架构师
这就是我们要做的——把认证的复杂性,彻底封装在网络层。
2. 准备工作:创建 Token 刷新 API 在封装拦截器之前,我们需要先在 src/apis/user.ts
文件中创建一个专门用于刷新 Token 的 API 函数。
请打开 src/apis/user.ts
并添加以下函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import httpInstance from "@/utils/http" ;import type { LoginForm , UserInfo } from "@/types/user" ;export const refreshTokenAPI = (refreshToken : string ): Promise <{ result : { accessToken : string } }> => { return httpInstance ({ method : "POST" , url : "/token/refresh" , data : { refreshToken, }, }); };
3. 完整代码实现:封装 http.ts
现在,我们准备好对 src/utils/http.ts
进行终极改造了。
请打开 src/utils/http.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 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 import axios from "axios" ;import { useUserStore } from "@/stores/user" ;import router from "@/router" ; import { refreshTokenAPI } from "@/apis/user" ; import type { UserInfo } from "@/types/user" ;const httpInstance = axios.create ({ baseURL : "/api" , timeout : 5000 , }); httpInstance.interceptors .request .use ( (config ) => { const userStore = useUserStore (); const token = userStore.getAccessToken ; if (token) { config.headers .Authorization = `Bearer ${token} ` ; } return config; }, (e ) => Promise .reject (e) ); let isRefreshing = false ;httpInstance.interceptors .response .use ( (res ) => res.data , async (error) => { if ( error.response ?.status === 401 && error.config .url !== "/token/refresh" && !error.config ._retry && !isRefreshing ) { error.config ._retry = true ; isRefreshing = true ; const userStore = useUserStore (); const refreshToken = userStore.getRefreshToken ; if (!refreshToken) { isRefreshing = false ; userStore.clearUserInfo (); router.push ("/login" ); return Promise .reject (error); } try { const refreshRes = await refreshTokenAPI (refreshToken); const newAccessToken = refreshRes.result .accessToken ; const currentUserInfo = userStore.userInfo as UserInfo ; userStore.setUserInfo ({ ...currentUserInfo, accessToken : newAccessToken, }); error.config .headers .Authorization = `Bearer ${newAccessToken} ` ; isRefreshing = false ; return await httpInstance (error.config ); } catch (refreshError) { isRefreshing = false ; userStore.clearUserInfo (); router.push ("/login" ); return Promise .reject (refreshError); } } return Promise .reject (error); } ); export default httpInstance;
4. 深度代码解读 这段代码是整个认证系统的“心脏”,让我们逐一解析它的工作机制:
关键陷阱 : 在 Pinia
的 store
文件之外使用 useUserStore()
,必须在实际需要它的函数 内部 调用,而不能在文件的顶层作用域调用。这是因为 Pinia 实例的挂载是在 main.ts
中,顶层作用域的代码执行时 Pinia 可能尚未准备好。
我们已经成功地为我们的应用构建了一个高度智能、具备“自愈”能力的“神经网络”。现在,任何组件发起的 API 请求都自带了‘无感认证’的超能力。
3.5 编码实现:构建登录 UI 与表单校验 后端、状态管理和网络层这三大“基础设施”已经就绪。现在,我们回到用户能直接感知的层面——构建一个美观且功能强大的登录页面。
本节,我们将采用分步走的策略,清晰地分离 视图 和 逻辑 的构建过程。
第一步:搭建 UI 骨架 (视图层) 本节目标 : 专注于 视觉实现 。我们将准备好所有静态资源(图片、样式),并编写 Login.vue
的 <template>
部分,搭建一个包含动画、导航和第三方登录入口的完整静态视图。
1. 准备静态资源 这个页面的样式和动画比较复杂。为了让我们可以专注于逻辑,我们将其作为一个“资源包”直接引入。
图片资源 : 请在 src/assets/
目录下创建一个 login
文件夹,并放入 img1.png
, img2.png
, QQ.png
, WeChat.png
四张图片。样式文件 : 请在 src/styles/
目录下创建 login.scss
文件,并 直接复制粘贴 以下所有样式代码。点击查看 login.scss 的完整代码 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 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 @use "sass:color" ;@use "abstracts/variables" as *;:root { font-size : 15px ; } .login-page { margin : 0 ; min-height : 100vh ; background : linear-gradient ( 135deg , color.adjust ($GLColor , $lightness : 45% ) 0% , color.adjust ($GLColor , $lightness : 50% ) 25% , color.adjust ($sucColor , $lightness : 40% ) 50% , color.adjust ($warnColor , $lightness : 35% ) 75% , color.adjust ($GLColor , $lightness : 48% ) 100% ); position : relative; overflow : hidden; } .login-page ::after { content : "" ; display : block; position : fixed; width : 100% ; height : 100% ; top : 0 ; left : 0 ; backdrop-filter : blur (10px ); -webkit-backdrop-filter : blur (10px ); } .login-content { width : 90vw ; height : 90vh ; position : absolute; left : 50% ; top : 50% ; transform : translate (-50% , -50% ); z-index : 1 ; border-radius : 30px ; background : rgba (255 , 255 , 255 , 0.6 ); border : 1px solid rgba (255 , 255 , 255 , 0.18 ); display : flex; max-width : 1200px ; max-height : 800px ; .left-section { flex : 1 ; position : relative; .sphere { position : absolute; left : 30% ; width : 90% ; z-index : 1 ; animation : sphereAnimation 2s ; animation-fill-mode : forwards; animation-timing-function : ease; } .people { position : absolute; left : -50% ; top : 20% ; width : 70% ; z-index : 2 ; } .p-animation { animation : peopleAnimation 2s ; animation-fill-mode : forwards; animation-timing-function : ease; } .p-other-animation { animation-name : pOtherAnimation; animation-direction : alternate; animation-timing-function : linear; animation-iteration-count : infinite; animation-duration : 3s ; } .s-animation { animation : sphereAnimation 2s ; animation-fill-mode : forwards; animation-timing-function : ease; } .s-other-animation { animation-name : sOtherAnimation; animation-direction : alternate; animation-timing-function : linear; animation-iteration-count : infinite; animation-duration : 3s ; } } .right-section { flex : 1 ; position : relative; z-index : 12 ; .top-navigation { width : 80% ; margin-left : 38px ; color : $GLColor ; font-size : 20px ; font-weight : 600 ; position : absolute; left : 50% ; top : 5% ; transform : translate (-50% , 0 ); .nav-item { float : left; width : 150px ; height : 40px ; line -height : 40px ; text -align: center; margin-right : 10px ; transition : $transition-duration ; cursor : pointer; &:hover { border : 0 ; background-color : #fff ; border-radius : 50px ; box-shadow : -20px 10px 32px 1px rgba (182 , 183 , 185 , 0.37 ); } } } .form-wrapper { width : 60% ; position : absolute; left : 50% ; top : 50% ; transform : translate (-50% , -50% ); text -align: left; .form-title { font-family : "Century Gothic" , Times, serif; margin : 30px 0 ; color : $GLColor ; font-size : 28px ; font-weight : 600 ; } .el-form { .el-form-item { margin-bottom : 30px ; .el-input { .el-input__wrapper { height : 70px ; border-radius : $borderRadius * 2.5 ; border : 0 ; background-color : color.adjust ($borderColor , $lightness : 5% ); box-shadow : none; padding : 0 20px ; .el-input__inner { color : $textColor ; font-family : "Century Gothic" , Times, serif; font-size : 20px ; &::placeholder { color : $textColor-secondary ; } } } &.is-focus .el-input__wrapper { box-shadow : 0 0 0 1px $GLColor inset; } } .el-checkbox { .el-checkbox__label { color : $textColor-secondary ; font-family : "Century Gothic" , Times, serif; } } } } .forgot-password { display : block; margin-top : -15px ; margin-bottom : 20px ; color : $textColor-secondary ; cursor : pointer; text -decoration: none; font-family : "Century Gothic" , Times, serif; transition : color $transition-duration ; &:hover { color : $GLColor ; } } .login-button { width : 100% ; height : 50px ; background-color : $GLColor ; border-radius : $borderRadius * 2.5 ; font-size : 15px ; color : #fff ; border : 0 ; font-weight : 600 ; margin : 30px 0 ; cursor : pointer; box-shadow : -20px 28px 42px 0 rgba (1 , 85 , 178 , 0.37 ); font-family : "Century Gothic" , Times, serif; transition : all 0.3s ease; &:hover { background-color : color.adjust ($GLColor , $lightness : -8% ); transform : translateY (-2px ); } &:active { transform : translateY (0 ); } } .other-login { .divider { width : 100% ; margin : 20px 0 ; text -align: center; display : flex; align-items : center; justify-content : space-between; .line { display : inline-block; max-width : 35% ; width : 35% ; flex : 1 ; height : 1px ; background-color : $borderColor ; } .divider-text { vertical-align : middle; margin : 0px 20px ; display : inline-block; width : 150px ; color : $textColor-secondary ; white-space : normal; font-family : "Century Gothic" , Times, serif; } } .other-login-wrapper { width : 100% ; display : flex; justify-content : center; align-items : center; .other-login-item { width : 70px ; padding : 10px ; text -align: center; border-radius : $borderRadius * 2.5 ; cursor : pointer; font-weight : 600 ; color : $GLColor ; margin : 0 10px ; transition : 0.4s ; img { width : 40px ; height : 40px ; vertical-align : middle; } span { vertical-align : middle; } &:hover { width : 80px ; height : 50% ; background-color : #fff ; border : 0 ; box-shadow : -20px 10px 32px 1px rgba (182 , 183 , 185 , 0.37 ); } } } } } } } @keyframes sphereAnimation { 0% { width : 10% ; } 100% { width : 90% ; transform : translate (-30% , 5% ); } } @keyframes peopleAnimation { 0% { width : 40% ; } 100% { width : 70% ; transform : translate (90% , -10% ); } } @keyframes pOtherAnimation { 0% { transform : translate (90% , -10% ); } 100% { transform : translate (90% , -15% ); } } @keyframes sOtherAnimation { 0% { transform : translate (-30% , 5% ); } 100% { transform : translate (-30% , 10% ); } } @media (max-width : 768px ) { .login-content { width : 95vw ; height : 95vh ; flex-direction : column; .left-section { flex : 0 0 40% ; } .right-section { flex : 1 ; .top-navigation { display : none; } .form-wrapper { width : 80% ; .nav-item { width : 120px ; font-size : 16px ; } } } } }
2. 编写模板与样式 (Login/index.vue
) 现在,我们来编写 src/views/Login/index.vue
的模板和样式。
请打开 src/views/Login/index.vue
并用以下代码替换其 <template>
和 <style>
部分:
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 <template > <div class ="login-page" > <div class ="login-content" > <div class ="left-section" > <img ref ="peopleRef" src ="@/assets/login/img2.png" class ="people p-animation" alt ="people" /> <img ref ="sphereRef" src ="@/assets/login/img1.png" class ="sphere s-animation" alt ="sphere" /> </div > <div class ="right-section" > <div class ="top-navigation" > <div class ="nav-item" @click ="handleNavClick('首页')" > <span class ="nav-text" > 首页</span > </div > <div class ="nav-item" @click ="handleNavClick('注册')" > <span class ="nav-text" > 注册</span > </div > </div > <div class ="form-wrapper" > <h1 class ="form-title" > 欢迎登录我们的平台</h1 > <el-form ref ="formRef" :model ="formModel" :rules ="rules" @submit.prevent ="handleLogin" > <el-form-item prop ="account" > <el-input v-model ="formModel.account" placeholder ="请输入邮箱或手机号" size ="large" clearable /> </el-form-item > <el-form-item prop ="password" > <el-input v-model ="formModel.password" type ="password" placeholder ="请输入密码" size ="large" show-password clearable /> </el-form-item > <el-form-item prop ="agree" > <div style ="display: flex; justify-content: space-between; align-items: center; width: 100%;" > <el-checkbox v-model ="formModel.agree" > 我同意用户协议和隐私政策 </el-checkbox > <a href ="#" class ="forgot-password" @click.prevent ="handleForgotPassword" > 忘记密码? </a > </div > </el-form-item > <button type ="submit" class ="login-button" @click ="handleLogin" :disabled ="isPending" > {{ isPending ? '登录中...' : '登录' }} </button > </el-form > <div class ="other-login" > <div class ="divider" > <span class ="line" > </span > <span class ="divider-text" > 或使用以下方式登录</span > <span class ="line" > </span > </div > <div class ="other-login-wrapper" > <div class ="other-login-item" @click ="handleThirdPartyLogin('QQ')" > <img src ="@/assets/login/QQ.png" alt ="QQ" /> </div > <div class ="other-login-item" @click ="handleThirdPartyLogin('微信')" > <img src ="@/assets/login/WeChat.png" alt ="WeChat" /> </div > </div > </div > </div > </div > </div > </div > </template > <style lang ="scss" scoped > @use '@/styles/login.scss' ;</style >
此时,Login.vue
的 <script setup>
部分还是空的。但如果你运行项目并访问 /login
页面,应该已经能看到一个完整的静态登录界面了。
第二步:注入灵魂 (逻辑层) 本节目标 : 专注于 逻辑实现 。我们将编写 <script setup>
部分,定义表单的响应式数据模型、声明式校验规则,为后续的登录请求做好万全准备。
请打开 src/views/Login/index.vue
并用以下代码填充其 <script setup>
部分 (这将暂时替换为空的 <script>
):
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 <script setup lang ="ts" > import { ref, reactive } from 'vue' import type { FormInstance , FormRules } from 'element-plus' import { ElMessage } from 'element-plus' import { useEventListener } from '@vueuse/core' const formRef = ref<FormInstance >()const formModel = reactive ({ account : '3381292732@qq.com' , password : '123456' , agree : true , }) const rules : FormRules = { account : [ { required : true , message : '请输入邮箱或手机号' , trigger : 'blur' }, { pattern : /^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|1[3-9]\d{9})$/ , message : '请输入正确的邮箱或手机号' , trigger : 'blur' } ], password : [ { required : true , message : '请输入密码' , trigger : 'blur' }, { min : 6 , max : 20 , message : '密码长度应为6-20位' , trigger : 'blur' } ], agree : [ { validator : (_rule: unknown, value: boolean, callback: (error?: string) => void ) => { if (!value) { callback ('请同意用户协议' ) } else { callback () } }, trigger : 'change' } ] } const peopleRef = ref<HTMLImageElement >()const sphereRef = ref<HTMLImageElement >()const handleForgotPassword = ( ) => ElMessage .info ('忘记密码功能待开发' )const handleThirdPartyLogin = (type: string ) => ElMessage .info (`${type} 登录功能待开发` )const handleNavClick = (item: string ) => ElMessage .info (`${item} 功能待开发` )const handlePeopleAnimationEnd = ( ) => { if (peopleRef.value ) { peopleRef.value .classList .remove ('p-animation' ) peopleRef.value .classList .add ('p-other-animation' ) } } const handleSphereAnimationEnd = ( ) => { if (sphereRef.value ) { sphereRef.value .classList .remove ('s-animation' ) sphereRef.value .classList .add ('s-other-animation' ) } } useEventListener (peopleRef, 'animationend' , handlePeopleAnimationEnd)useEventListener (sphereRef, 'animationend' , handleSphereAnimationEnd)</script >
逻辑解读 :
DOM 引用 : 我们使用 ref()
创建了对模板中 ElForm
元素的引用,这对于后续调用其 validate()
方法至关重要。数据与规则 : formModel
和 rules
的定义是 ElForm
声明式校验的核心。我们将数据和校验规则分离,使代码清晰易懂。事件处理占位 : 我们为页面上的所有可交互元素都绑定了 @click
事件,并链接到 <script>
中对应的 handle...
函数。目前这些函数只做提示,为下一步集成真实逻辑预留了清晰的入口。useEventListener
: 对于动画结束事件,我们使用了 VueUse 的 useEventListener
。这是一个更优的实践,因为它会自动在组件卸载时销毁事件监听器,避免内存泄漏。至此,我们已经拥有了一个外观精美、且具备完整表单校验逻辑的登录页面。
3.6 编码实现:useMutation
与登录逻辑闭环 本章目标 : 我们将对 Login.vue
的 <script setup>
部分进行最终改造。您将学习如何引入并使用 TanStack Query
的 useMutation
来处理表单提交,并通过其强大的 onSuccess
和 onError
回调,将 API 请求、Pinia 状态更新、消息提示和路由跳转等一系列操作优雅地串联起来。
1. 设计思路:为何使用 useMutation
? 技术选型
实现登录请求前
架构师,现在我要实现点击登录按钮后的逻辑了。最直接的想法就是在 handleLogin
函数里 await loginApi()
,然后用 try/catch
分别处理成功和失败的逻辑,对吗?
你
完全正确,这是 Vue 开发的“标准答案”,它能解决问题。但专业的开发者会选择更合适的工具来处理这类“数据变更”操作。
你
这就是我们引入 TanStack Query
的 useMutation
的原因。它能完美地将 “触发动作” 与 “处理动作的副作用” 清晰地分离开来。
你
组件的职责 变得极其简单:只负责在点击按钮后,调用 mutate
函数来 触发 登录动作。而 useMutation
的职责 ,是负责在后台 执行 异步请求,并根据请求结果(成功/失败),在 onSuccess
/ onError
回调中 处理所有后续的副作用 ——比如弹窗提示、更新 Store、页面跳转等。
我明白了!这样我的组件逻辑就非常干净,所有跟登录成功/失败相关的后续操作都内聚在了一起,维护起来一目了然!
2. 完整代码实现 (Login.vue
) 我们将对 Login.vue
的 <script setup>
部分进行最终的整合。
请打开 src/views/Login/index.vue
并用以下完整代码替换整个 <script 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 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 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 <script setup lang ="ts" > import { ref, reactive } from "vue" ;import type { FormInstance , FormRules } from "element-plus" ;import { useEventListener } from "@vueuse/core" ;import { useMutation } from "@tanstack/vue-query" ;import { useUserStore } from "@/stores/user" ;import { useRouter } from "vue-router" ;import { loginApi } from "@/apis/user" ; const userStore = useUserStore ();const router = useRouter ();const formRef = ref<FormInstance >();const formModel = reactive ({ account : "" , password : "" , agree : true , }); const rules : FormRules = { account : [ { required : true , message : "请输入邮箱或手机号" , trigger : "blur" }, { pattern : /^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|1[3-9]\d{9})$/ , message : "请输入正确的邮箱或手机号" , trigger : "blur" , }, ], password : [ { required : true , message : "请输入密码" , trigger : "blur" }, { min : 6 , max : 20 , message : "密码长度应为6-20位" , trigger : "blur" }, ], agree : [ { validator : ( _rule: unknown, value: boolean, callback: (error?: string) => void , ) => { if (!value) { callback ("请同意用户协议" ); } else { callback (); } }, trigger : "change" , }, ], }; const peopleRef = ref<HTMLImageElement >();const sphereRef = ref<HTMLImageElement >();const { mutate, isPending } = useMutation ({ mutationFn : loginApi, onSuccess : (data ) => { ElMessage ({ message : "登录成功!" , type : "success" , showClose : true , }); userStore.setUserInfo (data.result ); router.push ("/" ); }, onError : (error ) => { ElMessage ({ message : "登录失败,请检查账号密码" , type : "error" , showClose : true , }); console .error ("登录错误:" , error); }, }); const handleLogin = async ( ) => { if (!formRef.value ) return ; try { await formRef.value .validate (); mutate ({ account : formModel.account , password : formModel.password , }); } catch { ElMessage ({ message : "请检查输入信息" , type : "error" , showClose : true , }); } }; const handleForgotPassword = ( ) => { ElMessage ({ message : "忘记密码功能待开发" , type : "info" , showClose : true , }); }; const handleThirdPartyLogin = (type: string ) => { ElMessage ({ message : `${type} 登录功能待开发` , type : "info" , showClose : true , }); }; const handleNavClick = (item: string ) => { ElMessage ({ message : `${item} 功能待开发` , type : "info" , showClose : true , }); }; const handlePeopleAnimationEnd = ( ) => { if (peopleRef.value ) { peopleRef.value .classList .remove ("p-animation" ); peopleRef.value .classList .add ("p-other-animation" ); } }; const handleSphereAnimationEnd = ( ) => { if (sphereRef.value ) { sphereRef.value .classList .remove ("s-animation" ); sphereRef.value .classList .add ("s-other-animation" ); } }; useEventListener (peopleRef, "animationend" , handlePeopleAnimationEnd);useEventListener (sphereRef, "animationend" , handleSphereAnimationEnd);</script > <template > <div class ="login-page" > <div class ="login-content" > <div class ="left-section" > <img ref ="peopleRef" src ="@/assets/login/img2.png" class ="people p-animation" alt ="people" /> <img ref ="sphereRef" src ="@/assets/login/img1.png" class ="sphere s-animation" alt ="sphere" /> </div > <div class ="right-section" > <div class ="top-navigation" > <div class ="nav-item" @click ="handleNavClick('首页')" > <span class ="nav-text" > 首页</span > </div > <div class ="nav-item" @click ="handleNavClick('注册')" > <span class ="nav-text" > 注册</span > </div > </div > <div class ="form-wrapper" > <h1 class ="form-title" > 欢迎登录我们的平台</h1 > <el-form ref ="formRef" :model ="formModel" :rules ="rules" @submit.prevent ="handleLogin" > <el-form-item prop ="account" > <el-input v-model ="formModel.account" placeholder ="请输入邮箱或手机号" size ="large" clearable /> </el-form-item > <el-form-item prop ="password" > <el-input v-model ="formModel.password" type ="password" placeholder ="请输入密码" size ="large" show-password clearable /> </el-form-item > <el-form-item > <div style =" display: flex; justify-content: space-between; align-items: center; width: 100%; " > <el-form-item prop ="agree" style ="margin: 0" > <el-checkbox v-model ="formModel.agree" > 我同意用户协议和隐私政策 </el-checkbox > </el-form-item > <a href ="#" class ="forgot-password" @click.prevent ="handleForgotPassword" > 忘记密码? </a > </div > </el-form-item > <button type ="submit" class ="login-button" :disabled ="isPending" @click ="handleLogin" > {{ isPending ? "登录中..." : "登录" }} </button > </el-form > <div class ="other-login" > <div class ="divider" > <span class ="line" > </span > <span class ="divider-text" > 或使用以下方式登录</span > <span class ="line" > </span > </div > <div class ="other-login-wrapper" > <div class ="other-login-item" @click ="handleThirdPartyLogin('QQ')" > <img src ="@/assets/login/QQ.png" alt ="QQ" /> </div > <div class ="other-login-item" @click ="handleThirdPartyLogin('微信')" > <img src ="@/assets/login/WeChat.png" alt ="WeChat" /> </div > </div > </div > </div > </div > </div > </div > </template > <style lang ="scss" scoped > @use "@/styles/login.scss" as *;</style >
3. 代码解读 引入依赖 : 我们引入了 useMutation
, useUserStore
, useRouter
和 loginApi
,这是完成登录闭环所需的所有“零件”。useMutation
配置 :mutationFn: loginApi
: 我们将实际的 API 请求函数 loginApi
告诉 useMutation
。onSuccess(data)
: 当 loginApi
成功返回时(Promise resolved),这个回调会被执行。参数 data
就是 loginApi
返回的完整响应。我们在这里 按顺序执行了三个核心的副作用操作 :弹窗提示、存入 Pinia、跳转页面。onError()
: 当 loginApi
失败时(Promise rejected),这个回调会被执行,我们在这里只做错误提示。mutate
函数 : 这是 useMutation
返回的用于 触发 异步操作的函数。我们在 handleLogin
中,当表单校验通过后,调用 mutate
,TanStack Query
就会自动用表单数据作为参数去执行 loginApi
。isPending
状态 : 这是 useMutation
返回的布尔值 ref
。当 loginApi
正在执行时,isPending
为 true
。我们已将其绑定到登录按钮的 :disabled
和文本上,提供了非常优秀的用户体验。4. 端到端完整流程验证 现在,我们已经完成了整个登录流程的闭环。是时候进行一次完整的测试了。
启动服务 : 确保您的前端 (pnpm run dev
) 和 Mock 后端 (pnpm run mock
) 都在运行。访问页面 : 打开浏览器,访问 http://localhost:5173/login
。校验失败测试 : 清空输入框,直接点击登录按钮,确认 ElForm
的校验提示正常出现。登录失败测试 : 输入错误的密码(例如 wrong_password
),点击登录。确认按钮显示“登录中…”,然后 ElMessage
弹出“登录失败,请检查账号密码”的提示。登录成功测试 :使用预填的正确账号和密码 (3381292732@qq.com
和 123456
)。 点击登录。 观察 : 按钮应显示“登录中…”。验证 : 您应该会看到“登录成功!”的提示,页面应自动跳转到首页 (/
)。验证 (持久化) : 刷新首页 。如果页面右上角依然显示用户名和“退出登录”按钮,证明我们的 Pinia
持久化已成功工作!Token 过期与无感刷新测试 :登录成功后,等待 1 分钟 (我们在后端设置的 accessToken
过期时间)。 在首页,尝试 刷新页面 。 观察 : 页面可能会有极短暂的加载过程,但最终你会发现你 依然保持登录状态 !打开开发者工具的“网络”面板,你会看到一次对 /token/refresh
的请求和一次对 /categories
(或其他首页 API) 的请求。这证明我们的 Axios 拦截器成功实现了无感刷新!3.7 模块提交与总结 至此,我们已经成功构建了一套完整的、企业级的用户认证与授权体系。从支持双 Token 的后端接口,到具备无感刷新能力的 Axios 拦截器,再到与 useMutation
结合的健壮登录流程,我们的应用现在拥有了坚实的安全基石。
现在,是时候将本模块的成果作为一个重要的里程碑,提交到我们的版本库了。
当前任务 : 3.7 - 模块成果提交任务目标 : 将模块三中完成的完整用户认证流程,作为一个核心功能节点提交到 Git 仓库。
命令行操作 请打开终端,并执行以下命令:
将所有已修改和新建的文件添加到 Git 暂存区:
提交代码,并附上符合“约定式提交”规范的 message:
1 git commit -m "feat(auth): implement full user authentication with dual-token refresh"
Commit Message 解读 :
feat
: 表示这是一个新功能 (feature) 的提交。(auth)
: 指明了本次提交的核心范围是“认证” (authentication
) 模块。implement full user authentication with dual-token refresh
: 简明扼要地描述了我们完成的具体工作:实现了完整的用户认证及双 Token 无感刷新机制。提交成功后,您的项目就有了一个清晰的、代表“用户认证功能开发完成”的历史节点。我们已经准备好带着一个安全的“身份”,去探索后续更复杂的业务模块了。
模块四:分类聚合 - 构建动态商品列表页 本章概述 : 欢迎来到模块四。在本章,我们将从“全局视野”转向“业务深耕”,构建电商应用的核心——商品分类聚合页。你将学习如何利用 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 选择和数据交互。 模块五:交互深化 - 构建商品详情页与购物车 本模块任务清单 任务模块 任务名称 核心技术应用与学习要点 后端与数据准备 利用现有 products
接口 json-server
: 无需修改!直接利用 json-server
自动生成的 GET /products/:id
接口来获取商品详情。逻辑层先行 封装 useGoods
Composable TanStack Query
, TypeScript
: 创建 useGoods
Composable 获取商品详情数据,并定义相应的类型接口。页面骨架搭建 搭建商品详情页 (Detail
) 静态布局 Vue Router
, BEM
: 搭建页面基础结构,包括商品信息区、详情展示区和热销推荐区。原子组件:图片交互 实现 GoodsImage
组件 @vueuse/core
: 基于 useMouseInElement
,构建一个高性能的图片预览及放大镜组件。【核心】客户端状态 第一步:创建 cartStore
Pinia
, TypeScript
: 创建 cartStore.ts
,定义 state、getters (总数、总价) 和 actions (添加、删除等) 的基本结构和类型。【核心】客户端状态 第二步:实现“加入购物车” Pinia
, Element Plus
: 在 Detail.vue
中,实现数量选择、点击按钮调用 cartStore
的 addCart
action,并用 ElMessage
给出反馈。UI 组件:购物车 实现 CartPanel
组件 Pinia
, Element Plus
: 创建一个购物车面板组件,从 cartStore
获取数据并动态渲染,实现删除商品等交互。页面整合与动态化 整合 Detail
页面,完成数据流闭环 props
, emits
: 将所有子组件在 Detail.vue
中组装,通过 props
传入数据,响应事件,完成页面功能。模块提交 提交模块成果 Git
: 提交一个功能完整的商品详情与购物车模块。
5.1 后端升级:创建全局商品接口 在构建商品详情页之前,我们需要一个能根据商品 ID 精确查询到单个商品所有信息的接口。回顾我们在模块四构建的 generate-data.js
,你会发现所有商品数据都嵌套在各自的分类之下,我们并没有一个顶层的、包含所有商品的 products
列表。
因此,json-server
无法自动为我们创建 GET /products/:id
这样的便捷接口。在本节,我们将对数据聚合脚本进行一次重要的升级,来解决这个问题。
当前任务 : 5.1 - 创建全局商品接口任务目标 : 升级 scripts/generate-data.js
脚本,在聚合分类数据的同时,额外创建一个 包含所有商品的、平铺的 products
数组 。利用这个新的数据结构,让 json-server
自动为我们提供 GET /products/:id
服务。
1. 设计思路:数据冗余以优化查询 API 设计
商品详情页开发前
架构师,我需要一个 GET /products/:id
接口,但是我们的 db.json
里没有顶级的 products
数组,json-server
生成不了这个路由,怎么办?
架构师
你发现了问题的关键。在真实的数据库设计中,这很常见。数据通常会以“规范化”的形式存储(比如按分类嵌套),但为了“查询性能”,我们经常会创建一些“非规范化”的、冗余的数据视图。
你的意思是,我们要在生成 db.json
的时候,既保留 categories
的嵌套结构,又 额外 创建一个包含所有商品的 products
数组?
架构师
完全正确!我们将在 generate-data.js
脚本中增加一步:每当它扫描到一个商品时,除了把它放进对应的分类里,同时 也把它放进一个全局的、顶层的 products
数组中。
我明白了!这样 db.json
中就有了 products
这个顶级资源。json-server
看到它,就会自动为我们创建 GET /products/:id
接口,问题就解决了!
架构师
是的。这就是用少量的数据冗余,换来 API 设计的简洁和查询效率的提升。
2. 升级 generate-data.js
脚本 我们将对脚本进行关键的重构。
请打开 scripts/generate-data.js
文件,并进行如下修改:
点击查看代码 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 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 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 readImageFiles = (folderPath ) => { if (!fs.existsSync (folderPath)) return []; return fs .readdirSync (folderPath) .filter ((file ) => /\.(jpg|jpeg|png|gif)$/i .test (file)) .sort (); }; const readCategoryData = (categoryId ) => { const categoryPath = path.join (__dirname, `../public/data/${categoryId} ` ); if (!fs.existsSync (categoryPath)) return { subCategories : [], products : [] }; const subCategories = []; const products = []; 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 subCategoryProducts = []; 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 coverPath = path.join (productPath, "cover" ); const galleryPath = path.join (productPath, "gallery" ); const detailPath = path.join (productPath, "detail" ); const coverImages = readImageFiles (coverPath); const galleryImages = readImageFiles (galleryPath); const detailImages = readImageFiles (detailPath); const coverImage = coverImages.length > 0 ? `/data/${categoryId} /${item.name} /${productDir.name} /cover/${coverImages[0 ]} ` : galleryImages.length > 0 ? `/data/${categoryId} /${item.name} /${productDir.name} /gallery/${galleryImages[0 ]} ` : "" ; const gallery = galleryImages.map ( (img ) => `/data/${categoryId} /${item.name} /${productDir.name} /gallery/${img} ` ); const details = detailImages.map ( (img ) => `/data/${categoryId} /${item.name} /${productDir.name} /detail/${img} ` ); const basePrice = faker.commerce .price ({ min : 1000 , max : 50000 , dec : 0 , }); const discount = faker.number .float ({ min : 0.7 , max : 0.95 , fractionDigits : 2 , }); const discountedPrice = Math .round (basePrice * discount); const product = { id : faker.string .uuid (), name : productInfo.name || productDir.name , desc : productInfo.tip || faker.commerce .productDescription (), categoryId : categoryId, categoryName : productInfo.category_name || item.name , cover : coverImage, gallery : gallery, details : details, price : discountedPrice.toString (), originalPrice : basePrice.toString (), sales : faker.number .int ({ min : 10 , max : 9999 }), rating : faker.number .float ({ min : 4.0 , max : 5.0 , fractionDigits : 1 }), stock : faker.number .int ({ min : 0 , max : 999 }), url : productInfo.url || "" , coverImage : coverImage, detailImages : details, tip : productInfo.tip , category_id : productInfo.category_id , tab_name : productInfo.tab_name , }; subCategoryProducts.push (product); products.push (product); } subCategories.push ({ id : `${categoryId} -${item.name} ` .replace (/[^a-zA-Z0-9-]/g , "-" ), name : item.name , products : subCategoryProducts, }); } } return { subCategories, products }; }; module .exports = () => { const staticDataPath = path.join (__dirname, "mock-data.json" ); const staticData = JSON .parse (fs.readFileSync (staticDataPath, "utf-8" )); const allProducts = []; const categories = staticData.categories .map ((category ) => { const categoryData = readCategoryData (category.id ); allProducts.push (...categoryData.products ); return { ...category, banners : readCategoryBanners (category.id ), subCategories : categoryData.subCategories , }; }); const data = { users : [], categories : categories, products : allProducts, 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; };
现在,我们的 db.json
结构已经更新。让我们来验证新的接口是否已经自动生成。
重新生成数据并启动服务 : 在终端中运行 pnpm run mock
。premock
脚本会自动执行我们更新后的 generate-data.js
,生成新的 db.json
。测试接口 :在 mock/db.json
的 products
数组中,随便复制一个商品的 id
值。 打开浏览器,在地址栏输入 http://localhost:3001/products/
并粘贴你复制的 ID。 预期结果 : 浏览器中会显示该商品 ID 对应的 单个商品 的完整 JSON 对象。我们通过一次精巧的脚本升级,成功地为 json-server
提供了它所需要的数据结构,从而“解锁”了 GET /products/:id
这个对商品详情页至关重要的接口。现在,后端已万事俱备,我们可以进入下一节,开始封装消费这个接口的前端逻辑了。
5.2 逻辑层先行:封装 useGoods
Composable 在构建 Detail
页面之前,我们先来打造它的“数据引擎”。我们将创建一个名为 useGoods
的 Composable,它的唯一职责就是:根据路由中的商品 ID,获取并提供该商品的详细数据及其加载状态。
这再一次实践了“关注点分离”的核心思想:让组件专注于“如何展示”,让 Composable 专注于“如何获取”。
当前任务 : 5.2 - 封装 useGoods
Composable任务目标 : 创建 apis/detail.ts
来封装商品详情的 API 请求,并在 views/Detail/composables/
目录下创建 useGoods.ts
,利用 TanStack Query
将获取单个商品数据的逻辑封装起来。
1. 设计思路:模式的复用与演进 Composable 设计
开始封装 useGoods
架构师,我要开始封装获取商品详情的逻辑了。这个流程我感觉很熟悉了:它需要依赖路由 id
,需要用 TanStack Query
来管理异步状态… 这和我们上一章做的 useCategory
是不是几乎一模一样?
架构师
你已经完全掌握了这种模式的精髓!完全正确。useGoods
和 useCategory
在 结构和思想 上是完全一致的。它们都遵循“依赖路由参数的异步数据获取”这一通用模式。
架构师
细微的差别在于 具体的数据 。useGoods
获取的是单个 Product
对象,而 useCategory
获取的是 TopCategory
对象。所以我们需要为它创建新的 API 函数和类型定义。但核心的、使用 computed
id 作为 queryKey
的 TanStack Query
模式,是完全可以复用的。
我明白了。这就是建立“最佳实践”的好处,遇到相似的问题,我们就可以用同样优雅的模式去解决。
2. 创建类型与 API 2.1 在 src/types/
目录下,创建一个新文件 detail.ts
为了保持类型文件的职责单一,我们将商品详情页相关的类型单独存放。我们将严格按照你提供的 JSON 数据结构来定义类型。
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 export interface GoodsDetail { id : string ; name : string ; desc : string ; categoryId : string ; categoryName : string ; cover : string ; gallery : string []; details : string []; price : string ; originalPrice : string ; sales : number ; rating : number ; stock : number ; url : string ; }
类型精确匹配 : 我们定义的 GoodsDetail
接口精确地反映了 GET /products/:id
接口返回的数据结构,包括 coverImage
, detailImages
, category_name
等所有字段。2.2 创建 src/apis/detail.ts
这个文件将专门负责商品详情相关的 API 请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 import httpInstance from "@/utils/http" ;import type { GoodsDetail } from "@/types/detail" ;export const getDetailAPI = (id : string ): Promise <GoodsDetail > => { return httpInstance.get (`/products/${id} ` ); };
3. 创建 useGoods
Composable 现在,我们来构建核心的 Composable。
首先,在 src/views/
目录下创建一个 Detail
文件夹,并在其中创建 composables
文件夹。然后,在 composables
文件夹内创建 useGoods.ts
文件。
1 2 3 4 5 6 7 src/ └── views/ ├── Category/ └── Detail/ <-- 新建 ├── composables/ <-- 新建 │ └── useGoods.ts <-- 新建 └── index.vue <-- 后续创建
请在 src/views/Detail/composables/useGoods.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 import { computed } from 'vue' import { useRoute } from 'vue-router' import { useQuery } from '@tanstack/vue-query' import { getDetailAPI } from '@/apis/detail' export function useGoods ( ) { const route = useRoute (); const goodsId = computed (() => route.params .id as string ); const { data : goodsData, isLoading, isError } = useQuery ({ queryKey : ['goodsDetail' , goodsId], queryFn : ({ queryKey } ) => { const id = queryKey[1 ] as string ; return getDetailAPI (id); }, enabled : computed (() => !!goodsId.value ) }); return { goodsData, isLoading, isError } }
4. 代码解读 熟悉的模式 : 看到这段代码,你应该会会心一笑。它的结构和 useCategory
几乎完全一样!我们定义了一个响应式的 goodsId
,将它作为 queryKey
的一部分,然后 TanStack Query
就会为我们处理好所有的数据获取、缓存和自动更新。职责的再次确认 : 这个 Composable 的职责非常纯粹:提供单个商品的详细数据及其状态 。它不关心 UI 如何渲染,也不关心用户交互。健壮性 : enabled: computed(() => !!goodsId.value)
再次确保了我们只在 URL 中确实存在商品 ID 时才发起请求。我们已经成功地为商品详情页打造了一个坚固、可靠且独立的数据引擎。在下一节,我们将开始搭建详情页的视觉骨架,并看看消费这个 Composable 是多么简单。
5.3 编码实现:详情页骨架与图片放大镜 本节目标 : 我们将分两步,完整地构建出商品详情页的核心视觉与交互。首先,我们将从零开始,利用 @vueuse/core
打造一个技术含量很高的 GoodsImage
图片放大镜组件。然后,我们将搭建 Detail.vue
页面的整体布局,并将我们刚刚完成的 GoodsImage
组件“安放”进去,最终完成一个带有加载状态的、结构清晰的页面骨架。
第一步:【核心】构建 GoodsImage
图片预览组件 这是本模块技术最密集的部分。我们将创建一个独立的、可复用的图片预览组件,它包含“缩略图点击切换”和“鼠标悬停放大”两大核心功能。
文件路径 : src/views/Detail/components/GoodsImage.vue
任务目标 : 创建一个独立的图片预览组件。学习如何使用 @vueuse/core
的 useMouseInElement
来响应式地获取鼠标位置,并通过 watch
监听和一系列计算,最终实现一个高性能的放大镜效果。
1. 逻辑层 (<script setup>
):交互的核心驱动 GoodsImage
组件的灵魂在于其 <script>
部分,这里处理了所有的状态和复杂的坐标计算。我们将分步进行讲解。
请在 src/views/Detail/components/
目录下创建 GoodsImage.vue
文件,并写入以下 <script 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 50 51 <script lang="ts" setup> import { ref, watch, computed } from 'vue' import type { GoodsDetail } from '@/types/detail' import { useMouseInElement } from '@vueuse/core' defineProps<{ products : GoodsDetail }>() const activeIndex = ref (0 )const switchImage = (i : number ) => { activeIndex.value = i } const target = ref (null ) const { elementX, elementY, isOutside } = useMouseInElement (target)const left = ref (0 )const top = ref (0 )const positionX = ref (0 )const positionY = ref (0 )watch ([elementX, elementY, isOutside], () => { if (isOutside.value ) return left.value = elementX.value - 100 top.value = elementY.value - 100 if (left.value < 0 ) left.value = 0 if (left.value > 200 ) left.value = 200 if (top.value < 0 ) top.value = 0 if (top.value > 200 ) top.value = 200 positionX.value = -left.value * 2 positionY.value = -top.value * 2 }) const showMagnifier = computed (() => { return !isOutside.value }) </script>
逻辑深度解读 :
缩略图切换 : 我们用一个 activeIndex
的 ref
来记录当前激活的缩略图索引。@mouseenter
事件会调用 switchImage
方法来更新这个索引,从而切换中图的 src
。获取鼠标位置 : 我们引入了 @vueuse/core
的 useMouseInElement
。把它应用到中图的 DOM 元素上 (ref="target"
),它就会为我们提供三个 响应式 的 ref:elementX
(鼠标相对元素左上角的 X 坐标)、elementY
(Y 坐标) 和 isOutside
(布尔值,表示鼠标是否在元素外)。显示控制 : 我们监听 isOutside
,当鼠标离开时,放大镜和滑块会隐藏。滑块位置计算 : 滑块的尺寸是 200x200
。为了让鼠标始终在滑块中心,我们将滑块的 left
设置为 鼠标X坐标 - 100
。边界控制确保滑块不会移出中图 400x400
的范围。大图定位计算 : 这是放大镜效果的核心。大图的尺寸是 800x800
,是中图的两倍(放大系数为 2)。当滑块向右移动 1px
时,大图的背景 (background-position
) 需要向 左 移动 2px
,从而在放大镜中“揭示”出右侧的内容。因此,positionX = -left * 2
。2. 视图层 (<template>
) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <template > <div class ="product-image" > <div class ="middle" ref ="target" > <img draggable ="false" :src ="products.gallery[activeIndex]" alt ="" /> <div class ="layer" v-show ="showMagnifier" :style ="{ left: `${left}px`, top: `${top}px` }" > </div > </div > <ul class ="small" > <li v-for ="(img, i) in products.gallery" :key ="i" @mouseenter ="switchImage(i)" :class ="{ active: activeIndex === i }" > <img draggable ="false" :src ="img" alt ="" /> </li > </ul > <div class ="large" v-show ="showMagnifier" :style ="[{ backgroundImage: `url(${products.gallery[activeIndex]})`, backgroundPositionX: `${positionX}px`, backgroundPositionY: `${positionY}px`, }]" ></div > </div > </template >
3. 样式层 (<style>
) 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 <style scoped lang="scss"> .product-image { width : 480px ; height : 400px ; position : relative; display : flex; .middle { width : 400px ; height : 400px ; background : #f5f5f5 ; position : relative; cursor : move; } .large { position : absolute; top : 0 ; left : 412px ; width : 400px ; height : 400px ; z-index : 500 ; box-shadow : 0 0 10px rgba (0 ,0 ,0 ,0.1 ); background-repeat : no-repeat; background-size : 800px 800px ; background-color : #f8f8f8 ; } .layer { width : 200px ; height : 200px ; background : rgba (0 ,0 ,0 ,0.2 ); left : 0 ; top : 0 ; position : absolute; } .small { width : 80px ; li { width : 68px ; height : 68px ; margin-left : 12px ; margin-bottom : 15px ; cursor : pointer; &:hover , &.active { border : 2px solid $GLColor ; } } } } </style>
第二步:组装 Detail
页面骨架 现在我们最复杂的“零件”已经造好,组装主页面就变得非常简单了。
请在 src/views/Detail/
目录下创建 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 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 <script setup lang ="ts" > import { useGoods } from './composables/useGoods' import GoodsImage from './components/GoodsImage.vue' const { goodsData, isLoading } = useGoods ()</script > <template > <div class ="detail-page" > <div class ="container" v-if ="!isLoading && goodsData" > <el-breadcrumb class ="detail-page__breadcrumb" separator =">" > <el-breadcrumb-item :to ="{ path: '/' }" > 首页</el-breadcrumb-item > <el-breadcrumb-item :to ="{ path: `/category/${goodsData.categoryId}` }" > {{ goodsData.categoryName }} </el-breadcrumb-item > <el-breadcrumb-item > {{ goodsData.name }}</el-breadcrumb-item > </el-breadcrumb > <div class ="detail-page__info-panel" > <el-row :gutter ="20" > <el-col :span ="12" > <GoodsImage :products ="goodsData" /> </el-col > <el-col :span ="12" > </el-col > </el-row > </div > <div class ="detail-page__detail-panel" > <h2 > 商品详情</h2 > <div v-for ="img in goodsData.details" :key ="img" > <img :src ="img" alt ="" > </div > </div > </div > <div class ="container loading-container" v-else > <el-skeleton style ="width: 100%" animated > </el-skeleton > </div > </div > </template > <style scoped lang ='scss' > .detail-page { background : #f5f5f5 ; padding-top : 20px ; &__breadcrumb { padding : 20px 0 ; } &__info-panel { background : #fff ; padding : 30px ; } &__detail-panel { background : #fff ; margin-top : 20px ; padding : 30px ; &__title { font-size : 22px ; font-weight : bold; margin-bottom : 20px ; } &__content { // 图片居中 display : flex; justify-content : center; align-items : center; width : 100% ; height : 100% ; } } } .info-panel { &__media { width : 580px ; height : 600px ; } &__spec { flex : 1 ; } } .spec { &__name { font-size : 22px ; } &__desc { color : #999 ; margin-top : 10px ; } &__service { margin-top : 20px ; .service-label { width : 50px ; color : #999 ; } } } .loading-container { background-color : #fff ; min-height : 800px ; } </style >
本节小结 : 我们通过“自底向上”的策略,先集中精力攻克了技术最复杂的 GoodsImage
组件,掌握了 useMouseInElement
和 watch
监听的核心用法。然后,我们轻松地搭建了 Detail
页面的宏观布局,并将 GoodsImage
作为一个“黑盒”组件组装进去,完美地实践了组件化开发的思想。
5.4 【核心】从零到一:构建购物车完整流程 本章目标 : 结束了商品详情的探索,我们现在进入交易流程的核心——购物车。本节,我们将从零开始,构建一个功能完整的购物车页面。核心是 深度实践 Pinia ,您将学习如何设计一个包含 state
, getters
和 actions
的复杂 store
来管理购物车的完整状态。同时,我们将应用 Element Plus 的 ElTable
组件来优雅地展示和操作这份复杂的状态。
第一步:奠定基石 - 创建 cartStore
在构建任何 UI 之前,我们必须先打造它的“数据大脑”。购物车的所有状态——商品列表、选中状态、总价、总数——都应该由一个专门的 cartStore
来统一管理。
1. 准备工作:定义类型 (CartItem
) 我们首先为购物车中的商品项定义一个清晰的 TypeScript 类型接口。
文件路径 : src/types/cart.ts
任务目标 : 创建购物车项的 TypeScript 类型接口 CartItem
,严格使用 id
作为唯一标识 。
请在 src/types/
目录下创建 cart.ts
文件,并写入以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 export interface CartItem { id : string ; name : string ; picture : string ; price : string ; count : number ; selected : boolean ; attrsText ?: string ; }
2. 创建 cartStore.ts
类型就绪后,我们来创建 store
文件本身。
文件路径 : src/stores/cartStore.ts
任务目标 : 创建 cartStore
,定义其 state
, getters
, 和 actions
,并开启持久化。
请在 src/stores/
目录下创建 cartStore.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 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 import { defineStore } from "pinia" ;import { ref, computed } from "vue" ;import type { CartItem } from "@/types/cart" ;import { useUserStore } from "./user" ;export const useCartStore = defineStore ("cart" , () => { const userStore = useUserStore (); const userInfo = computed ( () => userStore.userInfo as { accessToken ?: string } ); const cartList = ref<CartItem []>([]); const addCart = (goods : CartItem ) => { if (userInfo.value .accessToken ) { const existingItem = cartList.value .find ( (item : CartItem ) => item.id === goods.id ); if (existingItem) { existingItem.count += goods.count ; } else { cartList.value .push (goods); } ElMessage .success ("添加成功" ); } else { ElMessage .warning ("请先登录" ); } }; const delCart = (id : string ) => { const index = cartList.value .findIndex ((item : CartItem ) => item.id === id); if (index > -1 ) { cartList.value .splice (index, 1 ); ElMessage .info ("商品已删除" ); } }; const updateCartSelected = (id : string , selected : boolean ) => { const item = cartList.value .find ((item : CartItem ) => item.id === id); if (item) { item.selected = selected; } }; const updateAllCart = (selected : boolean ) => { cartList.value .forEach ((item : CartItem ) => (item.selected = selected)); }; const clearCart = ( ) => { cartList.value = []; }; const isAllSelected = computed ( () => cartList.value .length > 0 && cartList.value .every ((item : CartItem ) => item.selected ) ); const totalCount = computed (() => cartList.value .reduce ((sum : number , item : CartItem ) => sum + item.count , 0 ) ); const totalPrice = computed (() => cartList.value .reduce ( (sum : number , item : CartItem ) => sum + item.count * Number (item.price ), 0 ) ); const selectedCount = computed (() => cartList.value .filter ((item : CartItem ) => item.selected ) .reduce ((sum : number , item : CartItem ) => sum + item.count , 0 ) ); const selectedPrice = computed (() => cartList.value .filter ((item : CartItem ) => item.selected ) .reduce ((sum : number , item : CartItem ) => sum + item.count * Number (item.price ), 0 ) ); return { cartList, addCart, delCart, updateCartSelected, updateAllCart, clearCart, isAllSelected, totalCount, totalPrice, selectedCount, selectedPrice, }; });
第二步:用 ElTable
构建购物车页面 (CartPage.vue
) Store 已经就绪,现在我们可以安心地构建视图了。
1. 创建页面和路由 在 src/views/
目录下创建 Cart
文件夹及 index.vue
文件。 在 src/router/index.ts
中添加购物车页面的路由规则:1 2 3 4 5 6 7 { path : '/cart' , component : () => import ('@/views/Cart/index.vue' ) }
2. 完整代码实现 请在 src/views/Cart/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 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 <script setup lang ="ts" > import { useCartStore } from '@/stores/cartStore' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' const cartStore = useCartStore ()const router = useRouter ()const handleSettlement = ( ) => { if (cartStore.selectedCount === 0 ) { ElMessage .warning ('请至少选择一件商品进行结算' ) return } router.push ('/checkout' ) } </script > <template > <div class ="cart-page" > <div class ="container" > <el-breadcrumb separator =">" > <el-breadcrumb-item :to ="{ path: '/' }" > 首页</el-breadcrumb-item > <el-breadcrumb-item > 购物车</el-breadcrumb-item > </el-breadcrumb > <div class ="cart-page__content" > <el-table :data ="cartStore.cartList" class ="cart-page__table" style ="width: 100%" > <template #empty > <el-empty description ="购物车中还没有商品" > <el-button type ="primary" @click ="router.push('/')" > 随便逛逛</el-button > </el-empty > </template > <el-table-column width ="60" align ="center" > <template #header > <el-checkbox :model-value ="cartStore.isAllSelected" @change ="(selected) => cartStore.updateAllCart(selected as boolean)" /> </template > <template #default ="{ row }" > <el-checkbox :model-value ="row.selected" @change ="(selected) => cartStore.updateCartSelected(row.id, selected as boolean)" /> </template > </el-table-column > <el-table-column label ="商品信息" width ="400" > <template #default ="{ row }" > <div class ="goods-info" > <router-link :to ="`/product/${row.id}`" > <img :src ="row.picture" :alt ="row.name" > </router-link > <div > <p class ="name" > {{ row.name }}</p > <p class ="attr" > {{ row.attrsText }}</p > </div > </div > </template > </el-table-column > <el-table-column label ="单价" width ="220" align ="center" > <template #default ="{ row }" > <span class ="price" > ¥ {{ row.price }}</span > </template > </el-table-column > <el-table-column label ="数量" width ="180" align ="center" > <template #default ="{ row }" > <el-input-number v-model ="row.count" :min ="1" /> </template > </el-table-column > <el-table-column label ="小计" width ="180" align ="center" > <template #default ="{ row }" > <span class ="price subtotal" > ¥ {{ (Number(row.price) * row.count).toFixed(2) }}</span > </template > </el-table-column > <el-table-column label ="操作" width ="140" align ="center" > <template #default ="{ row }" > <el-popconfirm title ="确认删除吗?" @confirm ="cartStore.delCart(row.id)" > <template #reference > <a href ="javascript:;" class ="action-link" > 删除</a > </template > </el-popconfirm > </template > </el-table-column > </el-table > </div > <div class ="cart-page__action-bar" > <div class ="batch-info" > 共 {{ cartStore.totalCount }} 件商品,已选择 {{ cartStore.selectedCount }} 件,商品合计: <span class ="total-price" > ¥ {{ cartStore.selectedPrice.toFixed(2) }} </span > </div > <div class ="buttons" > <el-button size ="large" type ="primary" @click ="handleSettlement" > 下单结算</el-button > </div > </div > </div > </div > </template > <style lang ="scss" scoped > .cart-page { margin-top : 20px ; .container { max-width : 1240px ; margin : 0 auto; } &__content { background : #fff ; margin-top : 20px ; } &__table { .goods-info { display : flex; align-items : center; img { width : 100px ; height : 100px ; } div { width : 280px ; font-size : 16px ; padding-left : 10px ; .name { line-height : 1.2 ; white-space : nowrap; overflow : hidden; text-overflow : ellipsis; } .attr { font-size : 14px ; color : #999 ; } } } .price { font-size : 16px ; color : $priceColor; } .subtotal { font-weight : bold; } .action-link { color : $GLColor; &:hover { color : lighten ($GLColor, 10% ); } } } &__action-bar { display : flex; background : #fff ; margin-top : 20px ; height : 80px ; align-items : center; font-size : 16px ; justify-content : space-between; padding : 0 30px ; .batch-info { color : #666 ; } .total-price { font-size : 18px ; margin-right : 20px ; font-weight : bold; color : $priceColor; } } } </style >
5.5 集成购物车功能,完成交互闭环 本节目标 : 我们将把 cartStore
的能力注入到两个关键组件中:商品详情页 (Detail.vue
) 和 全局顶栏 (LayoutHeader.vue
) 。您将学习如何在一个组件中调用 store
的 action
来修改状态(添加商品),并在另一个组件中响应式地展示 store
的 state
和 getters
(更新购物车图标数量),从而深度理解 Pinia 作为“单一事实来源”在跨组件通信中的核心作用。
第一步:在商品详情页实现“加入购物车” 这是将商品送入购物车的唯一入口,也是连接“商品浏览”和“商品购买”两大流程的关键桥梁。
文件路径 : src/views/Detail/index.vue
任务目标 : 在商品详情页的右侧信息区,添加数量选择器 (ElInputNumber
) 和“加入购物车”按钮,并为其绑定事件,调用 cartStore
中的 addCart
action。
1. 更新 <script setup>
我们需要引入 cartStore
,并创建一个 ref
来绑定数量选择器的值,最后编写一个事件处理函数来执行添加操作。
请打开 src/views/Detail/index.vue
并更新其 <script 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 <script setup lang ="ts" > import { ref } from 'vue' import { useGoods } from './composables/useGoods' import GoodsImage from './components/GoodsImage.vue' import { useCartStore } from '@/stores/cartStore' const { goodsData, isLoading } = useGoods ()const cartStore = useCartStore () const count = ref (1 ) const addToCart = ( ) => { if (goodsData.value ) { cartStore.addCart ({ id : goodsData.value .id , name : goodsData.value .name , picture : goodsData.value .cover , price : goodsData.value .price , count : count.value , selected : true , attrsText : '' , skuId : '' , }) } } </script >
代码解读 :
我们引入并实例化了 cartStore
,这样在本组件内就可以随时访问它的 state 和 actions。 我们创建了 count
ref,它将与 ElInputNumber
组件进行 v-model
双向绑定。 addToCart
函数是核心。它会构建一个符合 CartItem
类型的对象,然后把它作为参数传递给 cartStore.addCart
action。所有复杂的添加逻辑(判断商品是否存在、更新数量等)都在 store
内部完成,组件本身只负责“通知”store
。2. 更新 <template>
现在,我们在右侧信息栏 (<el-col :span="12">
) 中,添加数量选择器和按钮。
请更新 src/views/Detail/index.vue
的 <template>
部分:
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 <template > <div class ="detail-page" > <div class ="container" v-if ="!isLoading && goodsData" > <div class ="detail-page__info-panel" > <el-row :gutter ="20" > <el-col :span ="12" > <GoodsImage :products ="goodsData" /> </el-col > <el-col :span ="12" > <div class ="info-panel__spec" > <h2 class ="spec__name" > {{ goodsData.name }}</h2 > <p class ="spec__desc" > {{ goodsData.desc }}</p > <p class ="spec__price" > ¥ {{ goodsData.price }}</p > <div class ="spec__quantity" > <span class ="quantity-label" > 数量</span > <el-input-number v-model ="count" :min ="1" /> </div > <div class ="spec__actions" > <el-button size ="large" type ="primary" @click ="addToCart" > 加入购物车</el-button > </div > </div > </el-col > </el-row > </div > </div > </div > </template >
3. 添加样式 最后,为我们新增的元素添加一些样式。
请在 src/views/Detail/index.vue
的 <style>
块中补充以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 .spec { &__price { font-size : 22px ; color : $priceColor ; margin-top : 10px ; } &__quantity { display : flex; align-items : center; margin-top : 20px ; .quantity-label { width : 50px ; color : #999 ; } } &__actions { margin-top : 30px ; } }
第二步:在全局顶栏添加入口点 用户需要一个在任何页面都能看到自己购物车状态、并能快速进入购物车页面的入口。全局顶栏 (LayoutHeader.vue
) 是放置这个入口的最佳位置。
文件路径 : src/views/Layout/components/LayoutHeader.vue
任务目标 : 在顶栏右侧添加一个购物车图标,并利用 cartStore
的 getter
实时显示购物车中的商品总数。点击该图标可以跳转到购物车页面。
请打开 src/views/Layout/components/LayoutHeader.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 <script setup lang ="ts" > import { useCategoryStore } from '@/stores/categoryStore' import { useCartStore } from '@/stores/cartStore' ; import { onMounted } from 'vue' const categoryStore = useCategoryStore ()const cartStore = useCartStore (); onMounted (() => { categoryStore.getCategory () }) </script > <template > <header class ="app-header" > <div class ="container" > <ul class ="app-header__nav" > </ul > <div class ="app-header__actions" > <div class ="app-header__cart" > <router-link to ="/cart" > <i-ep-shopping-cart /> <em > {{ cartStore.totalCount }}</em > </router-link > </div > </div > </div > </header > </template > <style lang ="scss" scoped > .app-header__actions { display : flex; align-items : center; } .app-header__cart { margin-left : 20px ; a { position : relative; display : block; font-size : 22px ; color : #666 ; em { position : absolute; top : -5px ; right : -12px ; height : 16px ; line-height : 1 ; min-width : 16px ; text-align : center; border-radius : 8px ; background : $priceColor; color : #fff ; font-size : 12px ; font-style : normal; padding : 2px 4px ; } } } </style >
响应式更新的魔力 : 我们仅仅是在模板中使用了 cartStore.totalCount
这个 getter
。由于 Pinia 的 getters
本质上是 computed
属性,它们是完全响应式的。现在,当你在商品详情页调用 cartStore.addCart()
导致 cartList
状态变化时,totalCount
getter
会自动重新计算,并 立即将新值反映到 LayoutHeader.vue
的角标上 。这就是 Pinia 作为单一事实来源带来的巨大便利。