手摸手带你封装Vue组件库(16)Carousel走马灯组件

我们 Carousel 走马灯组件一般分为两部分,一个是走马灯的容器组件 carousel,一个是走马灯的每一项组件 carousel-item,所以我们需要创建两个组件。

然后我们思考一下大致思路,carousel-item 在我点击向右的时候向左移动一位,容器中出现下一个的 carousel-item,当到最后一个再点击下一位就变为了第一个,反之也是如此,这样我们就可以设置一个 active 来控制每一个 carousel-item 的位置。

创建如下的结构。

carousel.js

js 复制代码
export const CarouselProps = {
  height: {
    type: String,
    default: "300px",
  },
};

carousel.vue

html 复制代码
<template>
  <div class="t-carousel" :style="{ height: props.height }">
    <div class="t-carousel__container">
      <slot></slot>
    </div>
  </div>
</template>
<script setup>
  import { CarouselProps } from "./carousel";

  defineOptions({
    name: "t-carousel",
  });

  const props = defineProps(CarouselProps);
</script>

carousel.less

css 复制代码
.t-carousel {
  position: relative;
  width: 500px;
  height: 200px;
  overflow: hidden;
  .t-carousel__container {
    width: 100%;
    height: 100%;
  }
}

carousel-item.vue

html 复制代码
<template>
  <div class="t-carousel-item"></div>
</template>
<script setup>
  defineOptions({
    name: "t-carousel-item",
  });
</script>

carousel-item.less

css 复制代码
.t-carousel-item {
  width: 100%;
  height: 100%;
  z-index: -1;
  position: absolute;
  &.is-active {
    z-index: 10;
  }
}

我们写一下示例

html 复制代码
<template>
  <t-carousel height="150px">
    <t-carousel-item>1</t-carousel-item>
    <t-carousel-item>2</t-carousel-item>
    <t-carousel-item>3</t-carousel-item>
    <t-carousel-item>4</t-carousel-item>
  </t-carousel>
</template>
<style>
  .t-carousel-item {
    color: #475669;
    opacity: 0.75;
    line-height: 150px;
    margin: 0;
    text-align: center;
  }

  .t-carousel-item:nth-child(2n) {
    background-color: #99a9bf;
  }

  .t-carousel-item:nth-child(2n + 1) {
    background-color: #d3dce6;
  }
</style>

基础样式和唯一处理

首先我们要知道 active 是哪一个,外部没有传递任何的唯一 id,我们该如何处理呢?其实类似于我们之前的 Step 组件,如果之前跟我们写过的人就知道了,我们可以通过 vnode 生成的 uid 来确认是哪个?这次我们换个简单思路,我们通过 provide 和 inject 来实现,先给 carousel 组件中定义一个数组来收集唯一的值,然后写一个 push 方法,将 push 方法和数组都 provide 出去,在 carousel-item 初始化的时候生成一个唯一的值,我们可以使用 symbol 来生成,然后调用 push 方法,将唯一的值传入,这样我们就得到了一个数组,每一个 carousel-item 都可以找到自己的位置。

carousel.vue

js 复制代码
import { provide } from "vue";
import { CarouselProps } from "./carousel";

defineOptions({
  name: "t-carousel",
});

const props = defineProps(CarouselProps);

const items = ref([]);

const activeIndex = ref(0);

const addItem = (item) => {
  items.value.push(item);
};

const removeItem = (uid) => {
  const index = items.value.findIndex((item) => item === uid);
  if (index > -1) {
    items.value.splice(index, 1);
  }
};

provide("carousel", {
  items,
  addItem,
  removeItem,
  activeIndex,
});
html 复制代码
<template>
  <div
    class="t-carousel-item"
    :class="{
      'is-active': currentIndex === activeIndex,
    }"
  >
    <slot></slot>
  </div>
</template>

<script setup>
  import { ref, inject, computed, onMounted, onBeforeUnmount } from "vue";
  import { CarouselItemProps } from "./carousel-item";

  defineOptions({
    name: "t-carousel-item",
  });

  const props = defineProps(CarouselItemProps);

  const uid = ref(Symbol("carousel-item"));

  const carousel = inject("carousel");

  const { activeIndex, items, addItem, removeItem } = carousel;

  const currentIndex = computed(() => {
    return items.value.indexOf(uid.value);
  });

  onMounted(() => {
    addItem(uid.value);
  });

  onBeforeUnmount(() => {
    removeItem(uid.value);
  });
</script>

这个 is-active 在第一个 carousel-item 的 class 上面加着,那说明我们没有问题。

切换功能实现

首先我们知道,每次切换到下一个都是右边的往左进入,前一个往左退出,并且有过渡效果,所以我们会想到先让所有的 carousel-item 摆成一排,先 overflow: hidden,只显示的当前 active 的,然后给 carousel-item 外面的盒子根据当前的 active 用 translate 来给设置位移,并且设置 transition 添加过渡效果,但是这样的话,我们在切换最后一个到第一个过渡的时候会返回去,体验很不好,所以一般我们会在最后面添加一个第一个 carousel-item,这样等到过渡到第一个(实际上是我们复制的第一个)的时候,取消过渡,然后将 active 设置到第一个,然后再过渡的时候添加上过渡效果,这样就可以无缝轮播了。

那有没有简单点的方案呢?

有的兄弟,有的。我们可以先把所有的carousel-item设置绝对定位,叠在一起,默认都是 z-index:-1; 然后 active 的设置为 10,每次轮播的时候下一个提前放在右侧,设置 translate 为 100%,设置 display: none, 然后当前设置 translate 为 0,当 active 的时候放开 display,然后让他从 translate(100%) 向 translate(0) 过渡,消失的时候从 translate(0) 向 translate(-100%) 过渡,等过渡结束又恢复到 left:0; z-index: -1; display: none; 这样就可以实现无缝轮播了。

那有没有更简单点的呢?

有的兄弟,有的,Vue 提供了 Transition 动画组件,可以将进入和离开动画应用到通过默认插槽传递给它的元素或组件,那就方便了很多了,我们可以直接设置进入和出来的样式即可。

carousel-item.vue

html 复制代码
<template>
  <Transition :name="transitionName" v-show="currentIndex === activeIndex">
    <div
      class="t-carousel-item"
      :class="{
        'is-active': currentIndex === activeIndex,
      }"
    >
      <slot></slot>
    </div>
  </Transition>
</template>

<script setup>
  import {
    ref,
    inject,
    computed,
    onMounted,
    onBeforeUnmount,
    nextTick,
  } from "vue";
  import { CarouselItemProps } from "./carousel-item";

  defineOptions({
    name: "t-carousel-item",
  });

  const props = defineProps(CarouselItemProps);

  const uid = ref(Symbol("carousel-item"));
  const transitionName = ref("carousel-next");

  const carousel = inject("carousel");

  const { activeIndex, items, addItem, removeItem } = carousel;

  const currentIndex = computed(() => {
    return items.value.indexOf(uid.value);
  });

  onMounted(() => {
    addItem(uid.value);
  });

  onBeforeUnmount(() => {
    removeItem(uid.value);
  });
</script>

carousel-item.less

css 复制代码
.t-carousel-item {
  width: 100%;
  height: 100%;
  z-index: -1;
  position: absolute;
  &.is-active {
    z-index: 10;
  }
}
.carousel-next-enter-active,
.carousel-next-leave-active {
  transition: all 0.35s linear;
}

.carousel-next-enter-active {
  transform: translateX(100%);
}
.carousel-next-enter-to,
.carousel-next-leave-active {
  transform: translateX(0);
}
.carousel-next-leave-to {
  transform: translateX(-100%);
}

我们写一个定时器,让他循环。

carousel.vue

js 复制代码
const play = (direction) => {
  if (direction === "prev") {
    activeIndex.value =
      activeIndex.value - 1 < 0
        ? items.value.length - 1
        : activeIndex.value - 1;
  } else {
    activeIndex.value =
      activeIndex.value + 1 > items.value.length - 1
        ? 0
        : activeIndex.value + 1;
  }
};

onMounted(() => {
  setInterval(() => {
    play("next");
  }, 2000);
});

这下无缝切换算是搞定了,但是我们发现刷新页面第一个 carousel-item 是从右边进来的,我们希望刚进入页面,第一个直接就在可视的位置内,然后过指定的时间从第二张才开始轮播,那我们处理一下添加动画的时机。

carousel-item.vue

js 复制代码
const transitionName = ref("");

onMounted(() => {
  addItem(uid.value);
  nextTick(() => {
    transitionName.value = "carousel-next";
  });
});

这时候我们将我们的轮播间隔、是否自动轮播抽成属性。

carousel.js

js 复制代码
export const CarouselProps = {
  height: {
    type: String,
    default: "300px",
  },
  autoplay: {
    type: Boolean,
    default: true,
  },
  interval: {
    type: Number,
    default: 3000,
  },
};
js 复制代码
let timer = null;

onMounted(() => {
  if (props.autoplay && props.interval > 0) {
    timer = setInterval(() => {
      play("next");
    }, props.interval);
  }
});

onUnmounted(() => {
  timer && clearInterval(timer);
});

主动切换

我们一般情况下需要主动切换,比如点击左右箭头,此时我们需要注意当我们鼠标移入轮播图范围的时候轮播图暂停,移出的时候继续轮播。我们先画一下左右的箭头。

carousel.vue

html 复制代码
<template>
  <div class="t-carousel" :style="{ height: props.height }">
    <div class="t-carousel__container">
      <slot></slot>
    </div>
    <button
      class="t-carousel__arrow t-carousel__arrow-left"
      @click="play('prev')"
    >
      <i class="t-icon icon-arrow-left-bold"></i>
    </button>
    <button
      class="t-carousel__arrow t-carousel__arrow-right"
      @click="play('next')"
    >
      <i class="t-icon icon-arrow-right-bold"></i>
    </button>
  </div>
</template>

carousel.less

css 复制代码
.t-carousel {
  position: relative;
  width: 500px;
  overflow: hidden;
  .t-carousel__container {
    width: 100%;
    height: 100%;
  }
  .t-carousel__arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background-color: rgba(31, 45, 61, 0.096);
    border: none;
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 30;
    transition: all 0.3s;
    cursor: pointer;
    &-left {
      left: 10px;
    }
    &-right {
      right: 10px;
    }
    .t-icon {
      font-size: 12px;
    }
    &:hover {
      background-color: rgba(31, 45, 61, 0.23);
    }
  }
}

接下来我们把鼠标移入暂停过渡,移除继续动画做一下。

html 复制代码
<template>
  <div
    class="t-carousel"
    :style="{ height: props.height }"
    @mouseenter="handleMouseEvent('enter')"
    @mouseleave="handleMouseEvent('leave')"
  >
    <div class="t-carousel__container">
      <slot></slot>
    </div>
    <button
      class="t-carousel__arrow t-carousel__arrow-left"
      @click="play('prev')"
    >
      <i class="t-icon icon-arrow-left-bold"></i>
    </button>
    <button
      class="t-carousel__arrow t-carousel__arrow-right"
      @click="play('next')"
    >
      <i class="t-icon icon-arrow-right-bold"></i>
    </button>
  </div>
</template>

<script setup>
  // ...

  let timer = null;

  const play = (direction) => {
    if (direction === "prev") {
      activeIndex.value =
        activeIndex.value - 1 < 0
          ? items.value.length - 1
          : activeIndex.value - 1;
    } else {
      activeIndex.value =
        activeIndex.value + 1 > items.value.length - 1
          ? 0
          : activeIndex.value + 1;
    }
  };

  const handleMouseEvent = (type) => {
    if (type === "enter") {
      timer && clearInterval(timer);
    } else {
      if (props.autoplay && props.interval > 0) {
        timer = setInterval(() => {
          play("next");
        }, props.interval);
      }
    }
  };

  onMounted(() => {
    if (props.autoplay && props.interval > 0) {
      timer = setInterval(() => {
        play("next");
      }, props.interval);
    }
  });

  // ...
</script>

我们不管是点向左还是向右,我们轮播都是从右往左切换,这时候我们完善一下反向切换的时候需要从左往右切换,那该怎么做呢?我们可以在点击向左的时候通过 provide 传递我们的方向,然后在 carousel-item 通过 inject 获取到方向,修改一下动画的名称,然后当我们鼠标移除轮播图的时候再重置一下轮播的方向即可。

carousel.vue

js 复制代码
// ...
const loopDirection = ref("next");

const play = (direction) => {
  loopDirection.value = direction;
  if (direction === "prev") {
    activeIndex.value =
      activeIndex.value - 1 < 0
        ? items.value.length - 1
        : activeIndex.value - 1;
  } else {
    activeIndex.value =
      activeIndex.value + 1 > items.value.length - 1
        ? 0
        : activeIndex.value + 1;
  }
};

const handleMouseEvent = (type) => {
  if (type === "enter") {
    timer && clearInterval(timer);
  } else {
    if (props.autoplay && props.interval > 0) {
      timer = setInterval(() => {
        play("next");
      }, props.interval);
    }
  }
};

provide("carousel", {
  items,
  addItem,
  removeItem,
  activeIndex,
  loopDirection,
});

carousel-item.vue

js 复制代码
const transitionName = ref("");

const carousel = inject("carousel");

const { activeIndex, items, addItem, removeItem, loopDirection } = carousel;

// ...

watch(
  () => loopDirection.value,
  (newVal) => {
    transitionName.value = `carousel-${newVal}`;
  }
);

这下我们点击左右切换都就可以了

指示器

我们一般情况下会在轮播图的下面加上一个指示器,这个可以显示当前有多少个轮播图,当前是第几个轮播图,鼠标点击或者 hover 某个指示器的时候可以快速跳转到对应的轮播图。

我们先来画一下指示器:

carousel.vue

html 复制代码
<template>
  <div
    class="t-carousel"
    :style="{ height: props.height }"
    @mouseenter="handleMouseEvent('enter')"
    @mouseleave="handleMouseEvent('leave')"
  >
    <!-- ... -->
    <ul class="t-carousel__indicators">
      <li
        v-for="(_, index) in items"
        :key="`indicators_${index}`"
        class="t-carousel__indicator"
      ></li>
    </ul>
  </div>
</template>

carousel.less

css 复制代码
.t-carousel__indicators {
  position: absolute;
  bottom: 14px;
  left: 50%;
  transform: translateX(-50%);
  padding: 0;
  margin: 0;
  z-index: 30;
  display: flex;
  .t-carousel__indicator {
    list-style: none;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: rgba(31, 45, 61, 0.096);
    margin: 0 4px;
    cursor: pointer;
    transition: all 0.3s;
  }
}

目前是这个样子,我们添加一下对应 active 和 hover 的样式,顺便加一下点击事件

html 复制代码
<template>
  <div
    class="t-carousel"
    :style="{ height: props.height }"
    @mouseenter="handleMouseEvent('enter')"
    @mouseleave="handleMouseEvent('leave')"
  >
    <!-- ... -->
    <ul class="t-carousel__indicators">
      <li
        v-for="(_, index) in items"
        :key="`indicators_${index}`"
        :class="[
          't-carousel__indicator',
          { 'is-active': index === activeIndex },
        ]"
        @click="activeIndex = index"
      ></li>
    </ul>
  </div>
</template>
css 复制代码
.t-carousel__indicators {
  /* ... */
  display: flex;
  .t-carousel__indicator {
    /* ... */
    &.is-active {
      background-color: var(--t-primary);
    }
    &:hover {
      background-color: var(--t-primary);
    }
  }
}

这下指示器也没问题。

箭头显示

我们一般情况下可以控制指示器是否显示,以及我们的左右箭头是始终显示还是 hover 的时候再显示,还是始终不显示。我们定义一个 arrow 的属性,分别有三种状态,always、hover、never,默认是 hover。

js 复制代码
const CAROUSEL_ARROW = ["always", "hover", "never"];

export const CarouselProps = {
  height: {
    type: String,
    default: "300px",
  },
  autoplay: {
    type: Boolean,
    default: true,
  },
  interval: {
    type: Number,
    default: 3000,
  },
  arrow: {
    type: String,
    default: "hover",
    validator: (value) => CAROUSEL_ARROW.includes(value),
  },
};
html 复制代码
<template>
  <div
    class="t-carousel"
    :style="{ height: props.height }"
    @mouseenter="handleMouseEvent('enter')"
    @mouseleave="handleMouseEvent('leave')"
  >
    <div class="t-carousel__container">
      <slot></slot>
    </div>
    <button
      class="t-carousel__arrow t-carousel__arrow-left"
      @click="play('prev')"
      v-if="props.arrow !== 'never' && items.length > 1"
      :style="{
        left:
          props.arrow === 'always' || (props.arrow !== 'never' && isHovering)
            ? '10px'
            : '-50px',
      }"
    >
      <i class="t-icon icon-arrow-left-bold"></i>
    </button>
    <button
      class="t-carousel__arrow t-carousel__arrow-right"
      @click="play('next')"
      v-if="props.arrow !== 'never'"
      :style="{
        right:
          props.arrow === 'always' || (props.arrow !== 'never' && isHovering)
            ? '10px'
            : '-50px',
      }"
    >
      <i class="t-icon icon-arrow-right-bold"></i>
    </button>
    <ul class="t-carousel__indicators">
      <li
        v-for="(_, index) in items"
        :key="`indicators_${index}`"
        :class="[
          't-carousel__indicator',
          { 'is-active': index === activeIndex },
        ]"
        @click="activeIndex = index"
      ></li>
    </ul>
  </div>
</template>

<script setup>
  import { ref, provide, onMounted, onUnmounted } from "vue";
  import { CarouselProps } from "./carousel";

  defineOptions({
    name: "t-carousel",
  });

  const props = defineProps(CarouselProps);

  const items = ref([]);
  const activeIndex = ref(0);
  const loopDirection = ref("next");
  const isHovering = ref(false);
  let timer = null;

  const addItem = (item) => {
    items.value.push(item);
  };

  const removeItem = (uid) => {
    const index = items.value.findIndex((item) => item === uid);
    if (index > -1) {
      items.value.splice(index, 1);
    }
  };

  const play = (direction) => {
    loopDirection.value = direction;
    if (direction === "prev") {
      activeIndex.value =
        activeIndex.value - 1 < 0
          ? items.value.length - 1
          : activeIndex.value - 1;
    } else {
      activeIndex.value =
        activeIndex.value + 1 > items.value.length - 1
          ? 0
          : activeIndex.value + 1;
    }
  };

  const handleMouseEvent = (type) => {
    if (type === "enter") {
      isHovering.value = true;
      timer && clearInterval(timer);
    } else {
      isHovering.value = false;
      if (props.autoplay && props.interval > 0) {
        timer = setInterval(() => {
          play("next");
        }, props.interval);
      }
    }
  };

  onMounted(() => {
    if (props.autoplay && props.interval > 0) {
      timer = setInterval(() => {
        play("next");
      }, props.interval);
    }
  });

  onUnmounted(() => {
    timer && clearInterval(timer);
  });

  provide("carousel", {
    items,
    addItem,
    removeItem,
    activeIndex,
    loopDirection,
  });
</script>

本节的走马灯组件就算开发完了,我们的组件教程依旧会持续更新,大家持续关注。

✨ 本专栏源码地址

相关推荐
lovingsoft4 分钟前
Chrome Lighthouse 详解
前端·chrome
Qlly14 分钟前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝18 分钟前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦19 分钟前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡39 分钟前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95271 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人1 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载1 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队1 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端