介绍
先来说一下轮播组件:轮播组件可以实现在有限区域内,对多个图片进行循环播放展示,通常会用于首页的最重要的广告和信息推送。轮播组件在英文中叫做Carousel,也就是我们常见的旋转木马。
轮播图预览
1. 需求分析
轮播图垂直水平居中,下标水平居中
轮播数目不受限制
前后翻动
直接定位
支持拖拽
自动轮播且hover、click时要暂停并清空计时
2. 需求分解
方案一:
首先我们可以很快的想到下面的方法,通过切换我们发现从最后一张到第一张会是快速回退的效果而不是像旋转木马那样连续滚动,所以无法满足首尾过渡连贯。
方案二:
为了改进方案一的不足,我们在从第三个切换到最后一个的时候把第一张图移到最后的位置,但是这样做的话会导致容器发生位置移动,所以在slide节点上需要绝对定位。虽然这个方案解决了图片快速回退的问题,但是在理想情况下也有不足的地方,就是图片数量一旦特别大就会出现轮播失效的状况,因为外部容器是整个进行滚动的,节点数量特别多的时候滚动会严重影响性能。
方案三:
跟进一下方案二的不足,也就是我们最终采用的方案,只使用三个节点,通过不断移动节点位置达到我们的旋转木马的效果。假设现在图片总数为1003张,通过复用前一个节点到最后一个位置,然后将它变为1,因为是在视口外操作,不影响使用体验,这个时候我们再进行滚动。
3. 页面结构分解
我们的视口区域必须得是overflow:hidden,否则会出现滚动条;整个节点容器我们可以看做一个传送带,虽然只有三个节点,但通过组件来循环的传递图片,使图片不断的出现在视口容器中。
// 视口容器
<div class="g-banner"></div>
// 传送带
<div class="m-slider">
<div class="slide"></div>
<div class="slide"></div>
<div class="slide"></div>
</div>
4. 公共API接口
// 初始化轮播
let slider = new Slider({
//视口容器
container: document.querySelector('.g-banner'),
// 是否允许拖拽
drag: true,
// 是否自动轮播
auto: true,
// 设定动画时间0.5s
fadeTime: 500,
// 图片数量自定义
images: [
'img/banner1.jpg',
'img/banner2.jpg',
'img/banner3.jpg',
'img/banner4.jpg',
'img/banner5.jpg',
'img/banner6.jpg',
'img/banner7.jpg',
'img/banner8.jpg',
]
});
// API接口
// 给下标添加点击事件
cursors.forEach(function(cursor, index) {
cursor.addEventListener('click', function() {
slider.nav(index);
})
});
// 给上一页添加点击事件
prev.addEventListener('click', function() {
slider.prev();
});
// 给下一页添加点击事件
next.addEventListener('click', function() {
slider.next();
});
// 每次slider图片位置变化,cursor也变化,增加监听处理
slider.on('nav', function(event) {});
// 从当前页开始轮播
slider.nav(2);
5. 数据结构定义
- pageIndex[0~pageNum]: 当前图片下标
- slideIndex[0~2]: slide下标
- offsetAll: 传送带容器(.m-slide)相对视口的偏移下标
这里的1002 指的是当前图片的下标,1指的是slide下标,因为slide只有三个,offsetAll是传送带容器相对视口的偏移量,通过这三个值可以控制slide的展示,后面我们将各种轮播操作都可以转换成数据的形式还原UI
代码分析
组件的初始化会做一些缓存节点以及定义数据结构等操作,比如这里强制设置overflow = 'hidden',以保证轮播图不会出现滚动条。
function Slider(opt) {
_.extend(this, opt);
// 容器节点,如果没有传入container,默认为body节点,
// 强制设置视口hidden样式
this.container = this.container || document.body;
this.container.style.overflow = 'hidden';
// 组件节点,并转换为数组
this.slider = this._layout.cloneNode(true);
this.slides = [].slice.call(this.slider.querySelectorAll('.slide'));
// 轮播图数量
this.pageNum = this.images.length;
......
// 内部数据结构
// pageIndex[0~pageNum]: 当前图片下标
// slideIndex[0~2]: slide下标
// offsetAll: 传送带容器(.m-slide)相对视口的偏移下标
this.slideIndex = 1;
this.pageIndex = this.pageIndex || 0;
this.offsetAll = this.pageIndex;
// 把dom节点渲染到HTML
this.container.appendChild(this.slider);
......
}
Emitter
// 事件发射器
_.extend( Slider.prototype, _.emitter);
轮播方式
这里我们将多种轮播方式都转换成计算pageIndex、slideIndex和offsetAll三个值来确定slide的显示,然后提供一个单一的入口 _calcSlide方法,以后添加其他的方式实现轮播添加进来也很方便,不会去改变别的轮播方式。
// 直接跳转到指定页
nav: function(pageIndex) {
this.pageIndex = pageIndex;
this.slideIndex = typeof this.slideIndex === 'number' ? this.slideIndex: (pageIndex + 1) % this.showNum;
this.offsetAll = pageIndex;
this.slider.style.transitionDuration = '0s';
this._calcSlide();
},
// 下一页
next: function() {
this._step(1);
},
// 上一页
prev: function() {
this._step( - 1);
},
// 单步移动
_step: function(offset) {
this.offsetAll += offset;
this.pageIndex += offset;
this.slideIndex += offset;
this.slider.style.transitionDuration = '0.5s';
this._calcSlide();
},
_calcSlide 入口
该方法是组件生效的关键,它做了三件事,第一是根据offsetAll 设置了三个slide的偏移;第二是设置了容器的偏移,这里我们发现容器的偏移和当前slide的偏移正好是相反的,这里是为了让图片正好显示在当前视口中;第三是激活当前类名时加一个类来设置样式和节点的获取。
// 执行Slide
// 每个slide的left = (offsetAll + offset(1, -1)) * 100%;
// 外层容器 (.m-slider) 的偏移 = offsetAll * 宽度
_calcSlide: function() {
let showNum = this.showNum;
let pageIndex = this.pageIndex = this._normIndex(this.pageIndex, this.pageNum);
let slideIndex = this.slideIndex = this._normIndex(this.slideIndex, showNum);
let offsetAll = this.offsetAll;
let slides = this.slides;
let prevslideIndex = this._normIndex(slideIndex - 1, showNum);
let nextslideIndex = this._normIndex(slideIndex + 1, showNum);
// 三个slide的偏移
slides[slideIndex].style.left = (offsetAll) * 100 + '%';
slides[prevslideIndex].style.left = (offsetAll - 1) * 100 + '%';
slides[nextslideIndex].style.left = (offsetAll + 1) * 100 + '%';
// 设置动画
this._fadeIn(slides[slideIndex]);
// 容器偏移
// translateZ(0) 触发硬件加速
this.slider.style.transform = 'translateX(' + ( - offsetAll * 100) + '%) translateZ(0)';
// 当前slide 添加 'z-active'的className
slides.forEach(function(node) {
_.delClassName(node, 'z-active')
});
_.addClassName(slides[slideIndex], 'z-active');
// 图片url处理
this._onNav(this.pageIndex, this.slideIndex);
},
自动轮播
思路就是通过定时器实现自动轮播,比较简单,但是注意的点是要在鼠标移动到slide的时候和点击翻页和下标的时候都需要暂停并清空计时。
拖拽操作
和其他方式有一个区别就是translateX不再是以百分比的形式去计算,这里需要通过px来计算偏移量。首先定义一个dragInfo来保存当前点击点的位置信息,拖拽会默认进行文本内容的选区以及如果是多屏的话会拖出图片,所以需要清空选区和阻止默认事件。
// 拖拽
// 阻止默认事件是为了防止拖出视口容器
// 记录初始坐标,transitionDuration设置为0s
_dragstart: function(ev) {
let dragInfo = this._dragInfo;
dragInfo.start = {
x: ev.pageX,
y: ev.pageY
};
ev.preventDefault();
},
_dragmove: function(ev) {
.......
// 清除选区
if (window.getSelection) {
window.getSelection().removeAllRanges();
} else if (window.document.selection) {
window.document.selection.empty();
}
let start = dragInfo.start;
// 加translateZ 分量是为了触发硬件加速
this.slider.style.transform = 'translateX(' + ( - (this.offsetWidth * this.offsetAll - ev.pageX + start.x)) + 'px) translateZ(0)'
},
// 通过步长判断图片是否翻页
.......
加入拖拽操作我们发现我们没有修改主逻辑,仍然复用了之前的那套逻辑,又计算了一次数据,然后根据数据还原出UI就可以了,这种数据驱动UI的方式是十分清晰和直观。如果还需要别的方式来实现轮播,无非就是再走一遍这个逻辑即可。
PS:计算数据第二个应该是 slideIndex
总结
至此,我们的轮播图组件就算完成了。我们将通用的需求写在组件内部,需要定制的内容(比如视口容器,是否需要拖拽、是否自动轮播、图片数量等等)则写在外面,这样就可以达到复用性,别人使用这个组件也不需要改动内部逻辑。
不足的地方:
- 如果持续调用next和prev将导致偏移量非常大
- 没有根据图片数量来渲染下标