# 商品分类

# 01-顶级类目-面包屑组件-初级

目的: 封装一个简易的面包屑组件,适用于两级场景。

大致步骤:

  • 准备静态的 xtx-bread.vue 组件
  • 定义 props 暴露 parentPath parentName 属性,默认插槽,动态渲染组件
  • library/index.js 注册组件,使用验证效果。

落的代码:

  • 基础结构 src/components/library/xtx-bread.vue
<template>
  <div class='xtx-bread'>
    <div class="xtx-bread-item">
      <RouterLink to="/">首页</RouterLink>
    </div>
    <i class="iconfont icon-angle-right"></i>
    <div class="xtx-bread-item">
      <RouterLink to="/category/10000">电器</RouterLink>
    </div>
    <i class="iconfont icon-angle-right"></i>
    <div class="xtx-bread-item">
      <span>空调</span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'XtxBread'
}
</script>

<style scoped lang='less'>
.xtx-bread{
  display: flex;
  padding: 25px 10px;
  &-item {
    a {
      color: #666;
      transition: all .4s;
      &:hover {
        color: @xtxColor;
      }
    }
  }
  i {
    font-size: 12px;
    margin-left: 5px;
    margin-right: 5px;
    line-height: 22px;
  }
}
</style>
  • 定义props进行渲染 src/components/library/xtx-bread.vue
<template>
  <div class='xtx-bread'>
    <div class="xtx-bread-item">
      <RouterLink to="/">首页</RouterLink>
    </div>
    <i class="iconfont icon-angle-right"></i>
+    <div class="xtx-bread-item" v-if="parentName">
+      <RouterLink v-if="parentPath" :to="parentPath">{{parentName}}</RouterLink>
+      <span v-else>{{parentName}}</span>
+    </div>
+    <i v-if="parentName" class="iconfont icon-angle-right"></i>
    <div class="xtx-bread-item">
+      <span><slot /></span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'XtxBread',
+  props: {
+    parentName: {
+      type: String,
+      default: ''
+    },
+    parentPath: {
+      type: String,
+      default: ''
+    }
+  }
}
</script>
  • 注册使用 src/components/library/index.js
+import 'XtxBread' from './xtx-bread.vue'

export default {
  install (app) {
+      app.component(XtxBread.name, XtxBread)

使用: <XtxBread parentPath="/category/1005000" parentName="电器">空调</XtxBread>

总结: 采用基本的封装手法,灵活度不是很高。

# 02-顶级类目-面包屑组件-高级

目的: 封装一个高复用的面包屑组件,适用于多级场景。认识 render 选项和 h 函数。

参考element-ui的面包屑组件:

1619946353625

大致步骤:

  • 使用插槽和封装选项组件完成面包屑组件基本功能,但是最后一项多一个图标。
  • 学习 render 选项,h 函数 的基本使用。
  • 通过 render 渲染,h 函数封装面包屑功能。

落的代码:

  • 我们需要两个组件,xtx-breadxtx-bread-item 才能完成动态展示。

定义单项面包屑组件 src/components/library/xtx-bread-item.vue

<template>
  <div class="xtx-bread-item">
    <RouterLink v-if="to" :to="to"><slot /></RouterLink>
    <span v-else><slot /></span>
    <i class="iconfont icon-angle-right"></i>
  </div>
</template>
<script>
export default {
  name: 'XtxBreadItem',
  props: {
    to: {
      type: [String, Object]
    }
  }
}
</script>

library/index.js注册

+ import 'XtxBreadItem' from './xtx-bread-item.vue'
export default {
  install (app) {
+   app.component(XtxBreadItem.name, XtxBread)
  • 过渡版,你发现结构缺少风格图标,如果在item中加上话都会有图标,但是最后一个是不需要的。
<template>
  <div class='xtx-bread'>
    <slot />
  </div>
</template>

<script>
export default {
  name: 'XtxBread'
}
</script>
<!-- 去掉scoped全局作用 -->
<style lang='less'>
<!-- 面包屑 -->
<XtxBread>
    <XtxBreadItem to="/">首页</XtxBreadItem>
    <XtxBreadItem to="/category/1005000">电器</XtxBreadItem>
    <XtxBreadItem >空调</XtxBreadItem>
</XtxBread>
  • 终极版,使用render函数自己进行拼接创建。

createVNode (opens new window) render (opens new window) render选项与h函数

  • 指定组件显示的内容,Vue2写法:new Vue({选项})
    • el 选项,通过一个选择器找到容器,容器内容就是组件内容
    • template 选项,<div>组件内容</div> 作为组件内容
    • render选项,它是一个函数,函数回默认传入 h 函数(createElement ),这个函数用来创建结构,再render函数返回渲染为组件内容。它的优先级更高。
//import App from './App.vue'
//new Vue({
//    render:h=>h(App)
//}).mount('#app')
// h() =====>  createElement()
// h(App) =====>  根据APP组件创建html结构
// render的返回值就是html结构,渲染到#app容器
// h() 函数参数,1.节点名称  2. 属性|数据 是对象  3. 子节点

xtx-bread-item.vue

<template>
  <div class="xtx-bread-item">
    <RouterLink v-if="to" :to="to"><slot /></RouterLink>
    <span v-else><slot /></span>
-   <i class="iconfont icon-angle-right"></i>
  </div>
</template>

xtx-bread.vue

<script>
import { h } from 'vue'
export default {
  name: 'XtxBread',
  render (proxy) {
    // 用法
    // 1. template 标签去除,单文件组件
    // 2. 返回值就是组件内容
    // 3. vue2.0 的h函数传参进来的,vue3.0 的h函数导入进来
    // 4. h 第一个参数 标签名字  第二个参数 标签属性对象  第三个参数 子节点
    // 需求
    // 1. 创建xtx-bread父容器
    // 2. 获取默认插槽内容
    // 3. 去除xtx-bread-item组件的i标签,因该由render函数来组织
    // 4. 遍历插槽中的item,得到一个动态创建的节点,最后一个item不加i标签
    // 5. 把动态创建的节点渲染再xtx-bread标签中
    const slot = proxy.$slots.default()
    const arr = []
    items.forEach((item, index) => {
      arr.push(item)
      if (index < (slot.length - 1)) {
        arr.push(h('i', { class: 'iconfont icon-angle-right' }))
      }
    })
    return h('div', { class: 'xtx-bread' }, arr)
  }
}
</script>

<style lang='less'>
// 去除 scoped 属性,目的:然样式作用到xtx-bread-item组件
.xtx-bread{
  display: flex;
  padding: 25px 10px;
  &-item {
    a {
      color: #666;
      transition: all .4s;
      &:hover {
        color: @xtxColor;
      }
    }
  }
  i {
    font-size: 12px;
    margin-left: 5px;
    margin-right: 5px;
    line-height: 22px;
  }
}
</style>
  • 使用代码
<!-- 面包屑 -->
<XtxBread>
    <XtxBreadItem to="/">首页</XtxBreadItem>
    <XtxBreadItem to="/category/1005000">电器</XtxBreadItem>
    <XtxBreadItem >空调</XtxBreadItem>
</XtxBread>
  • 总结,一下知识点
    • render 是vue提供的一个渲染函数,优先级大于el,template等选项,用来提供组件结构。
    • 注意:
      • vue2.0 rende r函数提供 h(createElement)函数用来创建节点
      • vue3.0 h(createVNode)函数有 vue 直接提供,需要按需导入
    • this.$slots.default() 获取默认插槽的node结构,按照要求拼接结构。
    • h函数的传参 tag 标签名|组件名称, props 标签属性|组件属性, node 子节点|多个节点
    • 具体参考 render )
  • 💥注意:不要在 xtx-bread 组件插槽写注释,注释也是节点(注释节点),也会被解析。

# 03-顶级类目-批量注册组件

目的: 自动的批量注册组件。

大致步骤:

  • 使用 require 提供的函数 context 加载某一个目录下的所有 .vue 后缀的文件。
  • 然后 context 函数会返回一个导入函数 importFn
    • 它又一个属性 keys() 获取所有的文件路径
  • 通过文件路径数组,通过遍历数组,再使用 importFn 根据路径导入组件对象
  • 遍历的同时进行全局注册即可

落的代码:

src/components/library/index.js

// 其实就是vue插件,扩展vue功能,全局组件、指令、函数 (vue.30取消过滤器)
// 当你在mian.js导入,使用Vue.use()  (vue3.0 app)的时候就会执行install函数
-  import XtxSkeleton from './xtx-skeleton.vue'
-  import XtxCarousel from './xtx-carousel.vue'
-  import XtxMore from './xtx-more.vue'
-  import XtxBread from './xtx-bread.vue'
-  import XtxBreadItem from './xtx-bread-item.vue'

// 导入library文件夹下的所有组件
// 批量导入需要使用一个函数 require.context(dir,deep,matching)
// 参数:1. 目录  2. 是否加载子目录  3. 加载的正则匹配
+ const importFn = require.context('./', false, /\.vue$/)
+ // console.dir(importFn.keys()) 文件名称数组

export default {
  install (app) {
-    app.component(XtxSkeleton.name, XtxSkeleton)
-    app.component(XtxCarousel.name, XtxCarousel)
-    app.component(XtxMore.name, XtxMore)
-    app.component(XtxBread.name, XtxBread)
-    app.component(XtxBreadItem.name, XtxBreadItem)

+    // 批量注册全局组件
+    importFn.keys().forEach(key => {
+      // 导入组件
+      const component = importFn(key).default
+      // 注册组件
+      app.component(component.name, component)
+    })

  }
}

总结,知识点:

  • require.context() 是webpack提供的一个自动导入的API
    • 参数1:加载的文件目录
    • 参数2:是否加载子目录
    • 参数3:正则,匹配文件
    • 返回值:导入函数 fn
      • keys() 获取读取到的所有文件列表

# 04-顶级类目-基础布局搭建

目的: 完成顶级分类的,面包屑+轮播图+所属全部子级分类展示。

大致步骤:

  • 准备基础结构,获取轮播图数据给组件使用
  • 获取面包屑和所有分类数据给子级分类展示使用

落的代码:

  • 基本结构和轮播图渲染 src/views/category/index.vue
<template>
  <div class="top-category">
    <div class="container">
      <!-- 面包屑 -->
      <XtxBread>
        <XtxBreadItem to="/">首页</XtxBreadItem>
        <XtxBreadItem>空调</XtxBreadItem>
      </XtxBread>
      <!-- 轮播图 -->
      <XtxCarousel :sliders="sliders" style="height:500px" />
      <!-- 所有二级分类 -->
      <div class="sub-list">
        <h3>全部分类</h3>
        <ul>
          <li v-for="i in 8" :key="i">
            <a href="javascript:;">
              <img src="http://zhoushugang.gitee.io/erabbit-client-pc-static/uploads/img/category%20(9).png" >
              <p>空调</p>
            </a>
          </li>
        </ul>
      </div>
      <!-- 不同分类商品 -->
        
    </div>
  </div>
</template>
<script>
import { findBanner } from '@/api/home'
export default {
  name: 'TopCategory',
  setup () {
    // 轮播图
    const sliders = ref([])
    findBanner(2).then(data => {
      sliders.value = data.result
    })
    return { sliders }  
  }
}
</script>
<style scoped lang="less">
.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;
          }
        }
      }
    }
  }
}
</style>
  • 获取分类数据,进行渲染。

    1. 定义API src/api/category.js
    /**
     * 一级类目-PC
     * @param {*} id 一级类目ID
     * @returns
     */
    export const findTopCategory = (id) => {
      return request('/category', 'get', { id: id })
    }
    
    1. 导入路由获取分类 id,根据分类id获取分类数据进行渲染。
import { useRoute } from 'vue-router'
import { ref } from 'vue'
    // 获取一级分类数据
    const route = useRoute()
    const cate = ref(null)
 	findTopCategory(route.params.id).then(({ result }) => {
       cate.value = result
    })
    
    return {
      sliders,
      cate,
    }
<template>
+  <div class="top-category" v-if="cate">
    <div class="container">
      <!-- 面包屑 -->
      <XtxBread>
        <XtxBreadItem to="/">首页</XtxBreadItem>
+        <XtxBreadItem>{{ cate.name }}</XtxBreadItem>
      </XtxBread>
      <!-- 轮播图 -->
      <XtxCarousel :sliders="sliders" style="height:500px" />
      <!-- 所有二级分类 -->
      <div class="sub-list">
        <h3>全部分类</h3>
        <ul>
+          <li v-for="item in cate.children" :key="item.id">
+            <RouterLink :to="`/category/sub/${item.id}`">
+              <img :src="item.picture" >
+              <p>{{ item.name }}</p>
+            </RouterLink>
          </li>
        </ul>
      </div>
      <!-- 不同分类商品 -->
    </div>
  </div>
</template>

# 05-顶级类目-分类商品-布局

目的: 展示各个子级分类下推荐的商品基础布局

大致步骤:

  • 准备单个商品组件
  • 完成推荐商品区块布局

落的代码:

  • 商品信息组件 src/views/category/components/goods-item.vue
<template>
  <RouterLink to="/" class="goods-item">
    <img src="http://zhoushugang.gitee.io/erabbit-client-pc-static/uploads/fresh_goods_2.jpg"alt="" />
    <p class="name ellipsis">红功夫 麻辣小龙虾 19.99/500g 实惠到家</p>
    <p class="desc ellipsis">火锅食材</p>
    <p class="price">&yen;19.99</p>
  </RouterLink>
</template>

<script>
export default {
  name: 'GoodsItem'
}
</script>

<style scoped lang="less">
.goods-item {
  display: block;
  width: 220px;
  padding: 20px 30px;
  text-align: center;
  .hoverShadow();
  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>
  • 顶级分类组件,进行布局 src/views/category/index.vue
+import GoodsItem from './components/goods-item.vue'
export default {
  name: 'TopCategory',
  components: {
+    GoodsItem
  },
<!-- 不同分类商品 -->
<div class="ref-goods">
    <div class="head">
        <h3>- 海鲜 -</h3>
        <p class="tag">温暖柔软,品质之选</p>
        <XtxMore />
    </div>
    <div class="body">
        <GoodsItem v-for="i in 5" :key="i" />
    </div>
</div>
  .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: flex-start;
      flex-wrap: wrap;
      padding: 0 65px 30px;
    }
  }

# 06-顶级类目-分类商品-展示

根据切换路由的时候,根据分类ID获取数据,渲染分类商品。

大致步骤:

  • 定义API,组件初始化要去加载数据,但是动态路由不会重新初始化组件。
  • 如果监听地址栏id的变化,然后变化了就去加载数据,但是初始化有不会加载了。
  • 不过watch提供了 immediate: true 可让watch初始化的时候主动触发一次。

落的代码:

  1. 使用watch加载数据 src/views/category/index.vue
    // 推荐商品
    const subList = ref([])
    const getSubList = () => {
      findTopCategory(route.params.id).then(data => {
        subList.value = data.result.children
      })
    }
    watch(() => route.params.id, (newVal) => {
      newVal && getSubList()
    }, { immediate: true })

    return {
      sliders,
      topCategory,
      subList
    }
  1. 开始渲染 src/views/category/index.vue
      <!-- 分类关联商品 -->
+      <div class="ref-goods" v-for="item in cate.children" :key="item.id">
        <div class="head">
+          <h3>- {{item.name}} -</h3>
+          <p class="tag">{{item.desc}}</p>
          <XtxMore />
        </div>
        <div class="body">
+          <GoodsItem v-for="item2 in item.goods" :key="item2.id" :goods="item2" />
        </div>
      </div>
  1. 开始渲染 src/views/category/components/goods-item.vue
<template>
  <RouterLink to="/" class="goods-item">
+    <img :src="goods.picture" alt="" />
+    <p class="name ellipsis">{{ goods.name }}</p>
+    <p class="desc ellipsis">{{ goods.desc }}</p>
+    <p class="price">&yen;{{ goods.price }}</p>
  </RouterLink>
</template>

<script>
export default {
  name: 'GoodsItem',
+  props: {
+    goods: {
+      type: Object,
+      // 返回对象需要带小括号
+      default: () => ({})
+    }
+  }
}
</script>

# 07-顶级类目-面包屑切换动画

目的: 由于切换顶级类目,面包屑文字瞬间完成,体验差,给切换的文字加上动画。

大致步骤:

  • 给面包屑ITEM组件加上Transition组件并且创建 动画条件
  • 定义动画css样式

落地代码:

  • 加 transition 和 name 属性
  • 🔔 加上key属性关联ID才会创建和移除。
<transition name="fade-right" mode="out-in">
    <XtxBreadItem :key="currCategory.id">{{currCategory.name}}</XtxBreadItem>
</transition>
  • 写动画样式 common.less 做为公用
.fade-right {
  &-enter-to,
  &-leave-from {
    opacity: 1;
    transform: translateX(0);
  }
  &-enter-active,
  &-leave-active {
    transition: all 0.5s;
  }
  &-enter-from,
  &-leave-to {
    opacity: 0;
    transform: translateX(20px);
  }
}

**总结: ** 不同的key可以创建移除元素,创造触发动画条件。

# 08-二级类目-处理跳转细节

目的: 在路由跳转的时候,优化跳转的细节。

大致需求:

  • 切换到二级类目路由的时候也有ID,但是也触发了watch导致发送了请求,需要处理。
  • 现在的路由跳转默认在当前浏览的位置(卷曲的高度),我们需要会到顶部。
  • 在点击二级类目的时候,页面滚动到顶部,造成进入一级类名事件触发,显示其对应二级弹窗,需要处理。

落的代码:

滚动行为:https://next.router.vuejs.org/zh/guide/advanced/scroll-behavior.html

  • 每次切换路由的时候滚动到顶部 src/router/index.js
const router = createRouter({
  history: createWebHashHistory(),
  routes,
+  scrollBehavior () {
+    return { left: 0, top: 0 }
+  }
})
  • 滚动到顶部,鼠标有时候会进入一级类目上,触发弹出二级类目。改成在一级类目上移动弹出二级类目。src/components/app-header-nav.vue
    <li class="home"><RouterLink to="/">首页</RouterLink></li>
+   <li @mousemove="show(item)"
  • 切换到二级类目路由的时候也有ID,但是也触发了watch导致发送了请求,需要处理。 src/views/category/index.vue
    watch(() => route.params.id, (id) => {
+     if (`/category/${id}` !== route.path) return 
      findTopCategory(id).then(({ result }) => {
          cate.value = result
      })
    }, { immediate: true })

总结: 跳转的时候需要注意些细节

# 09-二级类目-展示面包屑

目的:根据二级类目ID展示多级面包屑

大致思路:

  • 封装一个独立的组件来完成,因为需要加动画。

  • 使用组合API的方式通过获取到所需数据

逻辑代码:

  • 从vuex中通过计算属性得到面包屑所需数据 src/views/category/components/sub-bread.vue
<template>
  <XtxBread>
    <XtxBreadItem to="/">首页</XtxBreadItem>
    <XtxBreadItem to="/">一级分类</XtxBreadItem>
    <XtxBreadItem>二级分类</XtxBreadItem>
  </XtxBread>
</template>

<script>
export default {
  name: 'SubBread'
}
</script>
  • 将该组件在 src/views/category/sub.vue 中使用
<template>
  <div class='sub-category'>
    <div class="container">
      <!-- 面包屑 -->
      <SubBread />
      
    </div>
  </div>
</template>

<script>
import SubBread from './components/sub-bread.vue'
export default {
  name: 'SubCategory',
  components: { SubBread }
}
</script>


<style scoped lang="less">
</style>

# 10-二级类目-筛选区展示

目的:根据后台返回的筛选条件展示筛选区域。

大致步骤:

  • 定义一个组件来展示筛选区域
  • 获取数据进行品牌和属性的渲染

落地代码:

  • 基础布局:src/views/category/components/sub-filter.vue
<template>
	<!-- 筛选区 -->
   <div class="sub-filter">
     <div class="item" v-for="i in 4" :key="i">
       <div class="head">品牌:</div>
       <div class="body">
         <a href="javascript:;">全部</a>
         <a href="javascript:;" v-for="i in 4" :key="i">小米</a>
       </div>
     </div>
   </div>
</template>

<script>
export default {
  name: 'SubFilter'
}
</script>

<style scoped lang='less'>
  // 筛选区
  .sub-filter {
    background: #fff;
    padding: 25px;
    .item {
      display: flex;
      line-height: 40px;
      .head {
        width: 80px;
        color: #999;
      }
      .body {
        flex: 1;
        a {
          margin-right: 36px;
          transition: all .3s;
          display: inline-block;  
          &.active,
          &:hover {
            color: @xtxColor;
          }
        }
      }
    }
  }
</style>

sub 组件使用

<template>
  <div class='sub-category'>
    <div class="container">
      <!-- 面包屑 -->
      <SubBread />
+      <!-- 筛选区 -->
+      <SubFilter />
      </div>
    </div>
  </div>
</template>

<script>
import SubBread from './components/sub-bread.vue'
+import SubFilter from './components/sub-filter.vue'
export default {
  name: 'SubCategory',
+  components: { SubBread, SubFilter}
}
</script>
  • 获取数据:在地址栏二级类目ID改变的时候去加载筛选条件数据

src/api/category.js 定义API


/**
 *二级类目-筛选条件-PC
 * @param {*} id 二级分类ID
 * @returns
 */
export const findSubCategory = (id) => {
  return request('/category/sub/filter', 'get', { id: id })
}

src/views/category/sub-filter.vue 获取数据,组装数据。

<template>
  <!-- 筛选区 -->
  <div class="sub-filter">
    <div class="item" v-for="item in list" :key="item.id">
      <div class="head">{{ item.name }}:</div>
      <div class="body">
        <a href="javascript:;">全部</a>
        <a href="javascript:;" v-for="p in item.properties" :key="p.id">
          {{ p.name }}
        </a>
      </div>
    </div>
  </div>
</template>

<script>
import { findSubCategoryFilter } from '@/api/category'
import { useRoute } from 'vue-router'
import { ref, watch } from 'vue'
export default {
  name: 'SubFilter',
  setup () {
    const route = useRoute()

    const list = ref([])

    const getFilter = async id => {
      const { result } = await findSubCategory(id)
      // console.log(result)
      // result.brands            品牌数据
      // result.saleProperties    筛选条件
      // 把品牌和筛选条件放到了同一个数组内
      list.value = [
        // 品牌数据
        { id: null, name: '品牌', properties: result.brands },
        // 筛选条件
        ...result.saleProperties
      ]
      console.log(list.value)
    }

    watch(
      () => route.params.id,
      id => {
        // console.log('id发生变化了', id)
        if (route.path === `/category/sub/${id}`) {
          getFilter(id)
        }
      },
      { immediate: true }
    )

    //
    return { list }
  }
}
</script>

渲染模板:src/views/category/sub.vue 且加上骨架效果

<template>
  <div class="sub-filter">
    <div class="item" v-for="item in list" :key="item.id">
      <div class="head">{{ item.name }}:</div>
      <div class="body">
        <a href="javascript:;">全部</a>
        <a href="javascript:;" v-for="p in item.properties" :key="p.id">
          {{ p.name }}
        </a>
      </div>
    </div>
  </div>
  <div v-else class="sub-filter">
    <XtxSkeleton class="item" width="800px" height="40px"  />
    <XtxSkeleton class="item" width="800px" height="40px"  />
    <XtxSkeleton class="item" width="600px" height="40px"  />
    <XtxSkeleton class="item" width="600px" height="40px"  />
    <XtxSkeleton class="item" width="600px" height="40px"  />
  </div>
</template>
.xtx-skeleton {
    padding: 10px 0;
}

# 11-二级类目-复选框组件封装

目的:实现一个自定义复选框组件。

大致步骤:

  • 实现组件本身的选中与不选中效果
  • 实现组件的v-model指令
  • 改造成 @vueuse/core 的函数写法

落地代码:

  • 1)组件静态布局 src/components/library/xtx-checkbox.vue
<template>
  <div class="xtx-checkbox">
    <i class="iconfont icon-checked"></i>
    <i class="iconfont icon-unchecked"></i>
    <span> 复选框文字 </span>
  </div>
</template>

<script>
export default {
  name: 'XtxCheckbox',
}
</script>

<style scoped lang="less">
.xtx-checkbox {
  display: inline-block;
  margin-right: 2px;
  .icon-checked {
    color: @xtxColor;
    ~ span {
      color: @xtxColor;
    }
  }
  i {
    position: relative;
    top: 1px;
  }
  span {
    margin-left: 2px;
  }
}
</style>
  • 2)实现双向绑定
    • vue3.0中v-model会拆解成 属性 modelValue 和 事件 update:modelValue
<template>
+  <div class="xtx-checkbox" @click="changeChecked">
+    <i class="iconfont":class="modelValue ? 'icon-checked' : 'icon-unchecked'"/>
+    <span v-if="$slots.default" > <slot /> </span>
  </div>
</template>

<script>
export default {
  name: 'XtxCheckbox',
+  // 🔔 Vue3 的 v-model 是  :modelValue 和 @update:modelValue 组成的语法糖。
+  props: {
+    // 子组件接收布尔值
+    modelValue: {
+      type: Boolean,
+      default: false
+    }
+  },
+  setup (props, { emit }) {
+    const changeChecked = () => {
+      // 点击后把取反后的布尔值传递给父组件
+      const temp = !props.modelValue
+      emit('update:modelValue', temp)
+    }
+    return { changeChecked }
+  }
}
</script>
  • 3)补充 @vueuse/core 的实现
<script>
+import { useVModel } from '@vueuse/core'
export default {
  name: 'XtxCheckbox',
  // 🔔 Vue3 的 v-model 是  :modelValue 和 @update:modelValue 组成的语法糖。
  props: {
    // 子组件接收布尔值
    modelValue: {
      type: Boolean,
      default: false
    }
  },
  setup (props, { emit }) {
-    // 🔔写法1:用 props +  emit 实现
-    const changeChecked = () => {
-       // 点击后把取反后的布尔值传递给父组件
-      const temp = !props.modelValue
-      emit('update:modelValue', temp)
-    }

+    // 🔔写法2:用 useVModel 实现 (简写了props +  emit 变成 ref )
+    const data = useVModel(props, 'modelValue', emit)
+    const changeChecked = () => {
+      data.value = !data.value // 其实就是调用了 emit('update:modelValue', xxx)
+    }
    return { changeChecked }
  }
}
</script>

总结: useVModel 工具函数可实现双向绑定。

# 12-二级类目-结果区-排序组件

目的:封装排序组件,完成排序切换效果

大致步骤:

  • 定义一个组件 sub-sort,完成基础布局
  • sub.vue 组件使用
  • 完成切换排序时候的交换效果

落地代码:

  • 1)静态布局: src/views/category/components/sub-sort.vue
<template>
  <div class='sub-sort'>
    <div class="sort">
      <a href="javascript:;">默认排序</a>  
      <a href="javascript:;">最新商品</a>
      <a href="javascript:;">最高人气</a>
      <a href="javascript:;">评论最多</a>
      <a href="javascript:;">
        价格排序
        <i class="arrow up" />
        <i class="arrow down" />
      </a>
    </div>
    <div class="check">
      <XtxCheckbox>仅显示有货商品</XtxCheckbox>
      <XtxCheckbox>仅显示特惠商品</XtxCheckbox>
    </div>
  </div>
</template>

<script>
export default {
  name: 'SubSort'
}
</script>

<style scoped lang='less'>
.sub-sort {
  height: 80px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  .sort {
    display: flex;
    a {
      height: 30px;
      line-height: 28px;
      border: 1px solid #e4e4e4;
      padding: 0 20px;
      margin-right: 20px;
      color: #999;
      border-radius: 2px;
      position: relative;
      transition: all .3s;
      &.active {
        background: @xtxColor;
        border-color: @xtxColor;
        color: #fff;
      }
      .arrow {
        position: absolute;
        border: 5px solid transparent;
        right: 8px;
        &.up {
          top: 3px;
          border-bottom-color: #bbb;
            &.active {
            border-bottom-color: @xtxColor;
          }
        }
        &.down {
          top: 15px;
          border-top-color: #bbb;
          &.active {
            border-top-color: @xtxColor;
          }
        }
      }
    }
  }
  .check {
    .xtx-checkbox {
      margin-left: 20px;
      color: #999;
    }
  }
}
</style>

使用组件:src/views/category/sub.vue

<template>
  <div class='sub-category'>
    <div class="container">
      <!-- 面包屑 -->
      <SubBread />
      <!-- 筛选区 -->
      <SubFilter />
      <!-- 结果区域 -->
+      <div class="goods-list">
+        <!-- 排序 -->
+        <SubSort />
+        <!-- 商品列表 -->
+        <ul class="list">
+          <li v-for="i in 20" :key="i">
+            <GoodsItem></GoodsItem>
+          </li>
+        </ul>
+      </div>
    </div>
  </div>
</template>

<script>
import SubBread from './components/sub-bread.vue'
import SubFilter from './components/sub-filter.vue'
+import SubSort from './components/sub-sort.vue'
+import GoodsItem from './components/goods-item.vue'
export default {
  name: 'SubCategory',
+  components: { SubBread, SubFilter, SubSort,GoodsItem}
}
</script>

<style scoped lang='less'>
+.goods-list {
+  background: #fff;
+  padding: 0 25px;
+  margin-top: 25px;
+  ul {
+    display: flex;
+    flex-wrap: wrap;
+    padding: 0 5px;
+    li {
+      margin-right: 20px;
+      margin-bottom: 20px;
+      &:nth-child(5n) {
+        margin-right: 0;
+      }
+    }
+  }
+}
</style>

# 13-二级类目-获取排序参数

接口参数初始化:src\views\category\sub.vue

<script>
// ... 省略其他代码
+import { reactive } from 'vue'
export default {
  name: 'SubCategory',
  components: { SubBread, SubFilter, SubSort, GoodsItem },
  setup () {
    // ... 省略其他代码
+    // 用于商品列表--分页查询接口的请求参数
+    const data = reactive({
+      sortField: null,
+      sortMethod: null,
+      inventory: false,
+      onlyDiscount: false
+    })
+    // 父组件提供的是响应式对象,子组件内部可以直接修改
+    provide('data', data)

  }
}
</script>

获取排序区参数 src/views/category/components/sub-sort.vue

<template>
  <div class='sub-sort'>
    <div class="sort">
      <a
        :class="{ active: data.sortField === null }"
        @click="changeSort(null)"
        href="javascript:;"
      >
        默认排序
      </a>
      <a
        :class="{ active: data.sortField === 'publishTime' }"
        @click="changeSort('publishTime')"
        href="javascript:;"
      >
        最新商品
      </a>
      <a
        :class="{ active: data.sortField === 'orderNum' }"
        @click="changeSort('orderNum')"
        href="javascript:;"
      >
        最高人气
      </a>
      <a
        :class="{ active: data.sortField === 'evaluateNum' }"
        @click="changeSort('evaluateNum')"
        href="javascript:;"
      >
        评论最多
      </a>
      <a href="javascript:;" @click="changeSort('price')">
        价格排序
        <i
          :class="{ active: data.sortMethod === 'asc' }"
          class="arrow up"
        />
        <i
          :class="{ active: data.sortMethod === 'desc' }"
          class="arrow down"
        />
      </a>
    </div>
    <div class="check">
      <XtxCheckbox v-model="data.inventory">仅显示有货商品</XtxCheckbox>
      <XtxCheckbox v-model="data.onlyDiscount">仅显示特惠商品</XtxCheckbox>
    </div>
  </div>
</template>
<script>
import { reactive } from 'vue'
export default {
  name: 'SubSort',
  setup () {

    // 改变排序
    const changeSort = (val) => {
      data.sortField = val
      if (val === 'price') {
          // 第一次点击,默认是降序
          data.sortMethod = data.sortMethod === 'desc' ? 'asc' : 'desc'
      } else {
        // 不是价格则删除掉价格排序字段
        delete data.sortMethod
      }
    }

    return { data, changeSort }
  }
}
</script>

# 13-二级类目-结果区-进行筛选

目的:在做了筛选和排序的时候从新渲染商品列表。

大致步骤:

  • 排序组件,当你点击了排序后 或者 复选框改变后 触发自定义事件 sort-change 传出排序参数
  • 筛选组件,当你改了品牌,或者其他筛选添加,触发自定义事件 filter-change 传出筛选参数
  • 在sub组件,分别绑定 sort-change filter-change 得到参数和当前参数合并,回到第一页,清空数据,设置未加载完成,触发加载。

落地代码:

  • 组件:src/views/category/components/sub-sort.vue
    // 改变排序
    const changeSort = (sortField) => {
      // 省略代码....
+      emit('sort-change', data)
    }    
  • 组件 src/views/category/components/sub-filter.vue
    // 获取筛选参数
    const getFilterParams = () => {
      const filterParams = {}
      const attrs = []
      filterParams.brandId = filterData.value.selectedBrand
      filterData.value.saleProperties.forEach(p => {
        const attr = p.properties.find(attr => attr.id === p.selectedProp)
        if (attr && attr.id !== undefined) {
          attrs.push({ groupName: p.name, propertyName: attr.name })
        }
      })
      if (attrs.length) filterParams.attrs = attrs
      return filterParams
    }

    // 选择品牌
    const changeBrand = (brandId) => {
      if (filterData.value.selectedBrand === brandId) return 
      filterData.value.selectedBrand = brandId
      emit('filter-change', getFilterParams())
    }
    // 选中属性
    const changeAttr = (p, attrId) => {
      if (p.selectedProp === attrId) return
      p.selectedProp = attrId
      emit('filter-change', getFilterParams())
    }

    return { filterData, filterLoading, changeBrand, changeAttr }
  }
  • 组件 src/views/category/sub.vue
<SubFilter @sort-change="changeFilter" />
<GoodsSort @filter-change="changeSort"/>
    // 监听筛选区改变
    const changeFilter = (filterParams) => {
      goodsList.value = []
      finished.value = false
      reqParams = { ...data, ...filterParams, page: 1 }
      onLoad()
    }
    // 监听排序改变
    const changeSort = (data) => {
      goodsList.value = []
      finished.value = false
      data = { ...data, ...data, page: 1 }
      onLoad()
    }

    return { loading, finished, goodsList, getData, changeFilter, changeSort }

# 14-二级类目-结果区-数据加载

目的:实现结果区域商品展示。

大致步骤:

  • 完成结果区域商品布局
  • 完成 xtx-infinite-loading 组件封装
  • 使用 xtx-infinite-loading 完成数据加载和渲染

落地代码:src/views/category/sub.vue

  1. 基础布局
<template>
  <div class='sub-category'>
    <div class="container">
      <!-- 面包屑 -->
      <SubBread />
      <!-- 筛选区 -->
      <SubFilter />
      <!-- 结果区域 -->
      <div class="goods-list">
        <!-- 排序 -->
        <SubSort />
        <!-- 列表 -->
        <ul>
          <li v-for="i in 20" :key="i" >
            <GoodsItem :goods="{}" />
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import SubBread from './components/sub-bread'
import SubFilter from './components/sub-filter'
import SubSort from './components/sub-sort'
import GoodsItem from './components/goods-item'
export default {
  name: 'SubCategory',
  components: { SubBread, SubFilter, SubSort, GoodsItem }
}
</script>

<style scoped lang='less'>
.goods-list {
  background: #fff;
  padding: 0 25px;
  margin-top: 25px;
  ul {
    display: flex;
    flex-wrap: wrap;
    padding: 0 5px;
    li {
      margin-right: 20px;
      margin-bottom: 20px;
      &:nth-child(5n) {
        margin-right: 0;
      }
    }
  }
}
</style>
  1. 无限列表加载组件 src/components/library/xtx-infinite-loading.vue
  • 参考 Vant 的List组件:https://vant-contrib.gitee.io/vant/#/zh-CN/list
<template>
  <div class="xtx-infinite-loading" ref="target">
    <div class="loading" v-show="loading">
      <span class="img"></span>
      <span class="text">正在加载...</span>
    </div>
    <div class="none" v-if="finished">
      <span class="img"></span>
      <span class="text">亲,没有更多了</span>
    </div>
  </div>
</template>

<script>
import { ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
export default {
  name: 'XtxInfiniteLoading',
  props: {
    loading: {
      type: Boolean,
      default: false
    },
    finished: {
      type: Boolean,
      default: false
    }
  },
  setup (props, { emit }) {
    const target = ref(null)
    useIntersectionObserver(
      target,
      ([{ isIntersecting }], dom) => {
        if (isIntersecting) {
          if (props.loading === false && props.finished === false) {
            emit('load')
          }
        }
      }
    )
    return { target }
  }
}
</script>

<style scoped lang='less'>
.xtx-infinite-loading {
  .loading {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 200px;
    .img {
      width: 50px;
      height: 50px;
      background: url(~@/assets/images/load.gif) no-repeat center / contain;
    }
    .text {
      color: #999;
      font-size: 16px;
    }
  }
  .none {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 200px;
    .img {
      width: 200px;
      height: 134px;
      background: url(~@/assets/images/none.png) no-repeat center / contain;
    }
    .text {
      color: #999;
      font-size: 16px;
    }
  }
}
</style>

  1. 定义获取数据的API src/api/category.js

/**
 * 获取分类下的商品(带筛选条件)
 * @param {Object} params - 可参考接口文档
 */
export const findSubCategoryGoods = (params) => {
  return request('/category/goods/temporary', 'post', params)
}


  1. src/views/category/sub.vue 使用组件
      <!-- 结果区域 -->
      <div class="goods-list">
        <!-- 排序 -->
        <GoodsSort />
        <!-- 列表 -->
        <ul>
          <li v-for="item in list" :key="item.id" >
            <GoodsItem :goods="item" />
          </li>
        </ul>
        <!-- 加载 -->
+        <XtxInfiniteLoading :loading="loading" :finished="finished" @load="onLoad" />
      </div>
<script>
import SubBread from './components/sub-bread'
import SubFilter from './components/sub-filter'
import SubSort from './components/sub-sort'
import GoodsItem from './components/goods-item'
import { ref, watch } from 'vue'
import { findSubCategoryGoods } from '@/api/category'
import { useRoute } from 'vue-router'
export default {
  name: 'SubCategory',
  components: { SubBread, SubFilter, SubSort, GoodsItem },
  setup () {
    // 1. 基础布局
    // 2. 无限加载组件
    // 3. 动态加载数据且渲染
    // 4. 任何筛选条件变化需要更新列表
    const route = useRoute()

    // 请求参数
    const data = {
      page: 1, // 页码
      pageSize: 20 // 页容量
    }

    // 加载状态
    const loading = ref(false)
    // 结束状态
    const finished = ref(false)

    // 商品列表
    const goodsList = ref([])

    // 加载数据
    const onLoad = async () => {
      // 请求前 - 显示请求动画
      loading.value = true
      // 请求中
      const { result } = await findSubCategoryGoods(data)
      // 请求完成 - 隐藏加载动画
      loading.value = false
      // console.log(result)
      // 🔔注意:分页加载时数组追加(不是直接赋值)
      list.value.push(...result.items)
      // 判断是否没有更多数据了
      if (result.items.length < data.pageSize) {
        // 显示没有更多数据了
        finished.value = true
        return
      }
      // 页码需要加1
      data.page++
    }

    // 切换二级分类重新加载
    watch(() => route.params.id, (id) => {
      if (route.path === '/category/sub/' + id) {
        goodsList.value = []
        data = {
          categoryId: id,
          page: 1,
          pageSize: 20
        }
        finished.value = false
      }
    })

    return { loading, finished, goodsList, getData }
  }
}
</script>