实现vue轮播图Carousel组件

1,830 阅读2分钟

概述

轮播图相信每个做前端的都用过,但是基本上都是直接使用组件库现成的,或者一些比较流行的轮播图插件比如swiper,现在就带你手写vue轮播图组件。你将会学到:

  • 发布订阅和跨组件通信的解决方案。
  • 组件之间数据相互获取的技巧

最终目标

动画.gif

实现原理

在原生js实现轮播图中,是比较麻烦的,具体原生js的写法,大家可以自行搜索,这里介绍使用框架来实现轮播组件,相比原生js的写法来说,你一定看完觉得框架使用起来实现这个功能相当简单。原理如下:

  1. 首先我们将所有的一个个item绝对定位然后叠在一起。
  2. 动态根据初始选中项的索引,然后决定显示对应索引项的那一个item,其他的隐藏(v-if)
  3. 至于切换动画我们可以使用transition组件来过渡完成
  4. 至于左右切换箭头,和指示器,可以根据用户自定义配置来决定是否显示

是不是觉得思路很简单,好吧,我们开始写吧

项目结构

自行搭建vue2项目的脚手架,目录层级结构如下

  • components
    • Carousel
      • Carousel.vue(暴露给用户使用的组件)
      • CarouselArrow.vue(左右箭头切换组件)
      • CarouselIndicator.vue(底部指示器组件)
      • CarouselItem.vue(一个个轮播item组件)
      • index.js(按需导出文件)
      • Pubsub.js(用于跨组件通信)

App.vue

<template>
  <div id="app">
   <!-- 添加自定义配置项,具体用途请看Carousel组件props -->
      <carousel
        :height="300"
        :initialIndex="2"
        trigger="click"
        :autoplay="true"
        :interval="2000"
        :showArrow="true"
        :showIndicator="true"
        @change="handleCarouselChange"
      >
        <carousel-item v-for="(item, index) in list" :key="index">
          <div class="img-item">
            <img :src="item.src" />
          </div>
        </carousel-item>
      </carousel>
    </div>
  </div>
</template>

<script>
import { Carousel, CarouselItem } from "./components/Carousel/index";
export default {
  name: "App",
  components: {
    Carousel,
    CarouselItem,
  },
  data() {
    return {
    //轮播数据
      list: [
        {
          id: 1,
          src: require("@/assets/img/01.webp"),
        },
        {
          id: 2,
          src: require("@/assets/img/02.webp"),
        },
        {
          id: 3,
          src: require("@/assets/img/03.webp"),
        },
        {
          id: 4,
          src: require("@/assets/img/04.webp"),
        },
      ],
    };
  },
  methods: {
  //切换的时候订阅的事件
    handleCarouselChange(item) {
      console.log(item);
    },
  },
};
</script>

<style lang="less">
  .img-item {
    img {
      width: 500px;
      height: 300px;
    }
  }
}
</style>

components/Carousel/Carousel.vue

<template>
  <div
    class="carousel"
    :style="{ height: height + 'px' }"
    @mouseenter.stop="handleMouseEnter"
    @mouseleave.stop="handleMouseLeave"
  >
    <!--S 主体内容 -->
    <slot></slot>
    <!--E 主体内容 -->

    <!--S 指示器 -->
    <div class="carousel-indicator-wrap" v-if="showIndicator">
      <carousel-indicator
        :length="len"
        :currentIndex="currentIndex"
        @carouselIndicatorChange="carouselIndicatorChange"
      ></carousel-indicator>
    </div>
    <!--E 指示器 -->

    <!--S 切换箭头 -->
    <transition-group name="arrow-animate">
      <carousel-arrow
        v-if="currentArrowStatus && showArrow"
        @click="arrowChange('prev')"
        class="left-arrow arrow"
        key="prev"
        >&lt;</carousel-arrow
      >
      <carousel-arrow
        v-if="currentArrowStatus && showArrow"
        @click="arrowChange('next')"
        class="right-arrow arrow"
        key="next"
        >&gt;</carousel-arrow
      >
    </transition-group>

    <!--E 切换箭头 -->
  </div>
</template>

<script>
import Pubsub from "./utils";//跨组件通信,本质发布订阅
import CarouselIndicator from "./CarouselIndicator.vue";//指示器
import CarouselArrow from "./CarouselArrow.vue";//切换剪头
export default {
  components: { CarouselIndicator, CarouselArrow },
  props: {
    // 高度
    height: {
      type: Number,
      default: 300,
    },
    // 初始显示项
    initialIndex: {
      type: Number,
      default: 0,
    },
    // 指示器的触发方法
    trigger: {
      type: String,
      default: "click",
    },
    // 是否自动播放
    autoplay: {
      type: Boolean,
      default: true,
    },
    // 自动播放时间间隔
    interval: {
      type: Number,
      default: 3000,
    },
    // 是否显示左右箭头
    showArrow: {
      type: Boolean,
      default: false,
    },
    // 是否显示指示器
    showIndicator: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      // 当前活跃项,用户可自定义,默认为0
      currentIndex: this.initialIndex || 0,
      // 轮播定时器标识
      timer: null,
      // 轮播项的数据长度,这里我们可以使用插槽获取到
      len: this.$slots.default.length,
      // 当前箭头滑过的显示状态
      currentArrowStatus: false,
    };
  },
  mounted() {
    // 挂载的时候就开始自动轮播
    this.autoPlay();
  },
  methods: {
    // 自动轮播
    autoPlay() {
      this.timer = setInterval(() => {
        this.handlePlay();
      }, this.interval);
    },
    /**
     * @Description 轮播逻辑
     * 轮播的下标在[0,lenght-1]之前,超出下标之后,要重置
     **/
    handlePlay(type) {
      switch (type) {
        case "prev":
          this.currentIndex--;
          if (this.currentIndex < 0) {
            this.currentIndex = this.len - 1;
          }
          //发布更新事件,告知CarouselItem组件进行切换
          Pubsub.notice("updateCurrentIndex", this.currentIndex);
          break;
        case "next":
        default:
          this.currentIndex++;
          if (this.currentIndex >= this.len) {
            this.currentIndex = 0;
          }
          //发布更新事件,告知CarouselItem组件进行切换
          Pubsub.notice("updateCurrentIndex", this.currentIndex);
          break;
      }
      // 切换后发布change事件,告知用户,当前项已经切换了,参数为当前索引
      this.$emit("change", this.currentIndex);
    },
    // 切换箭头点击触发
    arrowChange(type) {
      this.handlePlay(type);
    },
    // 鼠标进入
    handleMouseEnter() {
      this.stopInterval();
      this.currentArrowStatus = true;
    },
    // 鼠标离开
    handleMouseLeave() {
      this.autoPlay();
      this.currentArrowStatus = false;
    },
    // 指示器点击切换
    carouselIndicatorChange(index) {
      this.currentIndex = index;
      //发布更新事件
      Pubsub.notice("updateCurrentIndex", this.currentIndex);
    },
    // 清除定时器
    stopInterval() {
      clearInterval(this.timer);
      this.timer = null;
    },
  },
  beforeDestroy() {
    // 销毁前清空订阅的事件和定时器
    this.clearInterval();
    Pubsub.clearEventQueen();
  },
};
</script>

<style lang="less">
.carousel {
  overflow: hidden;
  position: relative;
  width: 100%;
  overflow: hidden;
  .arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
  }
  .left-arrow {
    left: 10px;
  }
  .right-arrow {
    right: 10px;
  }
  .carousel-indicator-wrap {
    position: absolute;
    bottom: 12px;
    left: 50%;
    transform: translateX(-50%);
  }
  .arrow-animate-enter-active,
  .arrow-animate-leave-active {
    transition: opacity 0.3s;
  }
  .arrow-animate-enter,
  .arrow-animate.leave-top {
    opacity: 0;
  }
}
</style>

</style>

components/Carousel/CarouselItem.vue

<template>
  <transition name="carousel-item-animate">
    <!-- 只显示当前活跃项 -->
    <div class="carousel-item" v-if="key == currentIndex">
      <slot></slot>
    </div>
  </transition>
</template>

<script>
import Pubsub from "./utils";
export default {
  data() {
    return {
      /* 
      *为什么获取这些这么麻烦,因为我们组件内部需要的东西,需要我们自己想办法,组件使用者只需要关注配置
      */
      // 在App中我们绑定了每个item组件的key值,可以通过当前组件的vnode对象获取到
      key: this.$vnode.key,
      // 当前活跃项,我们需要获取到父组件当中Carousel的currentIndex
      currentIndex: this.$parent.currentIndex,
    };
  },
  mounted() {
    // 挂载的时候订阅更新当前活跃索引,然后更新数据,触发视图更新
    Pubsub.subscribe("updateCurrentIndex", (...arg) => {
      // 参数为当前活跃索引
      this.currentIndex = arg[0];
    });
  },
};
</script>

<style lang="less">
.carousel-item {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
}

/* 
*下面的是轮播过渡的重点,本质就是前一个组件像左边移动自身的长度,下一个显示的组件从右边距离自身100%的地方进来,然后过程加上动画
*/
// 离开进入中
.carousel-item-animate-enter-active,
.carousel-item-animate-leave-active {
  transition: all 0.3s linear;
}
// 进入初始状态
.carousel-item-animate-enter {
  transform: translateX(100%);
}
// 进入后
.carousel-item-animate-enter-to {
  transform: translateX(0);
}
//离开后的状态
.carousel-item-animate-leave-to {
  transform: translateX(-100%);
}
</style>

components/Carousel/CarouselArrow.vue

<template>
  <!-- 点击的时候进行切换,发布事件给 Carousel组件-->
  <div class="carousel-arrow" @click="$emit('click')">
    <slot></slot>
  </div>
</template>

<style lang="less">
.carousel-arrow {
  width: 50px;
  height: 50px;
  background: rgba(0, 0, 0, 0.5);
  border-radius: 50%;
  color: #fff;
  text-align: center;
  line-height: 50px;
  font-size: 20px;
  cursor: pointer;
}
</style>

components/Carousel/CarouselIndicator.vue

<template>
<!-- 指示器在当前活跃的时候高亮显示,点击的时候发布切换事件逻辑 -->
  <div class="carousel-indicator">
    <span
      v-for="(item, index) in length"
      :key="index"
      :class="[currentIndex == index ? 'active' : '']"
      @click="$emit('carouselIndicatorChange', index)"
    ></span>
  </div>
</template>

<script>
export default {
  props: {
    // 数据长度
    length: {
      type: Number,
      default: 0,
    },
    // 当前活跃项
    currentIndex: {
      type: Number,
      default: 0,
    },
  },
};
</script>

<style lang="less">
.carousel-indicator {
  span {
    display: inline-block;
    width: 30px;
    height: 3px;
    background-color: rgba(0, 0, 0, 0.4);
    margin: 0 5px;
    cursor: pointer;
  }
  span.active {
    background-color: #fff;
  }
}
</style>

components/Carousel/Pubsub.js

  • 对于发布订阅模式不清楚的,请自行查阅资料
  • 这里只对基本功能进行封装
  • vue内部实现自定义事件,本质也是这种方法,只不过这里我们简化了
/**
 * @Description 发布订阅的简单封装实现
 * 使用这种方式实现跨组件的通信理由如下:
 * 1.组件是给使用者使用的,不能过多要求使用者传递过多你内部逻辑实现需要的参数
 * 2.跨组件通信我们其实还可以使用vuex,但是组件库中,涉及到的只是单个组件,使用vuex也就没必要了
 **/
class Pubsub {
  constructor() {
    this.queenList = [];
  }
  //   订阅事件
  subscribe(eventName, fn) {
    let cur = this.queenList.find((item) => item.eventName == eventName);
    if (cur) {
      cur.callback.push(fn);
    } else {
      this.queenList.push({
        eventName,
        callback: [fn],
      });
    }
  }
  //   发布事件
  notice(eventName, ...args) {
    let cur = this.queenList.find((item) => item.eventName == eventName);
    if (cur) {
      cur.callback.forEach((fn) => {
        fn(...args);
      });
    }
  }
  // 清空事件队列
  clearEventQueen() {
    this.queenList = [];
  }
}

export default new Pubsub();


components/Carousel/index.js

//导出组件暴露给用户
import Carousel from "./Carousel";
import CarouselItem from "./CarouselItem";
export { Carousel, CarouselItem };

总结

知道基本思路之后,其实组件写轮播图还是蛮简单的,就是跨组件之间数据交互麻烦了点,但是组件既然是给用户使用,我们就只能自己想办法解决内部传值的问题。