模块二:通用布局与首页开发 本模块任务清单 产品经理 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
: 简明扼要地描述了我们完成的具体工作:构建了主布局和动态化的首页。提交成功后,您的项目就有了一个清晰的、代表“首页开发完成”的历史节点。我们已经准备好进入下一个模块,继续构建登录与用户认证功能。