Skip to content

封装轮播图 Carousel 组件

​ 一般看到的 Carousel 组件都为一个 Carousel 父组件包裹 n 个 carousel-item 组件。

vue
<Carousel :height="180">
  <CarouselItem>1</CarouselItem>
  <CarouselItem>2</CarouselItem>
</Carousel>
<Carousel :height="180">
  <CarouselItem>1</CarouselItem>
  <CarouselItem>2</CarouselItem>
</Carousel>

​ 一个 carousel-item 组件代表一个轮播项,要在 Carousel 组件传入内容就需要先声明一个默认插槽,供给使用者来声明轮播项列表,同时 carousel-item 组件也需要提供一个默认插槽来代表该轮播项里面的内容。

​ Carousel 组件提供的默认插槽可以让使用者传入各式各样的 DOM 元素或组件,但是我们只以 carousel-item 组件为一个轮播项,所以需要过滤掉不需要渲染的内容,我们只需要接受 carousel-item 组件,其他内容都不需要渲染,所以需要获取到需要渲染的 carousel-item 组件列表。

​ defineSlots 返回值为一个对象,对象中某个成员代表一个插槽,成员值为一个函数,调用即可获取到对应插槽里面的内容,这个内容其实是一个虚拟 DOM,虚拟 DOM 是可以通过 component 组件来渲染的。

ts
// Carousel组件的script部分

// 自定义属性
const props = defineProps<{ height: number }>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容,以CarouselItem为一个轮播项,过滤出需要渲染的carouselItem组件列表,ele.type.name为组件的名称,我们只需要过滤出carouse-item组件实例即可,其他内容都不需要。
const defaultsList = slots
  .default()
  .filter((ele: any) => ele.type.name === "CarouselItem");

console.log(defaultsList);

defineOptions({
  name: "Carousel",
});
// Carousel组件的script部分

// 自定义属性
const props = defineProps<{ height: number }>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容,以CarouselItem为一个轮播项,过滤出需要渲染的carouselItem组件列表,ele.type.name为组件的名称,我们只需要过滤出carouse-item组件实例即可,其他内容都不需要。
const defaultsList = slots
  .default()
  .filter((ele: any) => ele.type.name === "CarouselItem");

console.log(defaultsList);

defineOptions({
  name: "Carousel",
});

​ 仅仅提供了一个默认插槽,供使用者传入轮播项的内容。

vue
<template>
  <div class="carousel-item">
    <slot></slot>
  </div>
</template>

<script lang="ts" setup>
defineOptions({
  name: "CarouselItem",
});
</script>

<style scoped lang="scss">
.carousel-item {
}
</style>
<template>
  <div class="carousel-item">
    <slot></slot>
  </div>
</template>

<script lang="ts" setup>
defineOptions({
  name: "CarouselItem",
});
</script>

<style scoped lang="scss">
.carousel-item {
}
</style>

​ 由于插槽里面的内容默认情况下都是直接渲染出来的。但是我们只需要渲染 carousel-item 列表组件,所以我们可以在通过 slot 接受到插槽内容后,适用 v-if 禁止默认插槽的渲染。我们需要手动渲染接受到的插槽就可以使用我们的神component 动态组件,来实现手动渲染。过滤出来 carousel-item 组件列表之后,需要展示只需要 v-for 列表渲染 component 动态组件并传入对应的组件虚拟 DOM 即可完成手动渲染内容。

不过下面这种过滤的方式不支持 v-for 指令渲染,指令渲染时需要额外进行处理。

vue
<template>
  <div
    class="carousel-container"
    :style="{ height: height + 'px' }">
    <slot v-if="false"></slot>
    <component
      v-for="(item, index) in defaultsList"
      :key="index"
      :is="item" />
  </div>
</template>

<script lang="ts" setup>
// 自定义属性
const props = defineProps<{ height: number }>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容,以CarouselItem为一个轮播项
const defaultsList = slots
  .default()
  .filter((ele: any) => ele.type.name === "CarouselItem");

console.log(defaultsList);

defineOptions({
  name: "Carousel",
});
</script>

<style scoped lang="scss">
.carousel-container {
  width: 100%;
}
</style>
<template>
  <div
    class="carousel-container"
    :style="{ height: height + 'px' }">
    <slot v-if="false"></slot>
    <component
      v-for="(item, index) in defaultsList"
      :key="index"
      :is="item" />
  </div>
</template>

<script lang="ts" setup>
// 自定义属性
const props = defineProps<{ height: number }>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容,以CarouselItem为一个轮播项
const defaultsList = slots
  .default()
  .filter((ele: any) => ele.type.name === "CarouselItem");

console.log(defaultsList);

defineOptions({
  name: "Carousel",
});
</script>

<style scoped lang="scss">
.carousel-container {
  width: 100%;
}
</style>

​ 由于通过 v-for 指令渲染时,会创建一个虚拟 DOM,然后把列表渲染的内容作为该虚拟 DOM 的子孩子,此时就需要单独的进行判断了。

​ 可以看到 v-for 渲染时,v-for 的虚拟 DOM 实例的 type 为 symbol,symbol 可以转换成字符串,我们转换成字符串比对即可知晓本次渲染是通过模板渲染还是 v-for 指令渲染了,v-for 指令渲染时,去查看他的子孩子列表,若子孩子虚拟 DOM 的名称为 carousel-item,则保存即可。

模板渲染时,插槽接受到的内容:

图片

v-for 指令渲染时,插槽接受到的内容:

​ 若使用了多个 v-for 指令,只会创建多个外层虚拟 DOM,其子孩子就是对应渲染的列表。

图片

图片

最后

vue
<template>
  <div
    class="carousel-container"
    :style="{ height: height + 'px' }">
    <div
      class="carousel-box"
      :style="{ left: `-${currentIndex * 100}%` }">
      <component
        v-for="(item, index) in carouselList"
        :key="index"
        :is="item" />
    </div>
    <div class="dots-container">
      <CarouselDot
        v-model="currentIndex"
        :index="index"
        v-for="(item, index) in carouselList"
        :key="index" />
    </div>
  </div>
  <slot v-if="false"></slot>
</template>

<script lang="ts" setup>
// 组件
import CarouselDot from "./components/CarouselDot.vue";
// hooks
import { ref } from "vue";

//当前显示的轮播图索引
const currentIndex = ref(0);
// 轮播图的自定义属性
const props = defineProps<{ height: number }>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容
const defaultsList = slots.default();

const carouselList = defaultsList.reduce((list: any, ele: any) => {
  if (ele.type.name === "CarouselItem") {
    // 若是模板渲染的子项且组件名称为CarouselItem就保留
    list.push(ele);
    return list;
  } else if ((ele.type as Symbol).toString() === "Symbol(v-fgt)") {
    // 若是通过v-for指令渲染的 则需要获取所有渲染的子内容并使用组件名是否为Carouselitme的筛选
    ele.children
      .filter((son: any) => son.type.name === "CarouselItem")
      .forEach((item: any) => list.push(item));
    return list;
  }
}, []);

console.log("轮播项列表:", carouselList);

defineOptions({
  name: "Carousel",
});
</script>

<style scoped lang="scss">
.carousel-container {
  overflow: hidden;
  position: relative;

  .carousel-box {
    display: flex;
    position: absolute;
    left: 0;
    transition: 0.3s;
    width: 100%;
    height: 100%;
    z-index: 100;
  }

  .dots-container {
    position: absolute;
    width: 100%;
    display: flex;
    z-index: 101;
    bottom: 10px;
    justify-content: center;
  }
}
</style>
<template>
  <div
    class="carousel-container"
    :style="{ height: height + 'px' }">
    <div
      class="carousel-box"
      :style="{ left: `-${currentIndex * 100}%` }">
      <component
        v-for="(item, index) in carouselList"
        :key="index"
        :is="item" />
    </div>
    <div class="dots-container">
      <CarouselDot
        v-model="currentIndex"
        :index="index"
        v-for="(item, index) in carouselList"
        :key="index" />
    </div>
  </div>
  <slot v-if="false"></slot>
</template>

<script lang="ts" setup>
// 组件
import CarouselDot from "./components/CarouselDot.vue";
// hooks
import { ref } from "vue";

//当前显示的轮播图索引
const currentIndex = ref(0);
// 轮播图的自定义属性
const props = defineProps<{ height: number }>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容
const defaultsList = slots.default();

const carouselList = defaultsList.reduce((list: any, ele: any) => {
  if (ele.type.name === "CarouselItem") {
    // 若是模板渲染的子项且组件名称为CarouselItem就保留
    list.push(ele);
    return list;
  } else if ((ele.type as Symbol).toString() === "Symbol(v-fgt)") {
    // 若是通过v-for指令渲染的 则需要获取所有渲染的子内容并使用组件名是否为Carouselitme的筛选
    ele.children
      .filter((son: any) => son.type.name === "CarouselItem")
      .forEach((item: any) => list.push(item));
    return list;
  }
}, []);

console.log("轮播项列表:", carouselList);

defineOptions({
  name: "Carousel",
});
</script>

<style scoped lang="scss">
.carousel-container {
  overflow: hidden;
  position: relative;

  .carousel-box {
    display: flex;
    position: absolute;
    left: 0;
    transition: 0.3s;
    width: 100%;
    height: 100%;
    z-index: 100;
  }

  .dots-container {
    position: absolute;
    width: 100%;
    display: flex;
    z-index: 101;
    bottom: 10px;
    justify-content: center;
  }
}
</style>

​ 也就是 carousel 组件默认插槽直接传入对应原生标签,以一个标签对应一个轮播项,只需要在 carousel 组件中进行处理即可。

5.实现末尾两张的平滑切换

​ 依旧使用老套路,在列表的前面和后面加上最后一项和第一个轮播项,此时列表为 n+2 个,临界值为 1 和 n。虽然有点 bug,不过算了。

​ 完成:

vue
<template>
  <div
    class="carousel-container"
    :style="{ height: height + 'px' }">
    <div
      class="carousel-box"
      :style="{ left: `-${currentIndex * 100}%`, transition: `${tsTime}s` }">
      <component :is="carouselList[carouselList.length - 1]"></component>
      <component
        v-for="(item, index) in carouselList"
        :key="index"
        :is="item" />
      <component :is="carouselList[0]"></component>
    </div>
    <div
      v-if="showDots"
      class="dots-container">
      <CarouselDot
        v-model="currentIndex"
        :index="item"
        v-for="item in carouselList.length"
        :key="item" />
    </div>
    <div
      class="carousel-btns"
      v-if="showBtns">
      <div
        class="carousel-btn"
        @click="throttlePre">
        &lt;
      </div>
      <div
        class="carousel-btn"
        @click="throttleNext">
        >
      </div>
    </div>
  </div>
  <slot v-if="false"></slot>
</template>

<script lang="ts" setup>
// 组件
import CarouselDot from "./components/CarouselDot.vue";
// hooks
import { ref, onMounted, nextTick, watch } from "vue";
import throttle from "@/utils/tools/throttle";

//当前显示的轮播图索引
// 1--- carousel.length为轮播项的范围,0是追加到最前面的最后一项  carousel.length+1为追加到最后面的第一项
const currentIndex = ref(1);
// 轮播图的自定义属性
const props = defineProps<{
  /**
   * 高
   */
  height: number;
  /**
   * 是否显示指示器
   */
  showDots: boolean;
  /**
   * 是否显示上下轮播项按钮
   */
  showBtns: boolean;
  /**
   * 是否自动播放
   */
  autoplay: boolean;
  /**
   * 自动播放的延迟时间
   */
  delay: number;
}>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容
const defaultsList = slots.default();
// 轮播图的过渡时间
const tsTime = ref<0.3 | 0>(0.3);
console.log(defaultsList);
// 要渲染的轮播项
const carouselList: any[] = defaultsList.reduce((list: any, ele: any) => {
  if (ele.type.name === "CarouselItem") {
    // 若是模板渲染的子项且组件名称为CarouselItem就保留
    list.push(ele);
    return list;
  } else if (
    typeof ele.type === "symbol" &&
    ele.type.toString() === "Symbol(v-fgt)"
  ) {
    // 若是通过v-for指令渲染的ele.type则是一个symbol类型 则需要获取所有渲染的子内容并使用组件名是否为Carouselitme的筛选
    ele.children
      .filter((son: any) => son.type.name === "CarouselItem")
      .forEach((item: any) => list.push(item));
    return list;
  } else {
    // 若为其他内容则不需要渲染
    return list;
  }
}, []);

// console.log('轮播项列表:', carouselList)

/**
 * 上一张
 */
function onHandlePre() {
  if (currentIndex.value == 1) {
    // 1为临界值 需要特殊处理
    // 先让其正常滑动到 0 也就是展示最后一项
    currentIndex.value--;
    // 当页面更新后
    nextTick(() => {
      // 延迟300ms是因为轮播图动画是三秒 当动画执行完成时也就是移动到正常范围-1处了,也就是重复的最后一项。我们需要设置过渡时间为0,好让改变left时没有动画效果,然后立即移动到正常范围的最后一项。
      setTimeout(() => {
        tsTime.value = 0;
        currentIndex.value = carouselList.length;
        // 当移动到最后一项后需要让过渡时间恢复 但是必须要用延迟器,而且必须要设置时间为动画效果的时间??(存疑),否则也会被同步更新,会导致移动到正常范围的最后一项会出现动画效果。
        nextTick(() => {
          setTimeout(() => {
            tsTime.value = 0.3;
          }, 150);
        });
      }, 300);
    });
  } else {
    currentIndex.value--;
  }
}

/**
 * 下一张
 */
function onHandleNext() {
  if (currentIndex.value === carouselList.length) {
    // 若当前是正常范围的最后一项 需要另做处理
    currentIndex.value++;
    nextTick(() => {
      // 调整过渡时间 立即跳转到正常范围中的第一项
      setTimeout(() => {
        tsTime.value = 0;
        currentIndex.value = 1;
        // 恢复过渡时间
        nextTick(() => {
          setTimeout(() => {
            tsTime.value = 0.3;
          }, 150);
        });
      }, 300);
    });
  } else {
    currentIndex.value++;
  }
}

const throttleNext = throttle(onHandleNext, 500);
const throttlePre = throttle(onHandlePre, 500);

// 是否自动播放?
if (props.autoplay) {
  onMounted(() => {
    setInterval(() => {
      throttleNext();
    }, props.delay);
  });
}

defineOptions({
  name: "Carousel",
});
</script>

<style scoped lang="scss">
.carousel-container {
  overflow: hidden;
  position: relative;

  .carousel-box {
    display: flex;
    position: absolute;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 100;
  }

  .dots-container {
    background-image: linear-gradient(to top, #0000002d, #00000000);
    position: absolute;
    width: 100%;
    display: flex;
    z-index: 102;
    bottom: 0px;
    height: 35px;
    align-items: center;
    justify-content: center;
  }

  .carousel-btns {
    pointer-events: none;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 101;
    display: flex;
    align-items: center;

    .carousel-btn {
      pointer-events: all;
      position: absolute;
      font-size: 18px;
      cursor: pointer;
      height: 25px;
      width: 25px;
      text-align: center;
      background-color: var(--dot-not-acitve-color);
      line-height: 25px;
      border-radius: 50%;
      color: #fff;
      transition: 0.3s;

      &:hover {
        background-color: var(--dot-acitve-color);
      }

      &:first-child {
        left: 10px;
      }

      &:last-child {
        right: 10px;
      }
    }
  }
}
</style>
<template>
  <div
    class="carousel-container"
    :style="{ height: height + 'px' }">
    <div
      class="carousel-box"
      :style="{ left: `-${currentIndex * 100}%`, transition: `${tsTime}s` }">
      <component :is="carouselList[carouselList.length - 1]"></component>
      <component
        v-for="(item, index) in carouselList"
        :key="index"
        :is="item" />
      <component :is="carouselList[0]"></component>
    </div>
    <div
      v-if="showDots"
      class="dots-container">
      <CarouselDot
        v-model="currentIndex"
        :index="item"
        v-for="item in carouselList.length"
        :key="item" />
    </div>
    <div
      class="carousel-btns"
      v-if="showBtns">
      <div
        class="carousel-btn"
        @click="throttlePre">
        &lt;
      </div>
      <div
        class="carousel-btn"
        @click="throttleNext">
        >
      </div>
    </div>
  </div>
  <slot v-if="false"></slot>
</template>

<script lang="ts" setup>
// 组件
import CarouselDot from "./components/CarouselDot.vue";
// hooks
import { ref, onMounted, nextTick, watch } from "vue";
import throttle from "@/utils/tools/throttle";

//当前显示的轮播图索引
// 1--- carousel.length为轮播项的范围,0是追加到最前面的最后一项  carousel.length+1为追加到最后面的第一项
const currentIndex = ref(1);
// 轮播图的自定义属性
const props = defineProps<{
  /**
   * 高
   */
  height: number;
  /**
   * 是否显示指示器
   */
  showDots: boolean;
  /**
   * 是否显示上下轮播项按钮
   */
  showBtns: boolean;
  /**
   * 是否自动播放
   */
  autoplay: boolean;
  /**
   * 自动播放的延迟时间
   */
  delay: number;
}>();
// 定义插槽
const slots = defineSlots<{ default: () => any }>();
// 获取默认插槽的内容
const defaultsList = slots.default();
// 轮播图的过渡时间
const tsTime = ref<0.3 | 0>(0.3);
console.log(defaultsList);
// 要渲染的轮播项
const carouselList: any[] = defaultsList.reduce((list: any, ele: any) => {
  if (ele.type.name === "CarouselItem") {
    // 若是模板渲染的子项且组件名称为CarouselItem就保留
    list.push(ele);
    return list;
  } else if (
    typeof ele.type === "symbol" &&
    ele.type.toString() === "Symbol(v-fgt)"
  ) {
    // 若是通过v-for指令渲染的ele.type则是一个symbol类型 则需要获取所有渲染的子内容并使用组件名是否为Carouselitme的筛选
    ele.children
      .filter((son: any) => son.type.name === "CarouselItem")
      .forEach((item: any) => list.push(item));
    return list;
  } else {
    // 若为其他内容则不需要渲染
    return list;
  }
}, []);

// console.log('轮播项列表:', carouselList)

/**
 * 上一张
 */
function onHandlePre() {
  if (currentIndex.value == 1) {
    // 1为临界值 需要特殊处理
    // 先让其正常滑动到 0 也就是展示最后一项
    currentIndex.value--;
    // 当页面更新后
    nextTick(() => {
      // 延迟300ms是因为轮播图动画是三秒 当动画执行完成时也就是移动到正常范围-1处了,也就是重复的最后一项。我们需要设置过渡时间为0,好让改变left时没有动画效果,然后立即移动到正常范围的最后一项。
      setTimeout(() => {
        tsTime.value = 0;
        currentIndex.value = carouselList.length;
        // 当移动到最后一项后需要让过渡时间恢复 但是必须要用延迟器,而且必须要设置时间为动画效果的时间??(存疑),否则也会被同步更新,会导致移动到正常范围的最后一项会出现动画效果。
        nextTick(() => {
          setTimeout(() => {
            tsTime.value = 0.3;
          }, 150);
        });
      }, 300);
    });
  } else {
    currentIndex.value--;
  }
}

/**
 * 下一张
 */
function onHandleNext() {
  if (currentIndex.value === carouselList.length) {
    // 若当前是正常范围的最后一项 需要另做处理
    currentIndex.value++;
    nextTick(() => {
      // 调整过渡时间 立即跳转到正常范围中的第一项
      setTimeout(() => {
        tsTime.value = 0;
        currentIndex.value = 1;
        // 恢复过渡时间
        nextTick(() => {
          setTimeout(() => {
            tsTime.value = 0.3;
          }, 150);
        });
      }, 300);
    });
  } else {
    currentIndex.value++;
  }
}

const throttleNext = throttle(onHandleNext, 500);
const throttlePre = throttle(onHandlePre, 500);

// 是否自动播放?
if (props.autoplay) {
  onMounted(() => {
    setInterval(() => {
      throttleNext();
    }, props.delay);
  });
}

defineOptions({
  name: "Carousel",
});
</script>

<style scoped lang="scss">
.carousel-container {
  overflow: hidden;
  position: relative;

  .carousel-box {
    display: flex;
    position: absolute;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 100;
  }

  .dots-container {
    background-image: linear-gradient(to top, #0000002d, #00000000);
    position: absolute;
    width: 100%;
    display: flex;
    z-index: 102;
    bottom: 0px;
    height: 35px;
    align-items: center;
    justify-content: center;
  }

  .carousel-btns {
    pointer-events: none;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 101;
    display: flex;
    align-items: center;

    .carousel-btn {
      pointer-events: all;
      position: absolute;
      font-size: 18px;
      cursor: pointer;
      height: 25px;
      width: 25px;
      text-align: center;
      background-color: var(--dot-not-acitve-color);
      line-height: 25px;
      border-radius: 50%;
      color: #fff;
      transition: 0.3s;

      &:hover {
        background-color: var(--dot-acitve-color);
      }

      &:first-child {
        left: 10px;
      }

      &:last-child {
        right: 10px;
      }
    }
  }
}
</style>