Carousel(Slider)控件练习小结

用数据驱动的js实现的Carousel控件,网易前端课的练习

Posted by koyo on February 13, 2016

目录

背景说明

关于此文的动机,参见上一篇关于Modal控件的博文

这节课的练习内容是:实现一个图片轮播控件,要求封装良好&扩展性强。 控件只封装上面的图片滚动条部分,不包含控制条; 暴露以下接口:

  1. 指定父容器
  2. 图片数组
  3. 是否支持拖拽

所谓“Carousel”就是“旋转木马”;在Web前端领域就表示“轮播头图”。也叫“Slider”;下文将随机混用这两种称谓,不加区分。

实现静态结构

Talk is cheap. Show me the code.

— Torvalds, Linus (2000-08-25)

直接上代码

HTML
  • m-slider就是Carousel控件了;整个控件用JS调用,可以指定其寄居的父容器
  • 三个slide分别是 前一张/当前/下一张 图片
<div class="m-slider">
  <div class="slide"></div>
  <div class="slide"></div>
  <div class="slide"></div>
</div>

上述HTML代码是在控件内部定义的,不在全局HTML中直接写; 而是用JS调用出来的。

CSS
/*全局的样式*/
html,
body {
	width: 100%;
	height: 100%;
	padding: 0;
	margin: 0;
	text-align: center;
}

div#carousel-container {
	width: 600px;
	height: 400px;
	padding: 0;
	margin: 10px auto;
	outline: 3px solid red; /*这个边框只是为了方便观察HTML结构用的*/
}

/*关于轮播头图的样式*/
.m-slider,
.m-slider .slide {
	width: 100%;
	height: 100%;
}

.m-slider {
	position: relative;
}

.m-slider .slide {
	position: absolute;
	top: 0;
	left: 0;
	text-align: center;
	vertical-align: middle;
}

.m-slider .slide img {
	/*这里先用绝对布局使左上角居中,然后再用transform的方式;比较优雅*/
	position: absolute;
	left: 50%;
	top: 50%;
	transform: translate(-50%, -50%);
	max-width: 90%;
	border: 2px solid #fff;
	border-radius: 2px;
	box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.5);
}

/*关于控制条的样式*/
.m-cursor {
	display: inline-block;
	margin: 3em auto 0;
	padding-left: 0;
	/*z-index: 10;*/
}

.m-cursor li {
	display: inline-block;
	width: 20px;
	height: 20px;
	line-height: 20px;
	margin-right: 4px;
	list-style: none;
	color: #aaa;
	transition: background-color 0.5s;
	border: 2px solid #999;
	border-radius: 50%;
	cursor: pointer;
}

.m-cursor li span {
	/*大于号小于号的位置偏低,修复一下*/
	position: relative;
	top: -0.1em;
}

.m-cursor li:hover,
.m-cursor li.z-active {
	color: #fff;
	background-color: #999;
}

定义接口

封装完好的Carouosel控件,应该是这样用的:

  • 基于类的接口
  • 可以传入控件的容器和图片列表&是否允许拖拽 作为构造参数
  • 使用事件发射器,方便扩展
  • 支持事件传参
  • 支持nav(),prev()next()方法
var slider = new Slider({
  container: document.getElementById("carousel-container"), //容器
  // 图片列表
  images: [
    "./imgs/pic01.jpg",
    "./imgs/pic02.jpg",
    "./imgs/pic03.jpg",
    "./imgs/pic04.jpg",
    "./imgs/pic05.jpg",
    "./imgs/pic06.jpg"
  ],
  drag: true // 是否允许拖拽
});

cursors.forEach(function(cursor, index){
  cursor.addEventListener('click', function(){
    slider.nav(index);
  })
})

prev.addEventListener('click', function(){
  slider.prev()
})
next.addEventListener('click', function(){
  slider.next()
})

// 通过监听`nav`事件来完成额外逻辑
slider.on('nav', function( ev ){
  var pageIndex = ev.pageIndex;
  cursors.forEach(function(cursor, index){
    if(index === pageIndex ){
      cursor.className = 'z-active';
    }else{
      cursor.className = '';
    }
  })
})

// 3s 自动轮播
setInterval(function(){
  slider.next();
},3000)

// 直接跳到第二页
slider.nav(2)

实现接口

后面在Carousel的实现中会用到一些工具函数,简要梳理一下:

函数原型 功能
html2node(str) 根据str生成DOM元素并返回
extent(o1,o2) 实现“混入”(Mixin)模式;把o2的属性混入到o1中,已有的属性不覆盖
emitter 这个其实不是函数,是一个发射器对象;提供最简单的事件绑定/解绑/触发功能

上述辅助函数的原理详见上一篇文章,在此不再赘述;本次使用的方法略有差异,仅就其结构说明如下:

首先是在util.js中封装了一个util对象,把辅助函数都放进去

var util = (function() {
    return {
		html2node: function(str) {
			//...
		},
        extend: function(o1, o2) {
			//...
        },
        addClass: function(node, className) {
			//...
        },
        delClass: function(node, className) {
			//...
        },
        emitter: {
            on: function(event, fn) {
				//...
            },
            // 解绑事件
            off: function(event, fn) {
				//...
            },
            // 触发事件
            emit: function(event) {
				//...
            }
        }
    }
})()

然后是在slider.js中调用它。
这里的技巧是:将util作为参数传入,形参名叫_,好写。

(function(_) {
    var template =
        '<div class="m-slider" >\
    <div class="slide"></div>\
    <div class="slide"></div>\
    <div class="slide"></div>\
  </div>'

	//定义Slider
    function Slider(opt) {
        _.extend(this, opt);
    }

	//扩展Slider.prototype
    _.extend(Slider.prototype, _.emitter);

    _.extend(Slider.prototype, {
        _layout: _.html2node(template),
        nav: function(pageIndex) {
			//...
        },
        // 下一页
        next: //...
        // 上一页
        prev: //...
        // 单步移动
		//...
    })
    window.Slider = Slider;
})(util);
Slider

OK,重点来了;下面是Slider的实现

// <!-- slider.js -->
(function(_) {
	function Slider(opt) {
		_.extend(this, opt);

		// 容器节点 以及 样式设置
		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.offsetWidth = this.container.offsetWidth;
		this.breakPoint = this.offsetWidth / 4;

		this.pageNum = this.images.length;

		// 内部数据结构
		this.slideIndex = 1;
		this.pageIndex = this.pageIndex || 0;
		this.offsetAll = this.pageIndex;

		// 初始化动作
		this.container.appendChild(this.slider);

		if (this.drag) this._initDrag();

	}

	_.extend(Slider.prototype, _.emitter);

	var template =
		'<div class="m-slider" >\
			<div class="slide"></div>\
			<div class="slide"></div>\
			<div class="slide"></div>\
		</div>';

	_.extend(Slider.prototype, {

		_layout: _.html2node(template),

		next: function() {
			this._step(1);
		},

		prev: function() {
			this._step(-1);
		},

		// 直接跳转到指定页
		nav: function(pageIndex) {
			this.pageIndex = pageIndex
			this.slideIndex = typeof this.slideIndex === 'number' ? this.slideIndex : (pageIndex + 1) % 3;
			this.offsetAll = pageIndex;
			// 直接跳转的时候,不支持动画
			this.slider.style.transitionDuration = '0s';
			this._calcSlide();
		},

		// 单步移动
		_step: function(offset) {
			this.offsetAll += offset;
			this.pageIndex += offset;
			this.slideIndex += offset;

			this.slider.style.transitionDuration = '.5s';
			this._calcSlide();
		},
		// 计算Slide
		// 每个slide的left = (offsetAll + offset(1, -1)) * 100%;
		// 外层容器 (.m-slider) 的偏移 = offsetAll * 宽度
		_calcSlide: function() {
			var slideIndex = this.slideIndex = this._normIndex(this.slideIndex, 3);
			var pageIndex = this.pageIndex = this._normIndex(this.pageIndex, this.pageNum);
			var offsetAll = this.offsetAll;
			var pageNum = this.pageNum;

			var prevSlideIndex = this._normIndex(slideIndex - 1, 3);
			var nextSlideIndex = this._normIndex(slideIndex + 1, 3);

			// 三个slide的偏移
			var slides = this.slides;
			slides[slideIndex].style.left = (offsetAll) * 100 + '%'
			slides[prevSlideIndex].style.left = (offsetAll - 1) * 100 + '%'
			slides[nextSlideIndex].style.left = (offsetAll + 1) * 100 + '%'

			// 容器偏移
			this.slider.style.transform = 'translateX(' + (-offsetAll * 100) + '%) translateZ(0)'

			// 当前slide 添加 'z-active'的className
			slides.forEach(function(node) {
				_.delClass(node, 'z-active')
			})
			_.addClass(slides[slideIndex], 'z-active');

			this._onNav(this.pageIndex, this.slideIndex);
		},
		// 标准化下标
		_normIndex: function(index, len) {
			return (len + index) % len
		},
		// 跳转时完成的逻辑, 这里是设置图片的url
		_onNav: function(pageIndex, slideIndex) {
			var slides = this.slides;

			for (var i = -1; i <= 1; i++) {
				var index = (slideIndex + i + 3) % 3;
				var img = slides[index].querySelector('img')
				if (!img) {
					img = document.createElement('img');
					slides[index].appendChild(img);
				}
				img.src = './imgs/pic0' + (this._normIndex(pageIndex + i, this.pageNum) + 1) + '.jpg';
			}

			//向外部调用者发射事件
			this.emit('nav', {
				pageIndex: pageIndex,
				slideIndex: slideIndex
			})
		},


		//拖动相关
		_initDrag: function() {
			this._dragInfo = {};
			this.slider.addEventListener('mousedown', this._dragstart.bind(this));
			this.slider.addEventListener('mousemove', this._dragmove.bind(this));
			this.slider.addEventListener('mouseup', this._dragend.bind(this));
			this.slider.addEventListener('mouseleave', this._dragend.bind(this));
		},
		_dragstart: function(ev) {
			var dragInfo = this._dragInfo;
			dragInfo.start = {
				x: ev.pageX,
				y: ev.pageY
			};
		},
		_dragmove: function(ev) {
			var dragInfo = this._dragInfo;
			if (!dragInfo.start) return;

			ev.preventDefault(); //手动拖拽时,图片的移动使用JS控制,不要有延迟
			this.slider.style.transitionDuration = '0s';

			var start = dragInfo.start;
			// 清除恼人的选区
			if (window.getSelection) {
				window.getSelection().removeAllRanges();
			} else if (window.document.selection) {
				window.document.selection.empty();
			}

			// 加translateZ 分量是为了触发硬件加速
			this.slider.style.transform =
				'translateX(' + (-(this.offsetWidth * this.offsetAll - ev.pageX + start.x)) + 'px) translateZ(0)'

		},
		_dragend: function(ev) {
			var dragInfo = this._dragInfo;
			if (!dragInfo.start) return;

			ev.preventDefault();
			var start = dragInfo.start;
			this._dragInfo = {};
			var pageX = ev.pageX;

			// 看走了多少距离
			var deltX = pageX - start.x;
			if (Math.abs(deltX) > this.breakPoint) {
				this._step(deltX > 0 ? -1 : 1)
			} else {
				this._step(0)
			}
		}
	})
	window.Slider = Slider;
})(util);

全部拼起来,走两步~ :sunglasses:

调用关系大概是这样子的:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <style>
    /*全局的样式*/
    /*关于轮播头图的样式*/
    /*关于控制条的样式*/
    </style>
    <script src="util.js" />
    <script src="slider.js"/>
</head>

<body>
    <div id="carousel-container">
        <!--        
        <div class="m-slider" style="transition-duration: 0.5s; transform: translateX(-600%) translateZ(0px);">
            <div class="slide z-active" style="left: 600%;"><img src="./imgs/pic01.jpg"></div>
            <div class="slide" style="left: 700%;"><img src="./imgs/pic02.jpg"></div>
            <div class="slide" style="left: 500%;"><img src="./imgs/pic06.jpg"></div>
        </div>
         -->
    </div>
    <ul class="m-cursor">
        <li class='prev'><span>&lt;</span></li>
        <li class='cursor'>1</li>
        <li class='cursor'>2</li>
        <li class='cursor z-active'>3</li>
        <li class='cursor'>4</li>
        <li class='cursor'>5</li>
        <li class='cursor'>6</li>
        <li class='next'><span>&gt;</span></li>
    </ul>

    <!-- 主页面逻辑,使用Slider -->
    <script>
    var $ = function(selector) {
        return [].slice.call(document.querySelectorAll(selector))
    }

    var cursors = $('.m-cursor .cursor');
    var prev = $('.m-cursor .prev')[0];
    var next = $('.m-cursor .next')[0];

    cursors.forEach(function(cursor, index) {
        cursor.addEventListener('click', function() {
            slider.nav(index);
        })
    })

    prev.addEventListener('click', function() {
        slider.prev()
    })
    next.addEventListener('click', function() {
        slider.next()
    })

    var slider = new Slider({
        container: $('#carousel-container')[0], //视口容器
        images: [ // 图片列表
            "./imgs/pic01.jpg",
            "./imgs/pic02.jpg",
            "./imgs/pic03.jpg",
            "./imgs/pic04.jpg",
            "./imgs/pic05.jpg",
            "./imgs/pic06.jpg"
        ],

        drag: true // 是否允许拖拽
    });

    // 通过监听`nav`事件来完成额外逻辑
    slider.on('nav', function(ev) {
        var pageIndex = ev.pageIndex;
        cursors.forEach(function(cursor, index) {
            if (index === pageIndex) {
                cursor.className = 'z-active';
            } else {
                cursor.className = '';
            }
        })
    })

    // 3s 自动轮播
    setInterval(function() {
        slider.next();
    }, 3000)

    // 直接跳到第二页
    slider.nav(2)
    </script>
</body>

</html>

That’s it, DONE!