Vue电商项目实战-2
Home模块开发
- 整体结构拆分和分类实现
- 将Home模块划分成了左侧分类和轮番图、新鲜好物、人气推荐和产品列表五个部分,在Home文件夹下创建文件夹components用于存放各个部分
- 在home中的Index.vue文件中实现各个部分的引入
- 分类实现通过准备模板后的用Pinia中的数据渲染来实现,引入UseCategoryStore,通过三个v-for实现数据左侧分类的渲染
<script setup>
import { useCategoryStore } from '@/stores/category'
import { includeBooleanAttr } from '@vue/shared';
import indexVue from '../index.vue';
const categoryStore = useCategoryStore()
</script>
<template>
<div class="home-category">
<ul class="menu">
<li v-for="item in categoryStore.categoryList" :key="item.id">
<RouterLink to="/">{{ item.name }}</RouterLink>
<RouterLink v-for="i in item.children.slice(0,2)" :key="i" to="/">{{i.name}}</RouterLink>
<!-- 弹层layer位置 -->
<div class="layer">
<h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4>
<ul>
<li v-for="i in item.goods" :key="i.id">
<RouterLink to="/">
<img :src="i.picture" alt="" />
<div class="info">
<p class="name ellipsis-2">
{{i.name}}
</p>
<p class="desc ellipsis">{{i.desc}}</p>
<p class="price"><i>¥</i>{{i.price}}</p>
</div>
</RouterLink>
</li>
</ul>
</div>
</li>
</ul>
</div>
</template>
<style scoped lang='scss'>
.home-category {
width: 250px;
height: 500px;
background: rgba(0, 0, 0, 0.8);
position: relative;
z-index: 99;
.menu {
li {
padding-left: 40px;
height: 55px;
line-height: 55px;
&:hover {
background: $xtxColor;
}
a {
margin-right: 4px;
color: #fff;
&:first-child {
font-size: 16px;
}
}
.layer {
width: 990px;
height: 500px;
background: rgba(255, 255, 255, 0.8);
position: absolute;
left: 250px;
top: 0;
display: none;
padding: 0 15px;
h4 {
font-size: 20px;
font-weight: normal;
line-height: 80px;
small {
font-size: 16px;
color: #666;
}
}
ul {
display: flex;
flex-wrap: wrap;
li {
width: 310px;
height: 120px;
margin-right: 15px;
margin-bottom: 15px;
border: 1px solid #eee;
border-radius: 4px;
background: #fff;
&:nth-child(3n) {
margin-right: 0;
}
a {
display: flex;
width: 100%;
height: 100%;
align-items: center;
padding: 10px;
&:hover {
background: #e3f9f4;
}
img {
width: 95px;
height: 95px;
}
.info {
padding-left: 10px;
line-height: 24px;
overflow: hidden;
.name {
font-size: 16px;
color: #666;
}
.desc {
color: #999;
}
.price {
font-size: 22px;
color: $priceColor;
i {
font-size: 16px;
}
}
}
}
}
}
}
// 关键样式 hover状态下的layer盒子变成block
&:hover {
.layer {
display: block;
}
}
}
}
}
</style>
- banner轮播页设计
- 从Elementplus上获得模板
- 在apis中创建Home.js用于封装接口
- 组件渲染数据,创建响应数据BnanerList,书写getBanner来实现数据获取,在onMounted中调用getBanner
- 模板中用v-for循环实现遍历
import httpInstance from '@/utils/https'
export function getBannerAPI(){
return httpInstance({
url:'home/banner'
})
}
<script setup>
import { getBannerAPI } from '@/apis/home'
import { ref,onMounted } from 'vue'
const bannerList = ref([])
const getBanner=async()=>{
const res = await getBannerAPI()
console.log(res)
bannerList.value=res.result
}
onMounted(()=>getBanner())
</script>
<template>
<div class="home-banner">
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
</div>
</template>
<style scoped lang='scss'>
.home-banner {
width: 1240px;
height: 500px;
position: absolute;
left: 0;
top: 0;
z-index: 98;
img {
width: 100%;
height: 500px;
}
}
</style>
- 面板组件封装
- 封装的核心思想:把可复写的结构只写一次,把可能变化的部分抽象为组件参数(props/插槽)
- 实现步骤:
- 准备静态模板
- 抽象可变部分
- 纯文本抽象为prop插入
- 复杂的内容抽象为插槽插入
<script setup>
defineProps({
Title:{
type:String
},
subTitle:{
type:String
}
})
</script>
<template>
<div class="home-panel">
<div class="container">
<div class="head">
<!-- 主标题和副标题 -->
<h3>
{{ Title }}<small>{{ subTitle }}</small>
</h3>
</div>
<!-- 主体内容区域 -->
<slot />
</div>
</div>
</template>
<style scoped lang='scss'>
.home-panel {
background-color: #fff;
.head {
padding: 40px 0;
display: flex;
align-items: flex-end;
h3 {
flex: 1;
font-size: 32px;
font-weight: normal;
margin-left: 6px;
height: 35px;
line-height: 35px;
small {
font-size: 16px;
color: #999;
margin-left: 20px;
}
}
}
}
</style>
- 新鲜好物、热卖单品业务实现
- 引入模板、运用封装的组件进行从写
- 在home.js写接口,在HomeNew中创建响应数据NewList,书写getNewList来实现数据获取,在onMounted中调用getNewList
- 同理:在home.js写接口,在HotNew中创建响应数据HotList,书写getHotList来实现数据获取,在onMounted中调用getHotList
export const getNewAPI=()=>{
return httpInstance({
url:'home/new'
})
}
export const getHotAPI=()=>{
return httpInstance({
url:'home/hot'
})
}
<script setup>
import HomePannel from './HomePannel.vue';
import { getHotAPI } from '@/apis/home.js';
import { onMounted,ref } from 'vue';
const hotList = ref([])
const getHotList = async()=>{
const res= await getHotAPI()
hotList.value=res.result
}
onMounted(()=>getHotList())
</script>
<template>
<HomePannel Title="热品推荐" subTitle="当下爆款 疯狂推荐">
<ul class="goods-list">
<li v-for="item in hotList" :key="item.id">
<RouterLink to="/">
<img :src="item.picture" alt="" />
<p class="name">{{ item.name }}</p>
<p class="price">{{ item.alt }}</p>
</RouterLink>
</li>
</ul>
</HomePannel>
</template>
<style scoped lang='scss'>
.goods-list {
display: flex;
justify-content: space-between;
height: 406px;
li {
width: 306px;
height: 406px;
background: #f0f9f4;
transition: all .5s;
&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}
img {
width: 306px;
height: 306px;
}
p {
font-size: 22px;
padding-top: 12px;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.price {
color: $priceColor;
}
}
}
</style>
<script setup>
import HomePannel from './HomePannel.vue';
import { getNewAPI } from '@/apis/home.js';
import { onMounted,ref } from 'vue';
const newList = ref([])
const getNewList = async()=>{
const res= await getNewAPI()
newList.value=res.result
}
onMounted(()=>getNewList())
</script>
<template>
<HomePannel Title="好物分享" subTitle="完美好物 敬请挑选">
<ul class="goods-list">
<li v-for="item in newList" :key="item.id">
<RouterLink to="/">
<img :src="item.picture" alt="" />
<p class="name">{{ item.name }}</p>
<p class="price">¥{{ item.price }}</p>
</RouterLink>
</li>
</ul>
</HomePannel>
</template>
<style scoped lang='scss'>
.goods-list {
display: flex;
justify-content: space-between;
height: 406px;
li {
width: 306px;
height: 406px;
background: #f0f9f4;
transition: all .5s;
&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}
img {
width: 306px;
height: 306px;
}
p {
font-size: 22px;
padding-top: 12px;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.price {
color: $priceColor;
}
}
}
</style>
- 图片懒加载指令实现
- 通过官方网站提示在main.js文件中实现空指令
- el:指令绑定的那个元素 img
- binding:binding.value 指令等于号后面绑定的值
- 实现指令核心逻辑,判断图片是否进入视口区域,若进入,实现发送
- 通过官方网站提示在main.js文件中实现空指令
app.directive('m-lazy',{
mounted(el,binding){
//el:指令绑定的那个元素 img
//binding:binding.value 指令等于号后面绑定的值
console.log(el,binding.value);
useIntersectionObserver(
el,
([{ isIntersecting }]) => {
console.log(isIntersecting);
if(isIntersecting){
//进入视口区域
el.src=binding.value
}
},
)
}
})
- 软加载指令优化
- 将懒加载插件单独写出来
- 在derectives中的创建index.js,写入:
- 在main.js中引入
- useIntersectionObserver存在重复监听的问题
- 在监听的图片完成第一次加载之后就停止监听
- 将懒加载插件单独写出来
mport { useIntersectionObserver } from '@vueuse/core'
//定义懒加载插件
export const lazyPlugin = {
install(app){
//懒加载逻辑
app.directive('m-lazy',{
mounted(el,binding){
//el:指令绑定的那个元素 img
//binding:binding.value 指令等于号后面绑定的值
console.log(el,binding.value);
useIntersectionObserver(
el,
([{ isIntersecting }]) => {
console.log(isIntersecting);
if(isIntersecting){
//进入视口区域
el.src=binding.value
}
},
)
}
})
}
}
- Product产品列表渲染
- 引入静态模板
- 封装接口
- 获取数据渲染模板
- 图片懒加载
<script setup>
import HomePanel from './HomePannel.vue'
import { getGoodsAPI } from '@/apis/home'
import { onMounted,ref } from 'vue';
//获取数据列表
// const goodsProduct=ref([])
// const getGoods = async=()=>{
// const res = await getGoodsAPI()
// goodsProduct.value = res.result
// }
// onMounted(()=>getGoods)
const goodsProduct = ref([])
const getGoods = async () => {
const { result } = await getGoodsAPI()
goodsProduct.value = result
}
onMounted( ()=> getGoods() )
</script>
<template>
<div class="home-product">
<HomePanel :title="cate.name" v-for="cate in goodsProduct" :key="cate.id">
<div class="box">
<RouterLink class="cover" to="/">
<img v-m-lazy="cate.picture" />
<strong class="label">
<span>{{ cate.name }}馆</span>
<span>{{ cate.saleInfo }}</span>
</strong>
</RouterLink>
<ul class="goods-list">
<li v-for="good in cate.goods" :key="good.id">
<RouterLink to="/" class="goods-item">
<img v-m-lazy="good.picture" alt="" />
<p class="name ellipsis">{{ good.name }}</p>
<p class="desc ellipsis">{{ good.desc }}</p>
<p class="price">¥{{ good.price }}</p>
</RouterLink>
</li>
</ul>
</div>
</HomePanel>
</div>
</template>
<style scoped lang='scss'>
.home-product {
background: #fff;
margin-top: 20px;
.sub {
margin-bottom: 2px;
a {
padding: 2px 12px;
font-size: 16px;
border-radius: 4px;
&:hover {
background: $xtxColor;
color: #fff;
}
&:last-child {
margin-right: 80px;
}
}
}
.box {
display: flex;
.cover {
width: 240px;
height: 610px;
margin-right: 10px;
position: relative;
img {
width: 100%;
height: 100%;
}
.label {
width: 188px;
height: 66px;
display: flex;
font-size: 18px;
color: #fff;
line-height: 66px;
font-weight: normal;
position: absolute;
left: 0;
top: 50%;
transform: translate3d(0, -50%, 0);
span {
text-align: center;
&:first-child {
width: 76px;
background: rgba(0, 0, 0, 0.9);
}
&:last-child {
flex: 1;
background: rgba(0, 0, 0, 0.7);
}
}
}
}
.goods-list {
width: 990px;
display: flex;
flex-wrap: wrap;
li {
width: 240px;
height: 300px;
margin-right: 10px;
margin-bottom: 10px;
&:nth-last-child(-n + 4) {
margin-bottom: 0;
}
&:nth-child(4n) {
margin-right: 0;
}
}
}
.goods-item {
display: block;
width: 220px;
padding: 20px 30px;
text-align: center;
transition: all .5s;
&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}
img {
width: 160px;
height: 160px;
}
p {
padding-top: 10px;
}
.name {
font-size: 16px;
}
.desc {
color: #999;
height: 29px;
}
.price {
color: $priceColor;
font-size: 20px;
}
}
}
}
</style>
export const getGoodsAPI=()=>{
return httpInstance({
url:'home/goods'
})
}
- GoodsItem组件封装
- 在多个业务模块中将使用相同的商品展示模板,为避免重复定义,封装起来
- 把要显示的数据对象抽象为props参数,传入什么就显示什么
- 在Home的components中创建goodsItem.vue,写入
- 并在HomeProduct中引入:
<script setup>
defineProps({
goods: {
type: Object,
default:() => { }
}
})
</script>
<template>
<RouterLink to="/" class="goods-item">
<img v-m-lazy="goods.picture" alt="" />
<p class="name ellipsis">{{ goods.name }}</p>
<p class="desc ellipsis">{{ goods.desc }}</p>
<p class="price">¥{{ goods.price }}</p>
</RouterLink>
</template>
<style lang="scss">
.goods-item {
display: block;
width: 220px;
padding: 20px 30px;
text-align: center;
transition: all .5s;
&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}
img {
width: 160px;
height: 160px;
}
p {
padding-top: 10px;
}
.name {
font-size: 16px;
}
.desc {
color: #999;
height: 29px;
}
.price {
color: $priceColor;
font-size: 20px;
}
}
</style>
<ul class="goods-list">
<li v-for="goods in cate.goods" :key="goods.id">
<GoodsItem :goods="goods" />
</li>
</ul>
一级分类配置
- 路由配置
- 为分类的不同页面设置不同的参数路由
- 在导航栏上配置导航区域链接
{
path:'category/:id',
component:category,
}
<li class="home" v-for="item in categoryStore.categoryList" :key="item.id">
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
</li>
- 面包屑导航渲染
- 准备组件模板
- 封装接口函数:需要通过id参数来实现传参
- 使用useRoute得到route实例,得到路由参数,调用接口获得数据
- 渲染模板
<script setup>
import { getCategoryAPI } from '@/apis/category';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const categaryData = ref([])
const route=useRoute()
const getCategory= async () =>{
const res = await getCategoryAPI(route.params.id)
categaryData.value=res.result
}
onMounted(()=>getCategory)
</script>
<template>
<div class="top-category">
<div class="container m-top-20">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>居家</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.top-category {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}
.sub-list {
margin-top: 20px;
background-color: #fff;
ul {
display: flex;
padding: 0 32px;
flex-wrap: wrap;
li {
width: 168px;
height: 160px;
a {
text-align: center;
display: block;
font-size: 16px;
img {
width: 100px;
height: 100px;
}
p {
line-height: 40px;
}
&:hover {
color: $xtxColor;
}
}
}
}
}
.ref-goods {
background-color: #fff;
margin-top: 20px;
position: relative;
.head {
.xtx-more {
position: absolute;
top: 20px;
right: 20px;
}
.tag {
text-align: center;
color: #999;
font-size: 20px;
position: relative;
top: -20px;
}
}
.body {
display: flex;
justify-content: space-around;
padding: 0 40px 30px;
}
}
.bread-container {
padding: 25px 0;
}
}
</style>
- 轮播图功能实现
- 改造之前banner的接口,默认为1,商品为2
- 在category的index.js文件里实现获取banner的功能
<script setup>
import { getCategoryAPI } from '@/apis/category';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { getBannerAPI } from '@/apis/home'
const categaryData = ref([])
const route=useRoute()
const getCategory= async () =>{
const res = await getCategoryAPI(route.params.id)
categaryData.value=res.result
}
onMounted(()=>getCategory)
//获取banner
const bannerList = ref([])
const getBanner=async()=>{
const res = await getBannerAPI({
distributionSite:'2'
})
console.log(res)
bannerList.value=res.result
}
onMounted(()=>getBanner())
</script>
<template>
<div class="top-category">
<div class="container m-top-20">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>居家</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 轮播图 -->
<div class="home-banner">
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.top-category {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}
.sub-list {
margin-top: 20px;
background-color: #fff;
ul {
display: flex;
padding: 0 32px;
flex-wrap: wrap;
li {
width: 168px;
height: 160px;
a {
text-align: center;
display: block;
font-size: 16px;
img {
width: 100px;
height: 100px;
}
p {
line-height: 40px;
}
&:hover {
color: $xtxColor;
}
}
}
}
}
.ref-goods {
background-color: #fff;
margin-top: 20px;
position: relative;
.head {
.xtx-more {
position: absolute;
top: 20px;
right: 20px;
}
.tag {
text-align: center;
color: #999;
font-size: 20px;
position: relative;
top: -20px;
}
}
.body {
display: flex;
justify-content: space-around;
padding: 0 40px 30px;
}
}
.bread-container {
padding: 25px 0;
}
}
.home-banner {
width: 1240px;
height: 500px;
margin:0 auto;
img {
width: 100%;
height: 500px;
}
}
</style>
- 激活状态控制和列表状态管理
- RoutetLink支持激活样式属性显示的类名,只需要给active-class设置对应的类名即可
- 分类的数据在面包屑导航时已经获取,只需要通过v-for遍历即可
<script setup>
import { getCategoryAPI } from '@/apis/category';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { getBannerAPI } from '@/apis/home';
import GoodsItem from '../Home/components/GoodsItem.vue';
const categoryData = ref([])
const route=useRoute()
const getCategory= async (id) =>{
const res = await getCategoryAPI(id)
categoryData.value=res.result
}
onMounted(()=>getCategory(route.params.id))
//获取banner
const bannerList = ref([])
const getBanner=async()=>{
const res = await getBannerAPI({
distributionSite:'2'
})
console.log(res)
bannerList.value=res.result
}
onMounted(()=>getBanner())
</script>
<template>
<div class="top-category">
<div class="container m-top-20">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 轮播图 -->
<div class="home-banner">
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
</div>
<div class="sub-list">
<h3>全部分类</h3>
<ul>
<li v-for="i in categoryData.children" :key="i.id">
<RouterLink to="/">
<img :src="i.picture" />
<p>{{ i.name }}</p>
</RouterLink>
</li>
</ul>
</div>
<div class="ref-goods" v-for="item in categoryData.children" :key="item.id">
<div class="head">
<h3>- {{ item.name }}-</h3>
</div>
<div class="body">
<GoodsItem v-for="goods in item.goods" :goods="goods" :key="goods.id" />
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.top-category {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}
.sub-list {
margin-top: 20px;
background-color: #fff;
ul {
display: flex;
padding: 0 32px;
flex-wrap: wrap;
li {
width: 168px;
height: 160px;
a {
text-align: center;
display: block;
font-size: 16px;
img {
width: 100px;
height: 100px;
}
p {
line-height: 40px;
}
&:hover {
color: $xtxColor;
}
}
}
}
}
.ref-goods {
background-color: #fff;
margin-top: 20px;
position: relative;
.head {
.xtx-more {
position: absolute;
top: 20px;
right: 20px;
}
.tag {
text-align: center;
color: #999;
font-size: 20px;
position: relative;
top: -20px;
}
}
.body {
display: flex;
justify-content: space-around;
padding: 0 40px 30px;
}
}
.bread-container {
padding: 25px 0;
}
}
.home-banner {
width: 1240px;
height: 500px;
margin:0 auto;
img {
width: 100%;
height: 500px;
}
}
</style>
- 解决路由缓存问题
- 路由缓存问题:一级路由切换时,会遇到相同的组件实例将被重复使用,组件的生命周期钩子不会被调用,导致分类数据无法更新
- 解决思路:
- 让组件实例不复用,强行销毁重建:添加key 破坏复用机制 强制销毁重建
- 监听路由变化,变化之后执行路由更新操作:使用beforeRouteUpdata生命函数勾子即可 在每次路由更新之后执行 回调中执行需要的业务逻辑即可
const categoryData = ref([])
const route=useRoute()
const getCategory= async (id=route.params.id) =>{
const res = await getCategoryAPI(id)
categoryData.value=res.result
}
onMounted(()=>getCategory())
//路由参数发生变化时,需要categoryData重新发送
onBeforeRouteUpdate((to)=>{
console.log("路由改变")
console.log(to)
getCategory(to.params.id)
})
- 使用逻辑函数拆分业务
- 实现步骤:
- 按照业务声明以use打头的业务逻辑
- 把独立的业务逻辑封装到各个函数内部
- 函数内部把组件需要用到的数据和方法return回来
- 在组件中调用函数把数据或方法组合起来使用
- 将轮播图数据获取和分类数据获取的业务逻辑分离出来到useBanner.js和useCategory.js中,在index.js中引入
- 实现步骤:
import { ref,onMounted } from "vue";
import { useRoute } from "vue-router";
import { onBeforeRouteUpdate } from "vue-router";
import { getCategoryAPI } from '@/apis/category';
export function useCategory(){
//获取分类数据
const categoryData = ref([])
const route=useRoute()
const getCategory= async (id=route.params.id) =>{
const res = await getCategoryAPI(id)
categoryData.value=res.result
}
onMounted(()=>getCategory())
//路由参数发生变化时,需要categoryData重新发送
onBeforeRouteUpdate((to)=>{
console.log("路由改变")
console.log(to)
getCategory(to.params.id)
})
return{
categoryData
}
}
import { ref,onMounted } from 'vue';
import { getBannerAPI } from '@/apis/home';
export function useBanner(){
const bannerList = ref([])
const getBanner=async()=>{
const res = await getBannerAPI({
distributionSite:'2'
})
console.log(res)
bannerList.value=res.result
}
onMounted(()=>getBanner())
return{
bannerList
}
}
<script setup>
import GoodsItem from '../Home/components/GoodsItem.vue';
import { useBanner } from './composables/useBanner';
import { useCategory } from './composables/useCategory';
const { bannerList } = useBanner()
const { categoryData } = useCategory()
</script>