[Element Plus 源码解析] Carousel 走马灯

4,083 阅读5分钟

一、组件介绍

官网链接:Carousel 组件 | Element (gitee.io)

Carousel组件也称作轮播图,常用于网站首页进行Banner的轮播展示。

Carousel需要与Carousel-item组件配合使用。

1.1 属性

1.1.1 显示类

  • height: string类型, 设置走马灯的高度;
  • type: string类型,走马灯的类型,可以设置成card;
  • indicator-position:string类型,设置指示器的位置,可设置成outside或none;
  • direction: string类型,设置显示方向,可选值为horizontal/vertical,默认是horizontal;

1.1.2 控制类

  • initial-index: number类型,设置初始状态时展示的图片索引;
  • trigger: string类型;设置指示器的触发方式,默认是hover,可以设置成click;
  • autoplay: boolean类型,是否自动切换,默认为true;
  • interval: number类型,自动切换间隔,单位是ms,默认是3000(即3s);
  • arrow:string类型,设置左右切换箭头显示的时机,可选择为always/hover/never,默认是hover;
  • loop:boolean类型,设置循环展示,默认是true;
  • pause-on-hover: boolean类型,hover时暂停切换,默认是true;

1.2 事件

  • change: 图片切换时触发,参数为当前激活的Index,之前激活的Index

1.3 可调用API

  • setActiveItem:切换幻灯片;参数:幻灯片索引或carousel-item的name属性值;
  • prev:切换到上一张;
  • next: 切换到下一张;

二、源码分析

2.1 Carousel 组件

2.1.1 template

<template>
  <div
    ref="root"
    :class="carouselClasses"
    // 鼠标移入移出事件用于暂停/重启轮播计时器
    @mouseenter.stop="handleMouseEnter"
    @mouseleave.stop="handleMouseLeave"
  >
    <div class="el-carousel__container" :style="{ height: height }">
      // 左右切换箭头
      <transition v-if="arrowDisplay" name="carousel-arrow-left">
        <button
          v-show="
            (arrow === 'always' || data.hover) &&
              (props.loop || data.activeIndex > 0)
          "
          type="button"
          class="el-carousel__arrow el-carousel__arrow--left"
          @mouseenter="handleButtonEnter('left')"
          @mouseleave="handleButtonLeave"
          // 节流处理
          @click.stop="throttledArrowClick(data.activeIndex - 1)"
        >
          <i class="el-icon-arrow-left"></i>
        </button>
      </transition>
      <transition v-if="arrowDisplay" name="carousel-arrow-right">
        <button
          v-show="
            (arrow === 'always' || data.hover) &&
              (props.loop || data.activeIndex < items.length - 1)
          "
          type="button"
          class="el-carousel__arrow el-carousel__arrow--right"
          @mouseenter="handleButtonEnter('right')"
          @mouseleave="handleButtonLeave"
          @click.stop="throttledArrowClick(data.activeIndex + 1)"
        >
          <i class="el-icon-arrow-right"></i>
        </button>
      </transition>
      <slot></slot>
    </div>
    // 指示器
    <ul v-if="indicatorPosition !== 'none'" :class="indicatorsClasses">
      <li
        v-for="(item, index) in items"
        :key="index"
        :class="[
          'el-carousel__indicator',
          'el-carousel__indicator--' + direction,
          { 'is-active': index === data.activeIndex },
        ]"
        @mouseenter="throttledIndicatorHover(index)"
        @click.stop="handleIndicatorClick(index)"
      >
        <button class="el-carousel__button">
          <span v-if="hasLabel">{{ item.label }}</span>
        </button>
      </li>
    </ul>
  </div>
</template>

2.1.2 script

// 部分核心源码

setup(props: ICarouselProps, { emit }) {
    // data
    const data = reactive<{
      activeIndex: number
      containerWidth: number
      timer: null | ReturnType<typeof setInterval>
      hover: boolean
    }>({
      activeIndex: -1,
      containerWidth: 0,
      timer: null,
      hover: false,
    })

    // refs
    const root = ref(null)
    // 存储CarouselItem子组件数据
    const items = ref<CarouselItem[]>([])

    // 计算属性,控制左右切换箭头是否展示,不展示的情况:arrow传值为never,diretcion为vertical
    const arrowDisplay = computed(
      () => props.arrow !== 'never' && props.direction !== 'vertical',
    )
    
    // 计算属性,carousel-item是否有lable属性
    const hasLabel = computed(() => {
      return items.value.some(item => item.label.toString().length > 0)
    })
    // 计算属性,carousel的class
    const carouselClasses = computed(() => {
      const classes = ['el-carousel', 'el-carousel--' + props.direction]
      if (props.type === 'card') {
        classes.push('el-carousel--card')
      }
      return classes
    })
    // 计算属性 指示器的class
    const indicatorsClasses = computed(() => {
      const classes = [
        'el-carousel__indicators',
        'el-carousel__indicators--' + props.direction,
      ]
      if (hasLabel.value) {
        classes.push('el-carousel__indicators--labels')
      }
      if (props.indicatorPosition === 'outside' || props.type === 'card') {
        classes.push('el-carousel__indicators--outside')
      }
      return classes
    })

    // methods
    // carousel-item注册方法,通过provide/inject模式在carousel-item组件中执行
    function addItem(item) {
      items.value.push(item)
    }
    // carousel-item卸载方法
    function removeItem(uid) {
      const index = items.value.findIndex(item => item.uid === uid)
      if (index !== -1) {
        items.value.splice(index, 1)
        if(data.activeIndex === index) next()
      }
    }
    // 节流处理,避免因幻灯片在轮播,同时用户点击左右箭头切换而导致的闪动
    const throttledArrowClick = throttle(
      index => {
        setActiveItem(index)
      },
      300,
      { trailing: true },
    )
    // 节流处理,指示器hover事件的切换
    const throttledIndicatorHover = throttle(index => {
      handleIndicatorHover(index)
    }, 300)
    
    // 清除计时器
    function pauseTimer() {
      if (data.timer) {
        clearInterval(data.timer)
        data.timer = null
      }
    }
    // 启动计时器
    function startTimer() {
      if (props.interval <= 0 || !props.autoplay || data.timer) return
      // 定时器的回调中执行playSides
      data.timer = setInterval(() => playSlides(), props.interval)
    }
    
    // 设置activeIndex
    const playSlides = () => {
      if (data.activeIndex < items.value.length - 1) {
        data.activeIndex = data.activeIndex + 1
      } else if (props.loop) {
        //循环播放
        data.activeIndex = 0
      }
    }
    
    // 设置激活对象,组件内部可调用,也可通过ref供使用者调用
    function setActiveItem(index) {
      // string类型的参数,主要是用户调用API时可以传入carousel-item 的 name 属性值
      if (typeof index === 'string') {
        const filteredItems = items.value.filter(item => item.name === index)
        if (filteredItems.length > 0) {
          index = items.value.indexOf(filteredItems[0])
        }
      }
      index = Number(index)
      if (isNaN(index) || index !== Math.floor(index)) {
        console.warn('[Element Warn][Carousel]index must be an integer.')
        return
      }
      let length = items.value.length
      const oldIndex = data.activeIndex
      if (index < 0) {
        data.activeIndex = props.loop ? length - 1 : 0
      } else if (index >= length) {
        data.activeIndex = props.loop ? 0 : length - 1
      } else {
        data.activeIndex = index
      }
      if (oldIndex === data.activeIndex) {
        // 重新设置幻灯片位置
        resetItemPosition(oldIndex)
      }
    }
    
    // 设置carouse-item的位置
    function resetItemPosition(oldIndex) {
      items.value.forEach((item, index) => {
        // translateItem是carousel-item子组件的方法,用于设置幻灯片的偏移位置
        item.translateItem(index, data.activeIndex, oldIndex)
      })
    }
    
    // instage表示card模式下,幻灯片是否是当前播放的
    function itemInStage(item, index) {
      const length = items.value.length
      if (
        (index === length - 1 && item.inStage && items.value[0].active) ||
        (item.inStage &&
          items.value[index + 1] &&
          items.value[index + 1].active)
      ) {
        return 'left'
      } else if (
        (index === 0 && item.inStage && items.value[length - 1].active) ||
        (item.inStage &&
          items.value[index - 1] &&
          items.value[index - 1].active)
      ) {
        return 'right'
      }
      return false
    }
    
    // 鼠标移入,暂停计时器 
    function handleMouseEnter() {
      data.hover = true
      if (props.pauseOnHover) {
        pauseTimer()
      }
    }
    
    // 鼠标移出,开启计时器
    function handleMouseLeave() {
      data.hover = false
      startTimer()
    }
    
    // 鼠标进入左右切换箭头
    function handleButtonEnter(arrow) {
      if (props.direction === 'vertical') return
      items.value.forEach((item, index) => {
        if (arrow === itemInStage(item, index)) {
          item.hover = true
        }
      })
    }
    // 鼠标离开左右切换箭头
    function handleButtonLeave() {
      if (props.direction === 'vertical') return
      items.value.forEach(item => {
        item.hover = false
      })
    }
    
    // 指示器点击事件,将activeIndex设置成点击的index
    function handleIndicatorClick(index) {
      data.activeIndex = index
    }
    
    // 指示器Hover事件,如果trigger设置成hover,则将activeIndex设置成点击的index
    function handleIndicatorHover(index) {
      if (props.trigger === 'hover' && index !== data.activeIndex) {
        data.activeIndex = index
      }
    }
    
    // 切换至上一张方法
    function prev() {
      setActiveItem(data.activeIndex - 1)
    }
    // 切换至下一张方法
    function next() {
      setActiveItem(data.activeIndex + 1)
    }

    // watch
    // 监测activeIndex的变化,
    watch(
      () => data.activeIndex,
      (current, prev) => {
        resetItemPosition(prev)
        if (prev > -1) {
          // 发射change事件
          emit('change', current, prev)
        }
      },
    )
    // 监测 autoplay变化,启动/暂停计时器
    watch(
      () => props.autoplay,
      current => {
        current ? startTimer() : pauseTimer()
      },
    )
    // 监测loop变化,
    watch(
      () => props.loop,
      () => {
        setActiveItem(data.activeIndex)
      },
    )

    // lifecycle
    onMounted(() => {
      nextTick(() => {
        // 监听resize事件
        addResizeListener(root.value, resetItemPosition)
        // initialIndex在合理范围时,activeIndex设置成initialIndex
        if (
          props.initialIndex < items.value.length &&
          props.initialIndex >= 0
        ) {
          data.activeIndex = props.initialIndex
        }
        // 启动定时器
        startTimer()
      })
    })

    onBeforeUnmount(() => {
      // 卸载时,清除事件监听
      if (root.value) removeResizeListener(root.value, resetItemPosition)
      pauseTimer()
    })

    // provide,向子组件提供数据
    provide<InjectCarouselScope>('injectCarouselScope', {
      root,
      direction: props.direction,
      type: props.type,
      items,
      loop: props.loop,
      addItem,
      removeItem,
      setActiveItem,
    })
  },

2.2 Carousel-item 组件

2.2.1 template

<template>
  <div
    v-show="data.ready"
    class="el-carousel__item"
    :class="{
      'is-active': data.active,
      'el-carousel__item--card': type === 'card',
      'is-in-stage': data.inStage,
      'is-hover': data.hover,
      'is-animating': data.animating,
    }"
    :style="itemStyle"
    @click="handleItemClick"
  >
    <div
      v-if="type === 'card'"
      v-show="!data.active"
      class="el-carousel__mask"
    ></div>
    <slot></slot>
  </div>
</template>

2.2.2 script

setup(props: ICarouselItemProps) {
    // 获取组件实例
    const instance = getCurrentInstance()

    // data
    const data = reactive({
      hover: false,
      translate: 0,
      scale: 1,
      active: false,
      ready: false,
      inStage: false,
      animating: false,
    })

    // inject 父组件传递的数据
    const injectCarouselScope: InjectCarouselScope = inject(
      'injectCarouselScope',
    )

    // computed
    // 父组件的方向
    const parentDirection = computed(() => {
      return injectCarouselScope.direction
    })
    // 动态行内样式,
    const itemStyle = computed(() => {
      const translateType =
        parentDirection.value === 'vertical' ? 'translateY' : 'translateX'
      const value = `${translateType}(${data.translate}px) scale(${data.scale})`
      // eg:style="transform: translateX(673px) scale(1);"
      const style: PartialCSSStyleDeclaration = {
        transform: value,
      }
      // autoprefixer是一个工具方法,用于添加浏览器兼容前缀,如ms-,webkit-等
      return autoprefixer(style)
    })

    // methods
    // 循环播放的情况下,根据传入的index、当前激活的index和items的length计算出合理的索引
    function processIndex(index, activeIndex, length) {
      if (activeIndex === 0 && index === length - 1) {
        return -1
      } else if (activeIndex === length - 1 && index === 0) {
        return length
      } else if (index < activeIndex - 1 && activeIndex - index >= length / 2) {
        return length + 1
      } else if (index > activeIndex + 1 && index - activeIndex >= length / 2) {
        return -2
      }
      return index
    }
    
    // 计算card模式下的位移距离
    function calcCardTranslate(index, activeIndex) {
      const parentWidth = injectCarouselScope.root.value?.offsetWidth || 0
      // inStage是指card模式下,当前播放的幻灯片
      if (data.inStage) {
        return (
          (parentWidth * ((2 - CARD_SCALE) * (index - activeIndex) + 1)) / 4
        )
      } else if (index < activeIndex) {
        return (-(1 + CARD_SCALE) * parentWidth) / 4
      } else {
        return ((3 + CARD_SCALE) * parentWidth) / 4
      }
    }
    
    // 计算位移距离:位移距离 = 父容器的宽/高 * (index和activeIndex差值)
    function calcTranslate(index, activeIndex, isVertical) {
      const distance = (isVertical ? injectCarouselScope.root.value?.offsetHeight : injectCarouselScope.root.value?.offsetWidth) || 0
      return distance * (index - activeIndex)
    }

    const translateItem = (
      index: number,
      activeIndex: number,
      oldIndex: number,
    ) => {
      const parentType = injectCarouselScope.type
      const length = injectCarouselScope.items.value.length
      // 给当前幻灯片和前一个幻灯片加上animating属性
      if (parentType !== 'card' && oldIndex !== undefined) {
        data.animating = index === activeIndex || index === oldIndex
      }
      // loop情况下调用processIndex计算实际的Index
      if (index !== activeIndex && length > 2 && injectCarouselScope.loop) {
        index = processIndex(index, activeIndex, length)
      }
      // card模式
      if (parentType === 'card') {
         // card模式不支持垂直形式
        if (parentDirection.value === 'vertical') {
          console.warn(
            '[Element Warn][Carousel]vertical direction is not supported in card mode',
          )
        }
        data.inStage = Math.round(Math.abs(index - activeIndex)) <= 1
        data.active = index === activeIndex
        // 调用calcCardTranslate计算偏移距离
        data.translate = calcCardTranslate(index, activeIndex)
        data.scale = data.active ? 1 : CARD_SCALE
      } else {
      // 普通模式
        data.active = index === activeIndex
        const isVertical = parentDirection.value === 'vertical'
        // 计算偏移距离
        data.translate = calcTranslate(index, activeIndex, isVertical)
      }
      data.ready = true
    }
    
    // 点击幻灯片
    function handleItemClick() {
      if (injectCarouselScope && injectCarouselScope.type === 'card') {
        const index = injectCarouselScope.items.value
          .map(d => d.uid)
          .indexOf(instance.uid)
         // 调用父组件的setActiveItem方法
        injectCarouselScope.setActiveItem(index)
      }
    }

    // lifecycle
    onMounted(() => {
      // 调用父组件的addItem方式进行注册
      if (injectCarouselScope.addItem) {
        injectCarouselScope.addItem({
          uid: instance.uid,
          ...props,
          ...toRefs(data),
          translateItem,
        })
      }
    })

    onUnmounted(() => {
       // 取消注册
      if (injectCarouselScope.removeItem) {
        injectCarouselScope.removeItem(instance.uid)
      }
    })

    return {
      data,
      itemStyle,
      translateItem,
      type: injectCarouselScope.type,
      handleItemClick,
    }
  },
}

2.3 总结

  1. provide/inject进行父子组件间数据交互;
  2. 父组件负责维护activeIndex,提供setActiveItem方法设置激活的幻灯片;
  3. 子组件提供translateItem方法,根据自身index和activeIndex,包括父组件的宽/高,计算出偏移位置;
  4. 走马灯的样式原理是:所有幻灯片平铺/垂直展示,通过translateX/Y调整每个幻灯片的偏移距离