Element分析(组件篇)——Carousel

_index.js

按照惯例,嵌套组件会写一个_index.js

import ElCarousel from './src/main';
import ElCarouselItem from './src/item';

export default function(Vue) {
  Vue.component(ElCarousel.name, ElCarousel);
  Vue.component(ElCarouselItem.name, ElCarouselItem);
};

export { ElCarousel, ElCarouselItem };


Carousel

首先是整个轮播图的框架部分。

生命周期

首先我们讲讲,在组件生命周期中进行的一些处理。

created

创建的时候,设置了两个属性。

created() {
  // 点击箭头的回调函数
  this.throttledArrowClick = throttle(300, true, index => {
    // 每隔300ms,可以调用一次 setActiveItem
    this.setActiveItem(index);
  });

  // 鼠标停在指示器上时的回调函数
  this.throttledIndicatorHover = throttle(300, index => {
    // 停止调用300ms后,可以再次调用handleIndicatorHover
    this.handleIndicatorHover(index);
  });
},

其中,throttle是一个工具函数,用来在一定时间内限定某函数的调用,其实现原理类似于我再underscore源码分析里面的函数,在这不进行具体描述。值得注意的是第二个参数noTrailing,当其设置为true时,保证函数每隔delay时间只能执行一次,如果设置为false或者没有指定,则会在最后一次函数调用后的delay时间后重置计时器。

setActiveItem

setActiveItem是用来设置当前页的。

methods: {
  setActiveItem(index) {
    if (typeof index === 'string') {
      // 如果索引是字符串,说明是指定名字的

      const filteredItems = this.items.filter(item => item.name === index);  // 周到对应的item

      if (filteredItems.length > 0) {
        // 如果找到的items长度大于0,取第一个的索引作为我们要使用的索引
        index = this.items.indexOf(filteredItems[0]);
      }
    }

    index = Number(index);  // 索引转成数字

    if (isNaN(index) || index !== Math.floor(index)) {
      // 如果索引不是数字,或者不是整数

      // 如果不是生产环境下,就报warn
      process.env.NODE_ENV !== 'production' &&
      console.warn('[Element Warn][Carousel]index must be an integer.');

      // 返回
      return;
    }

    // 获取所有项目的长度
    let length = this.items.length;
    if (index < 0) {  // 如果索引小于0,设置当前页为最后一页
      this.activeIndex = length - 1;
    } else if (index >= length) {  // 如果索引大于长度,设置当前页为第一页
      this.activeIndex = 0;
    } else {  // 否则设置为索引页
      this.activeIndex = index;
    }
  },
}

其中,activeIndexdata上用来标识当前页的一个属性。

data() {
  return {
    activeIndex: -1
  }
}

activeIndex改变的时候,会触发监听。


watch: {
  activeIndex(val, oldVal) {
    this.resetItemPosition();  // 重置子项的位置
    this.$emit('change', val, oldVal);  // 发送change事件
  }
},

其中resetItemPosition是用来重置项目位置的方法。

methods: {
  resetItemPosition() {
    this.items.forEach((item, index) => {
      item.translateItem(index, this.activeIndex);
    });
  },
}

它将data上的items里面的项目依次遍历并执行carousel-item上的translateItem方法来移动。itemsdata上的一个属性,并在carousel挂载的时候通过updateItems方法来初始化。一会进行介绍。

data() {
  return {
    items: []
  }
}

#### handleIndicatorHover
处理指示器悬浮事件。

```javascript
methods: {
  handleIndicatorHover(index) {
    // 如果触发方式是鼠标悬浮并且index不是当前索引
    if (this.trigger === 'hover' && index !== this.activeIndex) {
      this.activeIndex = index;  // 设置当前页为index
    }
  }
}

其中trigger是触发事件的方式,默认为hover,通过prop传递。

props: {
  trigger: {
    type: String,
    default: 'hover'
  },
}

mounted

组件在挂载的时候进行了一些处理。

mounted() {
  this.updateItems();
  this.$nextTick(() => {
    addResizeListener(this.$el, this.resetItemPosition);
    if (this.initialIndex < this.items.length && this.initialIndex >= 0) {
      this.activeIndex = this.initialIndex;
    }
    this.startTimer();
  });
},

updateItems

首先是更新子项目,获取所有子组件中的el-carousel-item置于items中。

methods: {
  updateItems() {
    this.items = this.$children.filter(child => child.$options.name === 'ElCarouselItem');
  },
}

nextTick

在下次 DOM 更新循环结束之后执行延迟回调。

this.$nextTick(() => {
  addResizeListener(this.$el, this.resetItemPosition);  // 增加resize事件的回调为resetItemPosition

  if (this.initialIndex < this.items.length && this.initialIndex >= 0) {
    // 如果初始化的索引有效,则将当前页设置为初始的索引
    this.activeIndex = this.initialIndex;
  }

  // 启动定时器
  this.startTimer();
});

addResizeListener

这是用来处理resize事件的回调的,饿了吗自己进行了处理。将有专门的工具类的分析,在这不进行展开。

startTimer

其中startTimer是用来启动定时器的。

methods: {
  startTimer() {
    if (this.interval <= 0 || !this.autoPlay) return;  // 如果间隔时间非正数或者设置了不自动播放,直接返回
    this.timer = setInterval(this.playSlides, this.interval);  // 否则每隔 interval 事件,执行playSlides函数
  }
}

其中,intervalautoPlay都是prop

props: {
  autoPlay: {
    type: Boolean,
    default: true
  },
  interval: {
    type: Number,
    default: 3000
  },
}

playSlides是另一个方法,用来改变activeIndex

methods: {
  playSlides() {
    if (this.activeIndex < this.items.length - 1) {
      this.activeIndex++;
    } else {
      this.activeIndex = 0;
    }
  },
}

beforeDestory

销毁前移除事件监听。

  beforeDestroy() {
    if (this.$el) removeResizeListener(this.$el, this.resetItemPosition);
  }

el-carousel

最外面是一个div.el-carousel,并在上面进行了一些处理。

<div
  class="el-carousel"
  :class="{ 'el-carousel--card': type === 'card' }"
  @mouseenter.stop="handleMouseEnter"
  @mouseleave.stop="handleMouseLeave">
</div>

动态class

会根据type这一prop来决定是否显示卡片化的风格。

props: {
  type: String
}

鼠标进入事件

鼠标进入的时候绑定了回调函数handleMouseEnter,并且使用stop修饰符来阻止事件冒泡。

methods: {
  handleMouseEnter() {
    this.hover = true;  // 设定hover为true
    this.pauseTimer();   // 停止计时器
  }
}

其中设置的hover是在data上的一个Boolean类型的属性。

data() {
  return {
    hover: false
  }
}

pauseTimer是实例上的另一个方法,用来停止计时器。

methods: {
  pauseTimer() {
    clearInterval(this.timer);
  }
}

timer也是在data上的属性,用来保存计时器的id

data() {
  return {
    timer: null
  }
}

鼠标离开事件

鼠标离开的时候绑定了回调函数handleMouseLeave,并且也使用了stop修饰符来阻止事件冒泡。

methods: {
  handleMouseLeave() {
    this.hover = false;  // 设定hover为false
    this.startTimer();   // 启动计时器
  }
}

接下来,分别是轮播图的主体和指示器两部分。

container

最外层是container,其高度可以根据传入的height改变。

<div
  class="el-carousel__container"
  :style="{ height: height }">
</div
props: {
  height: String
}

然后分别是前进后退两个控制按钮和轮播的内容。

控制按钮

两个控制按钮的逻辑基本是一样的,这里选择后退的按钮进行分析,另一个可以进行类推。

transition

首先最外面是一个transition来进行动画效果。

<transition name="carousel-arrow-left">
</transition>

其效果设置如下,使用了位移和透明度的改变:

.carousel-arrow-left-enter,
.carousel-arrow-left-leave-active {
  transform: translateY(-50%) translateX(-10px);
  opacity: 0;
}

值得注意的是,这里其实只有X轴的偏移是有效果的,因为Y轴方向并没有改变。

button

按钮的内容主体是对应的图标,这没有什么好分析的,但它有许多属性的设置,我们将对其一一进行讲解:

<button
  v-if="arrow !== 'never'"
  v-show="arrow === 'always' || hover"
  @mouseenter="handleButtonEnter('left')"
  @mouseleave="handleButtonLeave"
  @click.stop="throttledArrowClick(activeIndex - 1)"
  class="el-carousel__arrow el-carousel__arrow--left">
  <i class="el-icon-arrow-left"></i>
</button>
arrow

首先是一个名为arrowprop来决定按钮的是否渲染或者是否显示。它有三种情况:

  1. never的时候,直接不渲染按钮;
  2. always的时候,一直显示;
  3. hover的时候,即默认的时候,悬浮在上面的时候显示。
鼠标进入

鼠标进入的时候将触发handleButtonEnter('left')这一函数,它将对每一个轮播的项目通过itemInStage方法处理后和方向进行对比,设置项目的hover属性。

methods: {
  handleButtonEnter(arrow) {
    this.items.forEach((item, index) => {
      if (arrow === this.itemInStage(item, index)) {
        item.hover = true;  // hover设置为true
      }
    });
  },

  itemInStage(item, index) {
    const length = this.items.length;

    if (index === length - 1 // 当前为最后一个项目
        && item.inStage  // 当前项目在场景内
        && this.items[0].active   // 第一个项目激活状态
        || (item.inStage   // 当前项目在场景内
            && this.items[index + 1]  // 当前项目后面有至少一个项目
            && this.items[index + 1].active)  // 当前项目后面一个项目处于激活状态
        ) {
      return 'left';  // 返回left
    } else if 
      (index === 0  // 当前为第一个项目
        && item.inStage  // 当前项目的inStage为true
        && this.items[length - 1].active  // 最后一个项目处于激活状态
        || (item.inStage  // 当前项目在场景内
            && this.items[index - 1]  // 当前项目前面有至少一个项目
            && this.items[index - 1].active)  // 当前项目的前一个项目处于激活状态
      ) {
      return 'right';
    }
    return false;
  },
}
鼠标离开

鼠标离开时触发handleButtonLeave函数,将所有项目的hover设置为false

methods: {
  handleButtonLeave() {
    this.items.forEach(item => {
      item.hover = false;
    });
  },
}
click

单击时触发throttleArrowClick函数并阻止事件冒泡,该函数每隔300ms可以调用setActiveItem一次,从而改变当前页。

轮播内容

轮播内容是一个slot,用于放置carousel-item

<slot></slot>

指示器

指示器是一个无序列表,我们还是由外向内进行分析。

ul

<ul
  class="el-carousel__indicators"
  v-if="indicatorPosition !== 'none'"
  :class="{
      'el-carousel__indicators--outside'
        : indicatorPosition === 'outside'
          || type === 'card' 
  }">
</ul>

ul会根据indicatorPosition的设置进行一些设置,它有几种情况:

  1. none的时候,直接不渲染指示器;
  2. outside的时候,会显示在轮播图框下方;
  3. 默认的时候,会显示在轮播图的下方。

此外,当type设置为type的时候,也会显示在轮播图框的下方。

li

li标签将通过v-for根据轮播图项目进行渲染。

<li
  v-for="(item, index) in items"
  class="el-carousel__indicator"
  :class="{ 'is-active': index === activeIndex }"
  @mouseenter="throttledIndicatorHover(index)"
  @click.stop="handleIndicatorClick(index)">
  <button class="el-carousel__button"></button>
</li>

li标签的内容是一个button,没有什么处理,所有的处理都直接设置在li标签上,我们将一一进行讲解。

is-active

如果当前的indexactiveIndex相等,说明当前的指示器是当前页的指示器,加上is-active类。

鼠标进入

鼠标进入的时候将触发throttledIndicatorHover(index),它在300ms内只能调用handleIndicatorHover一次,它会在triggerhover的时候将当前页切换到鼠标进入的指示器对应的页上。

click

单击的时候会触发handleIndicatorClick(index),直接改变当前页。

methods: {
  handleIndicatorClick(index) {
    this.activeIndex = index;
  },
}

其他

此外还提供了prevnext两个方法来切换当前页。

methods: {
  prev() {
    this.setActiveItem(this.activeIndex - 1);
  },

  next() {
    this.setActiveItem(this.activeIndex + 1);
  },
}

还有一个handleItemChangecarousel-item调用。

methods: {
  handleItemChange() {
    debounce(100, () => {
      this.updateItems();
    });
  },
}

debounce保证了如果在100ms内再次调用函数将重置计时器,再等100ms,只有在100ms内不再被调用才会执行updateItems


carousel-item

轮播图的子项目。

生命周期

created

创建的时候调用了父组件的handleItemChange,这会更新items里面的内容。

created() {
  this.$parent && this.$parent.handleItemChange();
},

destroyed

销毁的时候也是调用父组件的handleItemChange

destroyed() {
  this.$parent && this.$parent.handleItemChange();
}

包裹

最外层是一个div.el-carousel__item并且有一些设置:

<div
  v-show="ready"
  class="el-carousel__item"
  :class="{
    'is-active': active,
    'el-carousel__item--card': $parent.type === 'card',
    'is-in-stage': inStage,
    'is-hover': hover
  }"
  @click="handleItemClick"
  :style="{
    msTransform: `translateX(${ translate }px) scale(${ scale })`,
    webkitTransform: `translateX(${ translate }px) scale(${ scale })`,
    transform: `translateX(${ translate }px) scale(${ scale })`
  }">
</div>

show

根据ready的值决定是否显示。

动态class

  1. 根据active决定is-active类;
  2. 根据父组件的type是不是card决定el-carousel__item-card类;
  3. 根据inStage决定is-in-stage类;
  4. 根据hover决定is-hover类。

click

单击的时候会触发handleItemClick事件,会将点击的页面设置为当前活跃的页面。

methods: {
handleItemClick() {
    const parent = this.$parent;
    if (parent && parent.type === 'card') {
      const index = parent.items.indexOf(this);
      parent.setActiveItem(index);
    }
  }
}

动态style

设置transform属性,根据translatescale两个值来改变效果。

内容

内容是一个遮罩maskslot,前者会在card模式下且当前页不是活跃页的时候显现,后者用于定制轮播图的内容。

<div
  v-if="$parent.type === 'card'"
  v-show="!active"
  class="el-carousel__mask">
</div>
<slot></slot>

其他

剩下还有三个方法,用来处理轮播的效果。

processIndex

对当前索引进行处理,其中最后两个else if是为了将所有的轮播平分。

processIndex(index, activeIndex, length) {
  if (activeIndex === 0 && index === length - 1) {
    return -1;  // 活跃页是第一页,当前页是最后一页,返回-1,这样相差为1,表示二者相邻且在左侧
  } else if (activeIndex === length - 1 && index === 0) {
    return length;  // 活跃页最后一页,当前页是第一页,返回总页数,这样相差也在1以内
  } else if (index < activeIndex - 1 && activeIndex - index >= length / 2) {
    return length + 1;  // 如果,当前页在活跃页前一页的前面,并且之间的间隔在一半页数即以上,则返回页数长度+1,这样它们会被置于最右侧
  } else if (index > activeIndex + 1 && index - activeIndex >= length / 2) {
    return -2;  // 如果,当前页在活跃页后一页的后面,并且之间的间隔在一般页数即以上,则返回-2,这样它们会被置于最左侧
  }
  return index;  // 其他的返回原值
},

calculateTranslate

计算偏移距离,我们来分析一下为什么要这么计算,首先我们要知道,正常情况下,卡片模式下,当前页轮播图占整体宽度的一半,而它两侧的图,会再乘以CARD_SCALE记为s,我们把整体宽度分为4份记为w,我们来计算一下,在场景里面这几页的偏移:

  1. 当前活跃页的宽度应当为2w,因为它居中所以左侧距离整体的距离应当为(4w - 2w) / 2,则为w
  2. 前一页的宽度应当为w * 2 * s,因为是先偏移再缩放,我们计算偏移距离的时候应当反过来计算,即如果缩放后正好是最左侧,那么不缩放的时候大小应当为w * 2,多出的宽度为2w - 2ws,则左侧超出了w - ws,且应当为负数,因此偏移距离为(s - 1) * w
  3. 同理后一页应当向右多偏移w - ws,故偏移距离应当为2w + w - ws,即(3 - s) * w

可以看出,他们有一个共同的因子w,然后系数依次为1-1 * (1 - s)1 * (3 - s),这里要用一个式子来表示,可以简单的看做一个线性方程f(x) = (2 - s) * x + 1,具体计算过程,太过简单,在此不细说。

再往前的页面的需要偏移缩放后,右边贴在轮播图框的左边的框。最终其左边框距离轮播图框的左边框2ws,然后再加上缩放的距离w - ws,一共是w+ws,即(1+s)*w,因为是向左,所以是负数。

再往后的页面,最终其左边框贴着轮播图框的右边的框,相当于4w个距离,然后放大后向左缩进w - ws,综上为4w - w + ws,即(3 + s) * w

calculateTranslate(index, activeIndex, parentWidth) {
  if (this.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;
  }
},

translateItem

这是用来移动轮播图子项目的方法。

translateItem(index, activeIndex) {
  const parentWidth = this.$parent.$el.offsetWidth;  // 获取父组件的宽度
  const length = this.$parent.items.length;  // 获取所有轮播页面的个数

  if (this.$parent.type === 'card') {  // 如果是card模式

    if (index !== activeIndex && length > 2) {  // 当前索引不是活跃索引,且所有页面多于2页
      index = this.processIndex(index, activeIndex, length);  // 对当前索引进行处理
    }

    this.inStage = Math.round(Math.abs(index - activeIndex)) <= 1;  // 活跃页及前后两页应当被展示
    this.active = index === activeIndex;  // 当前索引等于活跃页的索引的话,说明当前是活跃的页面
    this.translate = this.calculateTranslate(index, activeIndex, parentWidth);  // 计算偏移量
    this.scale = this.active ? 1 : CARD_SCALE;  // 计算缩放大小,后者是事先定义的常量0.83

  } else {  // 不是card模式
    this.active = index === activeIndex;  // 当前索引是活跃页的索引的话,说明当前是活跃的页面
    this.translate = parentWidth * (index - activeIndex);  // 偏移偏差数量的宽度
  }

  this.ready = true;  // 准备就绪
},
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容