现已发布到npm及git,安装及使用方法见:https://gitee.com/haochenguang/hcg-swipe
先看一下效果图
该项目为仿清欢美味严选商城小程序demo
前言
轮播图的原理,其实就是一个简单的 n+2 模式,即在原有图片的基础上,再添加两张图片,以达到障眼法的效果,在这个原理方面,我就不做过多的叙述,可以自行寻找度娘,该项目使用了原生js,面向对象,移动端的touchstart,touchmove,touchend事件,有一小部分使用了ES6的语法,如箭头函数,let声明变量等等,建议有一点基础的同学来读
正文开始
- 定义一个构造函数,用来实例化我们的HSwipe;并且声明一个nameSpace常量,用来定义命名空间前缀,便于修改
(function(window){
const nameSpace = 'h-swipe';
/**
* @class HSwipe
* @param {Object} option 轮播图配置
* @param {HTMLElement|String} option.el 轮播图外层容器
* @param {HTMLElement|String} option.wrapper 轮播图wrapper容器
* @param {HTMLElement|String} option.slide 轮播图slide容器
* @param {Number} option.activeIndex 初始激活的图像
* @param {Number} option.duration 动画消耗时间
* @param {Number} option.interval 每帧停留时间
* @param {Object} option.pagination 配置分页器
* @param {String} option.pagination.el 分页器选择器
* @param {String} option.pagination.tagName 分页器生成的标签
* @param {String} option.pagination.pageName 分页器的使用类名
* @param {String} option.pagination.activeClass 分页器激活使用的类名
* @return {Object} HSwipe 实例化一个HSwipe对象
* */
function HSwipe(option) {
console.log('%c swipe from 郝晨光!!!', 'color:white;font-size:14px;text-shadow: 0px 0px 5px red;');
if (this instanceof HSwipe) {
return this._init(option);
} else {
return new HSwipe(option);
}
}
window.HSwipe = HSwipe;
}(window))
定义一些函数方法满足我们的重复使用,可以先不用看这一段,当遇到不知道的函数的时候,可以返回来查看对应的函数的功能
/**
* @function getRootElement 获取根节点
* @param {HTMLElement|String} select DOM节点或者选择器
* @return {HTMLElement|Node} DOM节点
* */
function getRootElement(select) {
if (select.nodeType === 1) {
return select;
}
return document.querySelectorAll(select)[0];
}
/**
* @function getChildElement 获取子节点
* @param {HTMLElement|String} parent 父元素节点
* @param {HTMLElement|String} select 子元素节点
* @return {HTMLElement|NodeList} DOM节点
* */
function getChildElement(parent, select) {
return getRootElement(parent).querySelectorAll(select);
}
/**
* @function addTransition 添加transition动画
* @param {HTMLElement} element 需要执行动画的DOM节点
* @param {Number} duration 设置执行动画的时间
* */
function addTransition(element, duration) {
element.style.transition = `transform ${duration}ms`;
element.style.webkitTransition = `transform ${duration}ms`;
}
/**
* @function addTransition 取消transition动画
* @param {HTMLElement} element 需要取消动画的DOM节点
* */
function removeTransition(element) {
element.style.transition = `none`;
element.style.webkitTransition = `none`;
}
/**
* @function addTransition 添加transition动画
* @param {HTMLElement} element 需要设置偏移的DOM节点
* @param {Number} distance 设置执行偏移的距离
* @param {String = X} direction 设置translate的方向,默认为 X
* */
function setTranslate(element, distance, direction = 'X') {
element.style.transform = `translate${direction}(${distance}px)`;
element.style.webkitTransform = `translate${direction}(${distance}px)`;
}
/**
* @function setClass 设置class类名
* @param {HTMLElement} element 需要设置类名的DOM节点
* @param {String} className 需要设置的类名
* 当element存在相同的class类名时,直接返回,否则进行设置
* */
function setClass(element, className) {
let otherClassName = element.className.split(' ');
let index = otherClassName.indexOf(className);
if (index === -1) {
otherClassName.push(className);
element.className = otherClassName.join(' ');
}
}
/**
* @function removeClass 删除class类名
* @param {HTMLElement} element 需要删除类名的DOM节点
* @param {String} className 需要删除的类名
* 当element内存在类名则删除,不存在则返回
* */
function removeClass(element, className) {
let allClassName = element.className.split(' ');
let index = allClassName.indexOf(className);
let newClassName;
if (index > -1) {
allClassName.splice(index, 1);
newClassName = allClassName.join(' ');
} else {
newClassName = allClassName.join(' ');
}
element.className = newClassName;
}
/**
* @function onEvent addEventListener监听事件
* 兼容性处理
* */
function onEvent(element, event, callback) {
if (element.addEventListener) {
element.addEventListener(event, callback, false);
} else if (element.attachEvent) {
element.attachEvent('on' + event, callback);
} else {
element['on' + event] = callback;
}
}
/**
* @function onEvent removeEventListener取消监听事件
* 兼容性处理
* */
function removeEvent(element, event, callback) {
if (element.addEventListener) {
element.removeEventListener(event, callback, false);
} else if (element.attachEvent) {
element.detachEvent('on' + event, callback);
} else {
element['on' + event] = null;
}
}
正文继续 ---- 别错过
- 在HSwipe构造函数中,执行了一个 if 判断,其实就是判断当前构造函数,如果是通过new关键字调用的话,就执行this._init方法,传入option;如果不是通过new关键字调用的,则返回一个HSwipe对象;这样可以确保我们永远可以拿到一个由HSwipe构造函数生成的实例对象
HSwipe.prototype._init = function (option) {
this._option = option; // 保存初始化配置
this.container = getRootElement(this._option.el || `.${nameSpace}-container`); // 外层容器
this.currentIndex = 0; // 当前显示的图片的原始下标
this.activeIndex = this._option.activeIndex || 1; // 当前激活的图片的轮播下标
this.duration = this._option.duration || 800; // 动画时间
this.interval = this._option.interval || 2000; // 间隔时间
this.execute = this.duration + this.interval; // 定时器的执行时间
this.$transitionEnd = this._option.transitionEnd;
this.refresh(); // 刷新轮播图
};
在HSwipe.prototype._init方法中,初始化了一部分只要实例化元素就立马可以获取到的数据;例如传入的配置项,根据配置项获取根节点;设置原始下标,设置激活下标,动画时间,间隔时间,定时器的执行时间应该是由动画时间+间隔时间得到;
在_init方法中,我调用了getRootElement函数,以及this.refresh方法,可以看一下;
这个方法,就是用来获取根节点,如果当前传入的本身就是一个HTML的DOM节点的话,直接返回即可,如果不是的话,将通过querySelectorAll方法获取,并拿到其中的 0号(第一个)元素
而在refresh方法中,我调用了更多的方法,让我们来一步一步的看
HSwipe.prototype.refresh = function () {
this._formatHSwipe();
this.off(); // 先关闭之前开启的事件
this.$transitionEnd = this._option.transitionEnd;
this.timer = setInterval(this._move.bind(this), this.execute); // 开启定时器
this._event(); // HSwipe的事件
};
首先说一下为什么要定义这个refresh方法,
- 我们都知道,前端很多时候都需要通过ajax来请求数据,在现在特别火的Vue,React等MVVM框架中,我们更是在通过操作数据来操作DOM节点,那我们在获取到数据之后,或者说结构发生改变的时候,就要重新刷新一遍我们的轮播图,来保证我们的轮播图不会因为数据的改变或者DOM节点的改变而出错
- 我在refresh方法中,调用了_formatHSwipe方法,初始化DOM节点的尺寸,格式化HSwipe,在这个方法中,执行了我们的 n + 2 模式;首先定义获取轮播的wrapper,这些为什么不放在_init方法中进行呢?是因为我们在每次refresh的时候,都需要重新定义获取一遍wrapper,以保证我们的wrapper数据不会发生任何改变;
而getChildElement方法,就是获取父节点指定的子节点;
HSwipe.prototype._formatHSwipe = function () {
this.wrapper = getChildElement(this.container, this._option.wrapper || `.${nameSpace}-wrapper`)[0]; // 图片轮播容器
let slides = getChildElement(this.wrapper, this._option.slide || `.${nameSpace}-slide`);
this.slideWidth = this.container.offsetWidth; // 获取图片的宽度
let len = slides.length; // 保存原始的slide长度
this.wrapper.style.width = this.slideWidth * (len + 2) + 'px'; // 设置wrapper的宽度为每一项的宽度 * 总图片长度 + 2;即最终处理的 n + 2 模式的长度;使其能容纳所有图片
if (!this.disguise) {
this.slides = getChildElement(this.wrapper, this._option.slide || `.${nameSpace}-slide`); // 需要轮播的每一个slide
this.len = this.slides.length; // 保存原始的slide长度
if (this.len === 0) return; // 如果当前图片长度为0,则不进行刷新轮播
// 标识,判断是否需要重新获取DOM节点和数据
let endDisguise = this.slides[0].cloneNode(true); // 克隆第一张图片
let startDisguise = this.slides[this.len - 1].cloneNode(true); // 克隆最后一张图片
this.wrapper.appendChild(endDisguise); // 将克隆的第一张图片添加到尾部
this.wrapper.insertBefore(startDisguise, this.slides[0]); // 将克隆的最后一张图片添加到头部
this.disguise = true;
}
let distance = this.slideWidth * (-this.activeIndex); // 计算下一次的位置
setTranslate(this.wrapper, distance); // 设定初始化的位置
this._slides = getChildElement(this.wrapper, this._option.slide || `.${nameSpace}-slide`); // 重新获取所有的图片,保存在私有属性当中,并遍历设置宽度
for (let i = 0; i < this._slides.length; i++) {
this._slides[i].style.width = this.slideWidth + 'px';
}
// 如果有分页器配置的话,初始化分页器
if (this._option.pagination) {
if(typeof this._option.pagination === 'boolean') {
this._option.pagination = {};
}
this._formatHSwipePagination();
}
};
而在_formatHSwipe方法中,执行判断,如果当前有配置的pagination的话,执行this._formatHSwipePagination();方法
而在这个方法中,我初始化了关于pagination的所有属性和数据
/**
* @method _formatHSwipePagination 初始化分页器
* */
HSwipe.prototype._formatHSwipePagination = function () {
this.pagination = getChildElement(this.container, this._option.pagination.el || `.${nameSpace}-pagination`)[0]; // 分页器容器
// 删除所有之前存在的分页,避免出现重复渲染
for (let i = 0; this.pageBtns && i < this.pageBtns.length; i++) {
this.pagination.removeChild(this.pageBtns[i]);
}
// 遍历生成新的分页器
for (let i = 0; i < this.len; i++) {
let pageBtn = document.createElement(this._option.pagination.tagName || 'span'); // 生成DOM节点,默认为 span
pageBtn.className = this._option.pagination.pageName || `${nameSpace}-page-btn`; // 给DOM节点绑定类名,默认为HSwipe-page-btn
this.pagination.appendChild(pageBtn); // 追加到DOM内
}
this.pagination.style.marginLeft = -this.pagination.offsetWidth / 2 + 'px'; // 设置pagination容器的位置
let pageBtnsSelect = this._option.pagination.pageName ? '.' + this._option.pagination.pageName : `.${nameSpace}-page-btn`;
this.pageBtns = getChildElement(this.pagination, pageBtnsSelect); // 获取新的分页器
this._pageActive(); // 激活page-btn
};
因为我们要通过slide的长度来动态的创建分页器,并且,在pagination内始终应该保证只有对应数量的分页器,所以,我们应该在创建之前,先将原有的page-btn全部删除,然后在根据slide长度创建新的page-btn,其中的this._option中的属性都是可配置项,|| 后的为默认值
最后,获取新创建的所有page-btn;保存在this.pageBtns中;调用this._pageActive方法,保证HSwipe初始化的时候activeIndex对应的page-btn激活
再看看_pageActive方法,很简单,先遍历删除指定的activeClass,接着在对应的page-btn上在加上指定的activeClass类名;
/**
* @method _pageActive 分页器使用类名激活
* */
HSwipe.prototype._pageActive = function () {
// 先遍历删除所有的激活类名
for (let i = 0; i < this.pageBtns.length; i++) {
removeClass(this.pageBtns[i], this._option.pagination.activeClass || 'active');
}
// 给对应的page-btn设置active类名
setClass(this.pageBtns[this.currentIndex], this._option.pagination.activeClass || 'active');
};
- 调用 this.off 事件,确保当前只会执行一次定时器,确保所有的事件都不会被多次监听,而 this.$transitionEnd 方法是一个传入的 option中的回调函数,每次轮播完成触发,在此处清空该函数,可以看到的是还给window删除了resize事件,这是因为我们在监听事件的时候,还监听了resize事件
HSwipe.prototype.off = function () {
clearInterval(this.timer);
this.$transitionEnd = () => {
};
removeEvent(this.wrapper, 'touchstart', this._touchStart); // 触摸屏幕
removeEvent(this.wrapper, 'touchmove', this._touchMove); // 触摸移动
removeEvent(this.wrapper, 'touchend', this._touchEnd); // 触摸结束
removeEvent(this.wrapper, 'transitionEnd', this._transitionEnd); // 动画结束
removeEvent(this.wrapper, 'webkitTransitionEnd', this._transitionEnd); // 动画结束
removeEvent(window, 'resize', this.refresh); // resize重新计算尺寸
return null;
};
- 接着,我们重新定义this.$transitionEnd方法,重新赋值为option中的transitionEnd方法;
- 开启定时器,执行this._move方法,并通过bind绑定this指向,确保不会因为setInterval的原因,影响this指向;setInterval的执行时间为我们在_init中初始化的执行时间
- 而this._event方法中,我们初始化了所有的HSwipe事件;
/**
* @method _event HSwipe事件监听
* 开启 HSwipe 的事件
* */
HSwipe.prototype._event = function () {
this._touchStart = this._touchStart.bind(this); // 绑定事件的this指向,以及保存事件为具名函数,用于清除事件,避免重复触发
this._touchMove = this._touchMove.bind(this);
this._touchEnd = this._touchEnd.bind(this);
this._transitionEnd = this._transitionEnd.bind(this);
this.refresh = this.refresh.bind(this);
onEvent(this.wrapper, 'touchstart', this._touchStart); // 触摸屏幕
onEvent(this.wrapper, 'touchmove', this._touchMove); // 触摸移动
onEvent(this.wrapper, 'touchend', this._touchEnd); // 触摸结束
onEvent(this.wrapper, 'transitionEnd', this._transitionEnd); // 动画结束
onEvent(this.wrapper, 'webkitTransitionEnd', this._transitionEnd); // 动画结束
onEvent(window, 'resize', this.refresh); // resize重新计算尺寸
};
- 接着我们来看this._move方法;通过activeIndex的自增和调用addTransition、setTranslate方法。来执行轮播,而每次动画执行完毕,都会触发transitionEnd这个事件,而我在初始化事件的时候,监听了transitionEnd这个事件,触发this._transitionEnd这个方法
HSwipe.prototype._move = function () {
// 使activeIndex和currentIndex自增
this.activeIndex++;
this.currentIndex++;
let distance = this.slideWidth * (-this.activeIndex); // 计算下一次的位置
addTransition(this.wrapper, this.duration);
setTranslate(this.wrapper, distance);
};
- 来看看this._transitionEnd这个方法,在这个方法内我们先执行了_formtIndex方法,判断是否需要将activeIndex或者currentIndex重置,接着计算下一次的位置,并调用_pageActive方法,激活当前显示的slide对应的page-btn;并且删除原有的transition,设置新的偏移值;在尾部进行了判断,当我们的配置项中有transitionEnd这个方法的时候,回调执行这个方法,并传入当前的currentIndex索引,表示原slide的真实索引,而activeIndex表示的是进行障眼法之后的运行索引
/**
* @method _transitionEnd
* 动画结束以后执行
* */
HSwipe.prototype._transitionEnd = function () {
this._formatIndex(); // 判断index是否需要重置
let distance = this.slideWidth * (-this.activeIndex); // 计算下一次的位置
this._pageActive();
removeTransition(this.wrapper); // 删除transition
setTranslate(this.wrapper, distance); // 设置偏移
if (this._option.transitionEnd) {
setTimeout(() => {
this.$transitionEnd.call(this, this.currentIndex);
});
}
};
- 最后,看一下触摸事件,即可达到我们文章开始的那个效果
/**
* @method _touchStart
* @param {event} e
* 触摸开始
* */
HSwipe.prototype._touchStart = function (e) {
// 如果是多个手指按下,直接返回,不触发事件
if (e.touches.length > 1) {
return;
}
clearInterval(this.timer); // 清除定时器
this.touchStartX = e.touches[0].clientX - this.container.offsetLeft; // 保存初始触碰位置
this.touchStartTime = e.timeStamp; // 保存初始触碰时间
};
/**
* @method _touchMove
* @param {event} e
* 触摸移动
* */
HSwipe.prototype._touchMove = function (e) {
// 移动的距离
let touchMoveX = e.touches[0].clientX - this.touchStartX; // 计算手指滑动的距离
let distance = -this.activeIndex * this.slideWidth + touchMoveX; // 计算当前设置的偏移量
removeTransition(this.wrapper); // 删除transition
setTranslate(this.wrapper, distance); // 设置偏移
};
/**
* @method _touchEnd
* @param {event} e
* 触摸结束
* */
HSwipe.prototype._touchEnd = function (e) {
this.touchEndX = e.changedTouches[0].clientX; // 保存当前手指离开的位置
this.touchEndTime = e.timeStamp; // 保存手指离开的时间
// 当滑动时间小于150的时候,切换图片
let direction = this.touchStartX - this.touchEndX; // 正数是向左,负数是向右
if (this.touchEndTime - this.touchStartTime <= 150 || Math.abs(direction) >= this.slideWidth / 2) {
if (direction > 0) {
this.activeIndex++;
this.currentIndex++;
} else {
this.activeIndex--;
this.currentIndex--;
}
}
let distance = this.slideWidth * (-this.activeIndex); // 计算下一次的位置
addTransition(this.wrapper, this.duration); // 添加transition
setTranslate(this.wrapper, distance); // 设置偏移
this._formatIndex(); // 判断是否需要重置activeIndex和currentIndex
clearInterval(this.timer); // 清除定时器
this.timer = setInterval(() => this._move(), this.execute); // 重新开启定时器
};
- 使用,写到最后还是为了用户能够良好的使用
// 初始化HSwipe
let mySwipe = new HSwipe({
el: '.h-swipe-container',
pagination: {
el: '.h-swipe-pagination'
},
transitionEnd: current => {
console.log(current)
}
})
// 刷新HSwipe
mySwipe.refresh();
// 卸载HSwipe
mySwipe = mySwipe.off();
- 最后看一下html和css样式文件吧,样式使用scss编写
<div class="h-swipe-container">
<ul class="h-swipe-wrapper">
<li class="h-swipe-slide">
<img src="替换src" alt="">
</li>
<li class="h-swipe-slide">
<img src="替换src" alt="">
</li>
<li class="h-swipe-slide">
<img src="替换src" alt="">
</li>
</ul>
<div class="h-swipe-pagination"></div>
</div>
.h-swipe-container {
position: relative;
width: 100%;
overflow: hidden;
.h-swipe-wrapper {
width: 100%;
&:after {
content: '';
clear: both;
display: block;
height: 0;
overflow: hidden;
}
.h-swipe-slide {
width: 100%;
float: left;
background: #FFF;
a {
display: block;
}
img {
width: 100%;
}
}
}
.h-swipe-pagination {
position: absolute;
display: flex;
align-items: center;
bottom: 10%;
left: 50%;
.h-swipe-page-btn {
width: 8px;
height: 4px;
border-radius: 4px;
margin: 0 5px;
background: #fff;
opacity: 0.5;
transition: all .3s;
&.active {
width: 14px;
opacity: 1;
}
}
}
}