# 前言 {#前言}
轮播组件是在移动端很常用的一种组件,而swiper (opens new window)、vue-awesome-swiper (opens new window)(123KB)组件基本是大家的首选。鉴于现在对移动端性能的要求越来越高,而这两个组件功能较全、体积较大,很多功能可能根本用不到,故封装了 只适用于移动端、只支持水平方向滑动(可扩展支持垂直方向) 的 OcSwiper
组件,本组件源码大小15KB,打包后大小约10KB,gzip后大小约4KB。
# Swiper轮播实现功能 {#swiper轮播实现功能}
轮播组件,支持循环、分页、自动轮播等(目前仅支持水平方向轮播)。
# 演示 {#演示}
提示
触摸滑动只支持在移动端设备进行演示,如需在PC端使用,可自行修改组件源码,将移动端touch事件替换为mouse事件。
1
2
3
4
更新数据 增加一条数据 滚动到第3个 停止自动播放 3S自动播放 关闭循环 最小滑动距离50%切换 开启自定义分页 隐藏分页
# 安装 {#安装}
# 全局引入 {#全局引入}
import { OcSwiper } from 'OcSwiper'
Vue.use(OcSwiper)
# 按需引入 {#按需引入}
import { OcSwiper } from 'OcSwiper'
export default {
components: {
OcSwiper,
}
}
# 基础用法 {#基础用法}
<oc-swiper ref="swiper"
:pagination="true"
:autoplayTime="3000"
:loop="true"
minMoveDistance="20%"
@slideChangeTransitionStart="slideChangeTransitionStart"
@slideChangeTransitionEnd="slideChangeTransitionEnd"
@onTouchStart="onTouchStart"
@onTouchStart="onTouchStart"
@onTouchEnd="onTouchEnd"
v-model="index">
<div class="oc-swiper-item" v-for="(item, idx) in list" :key="idx" @click="slideClick(item, idx)">
<div class="slide-item">{{ item }}</div>
</div>
<!-- 自定义分页展示:slot="pagination" -->
<!-- 样式一:1 2 3 4 -->
<div slot="pagination">
<ul>
<li v-for="page in list.length" :key="page" :class="{'cur-page': index === (page - 1)}">
{{ page }}
</li>
</ul>
</div>
<!-- 样式二:1/4 -->
<div slot="pagination">
<div>{{ index + 1 }} / {{ list.length }}</div>
</div>
</oc-swiper>
# 方法 {#方法}
# mySwiper.reset() 重置swiper组件。常用于window.onresize和swiper数据异步更新,如: {#myswiper-reset-重置swiper组件。常用于window-onresize和swiper数据异步更新-如}
// 窗口大小改变
window.onresize = () => {
this.$refs.swiper.reset()
}
// 列表数据更新
this.list = ['a', 'b', 'c', 'd']
this.$nextTick(() => {
// 接口异步获取到列表数据后,重置swiper组件
this.$refs.swiper.reset()
})
# 配置参数 {#配置参数}
| 参数 | 说明 | 类型 | 默认值 | 说明 | |------------------|-----------------|---------|------|---------------------------------------------| | v-model | 绑定值 | Number | 0 | 当前slide索引,从0开始 | | pagination | 是否显示分页器 | Boolean | true | 默认显示分页器 | | autoplayTime | 自动轮播时间间隔 | Number | 2500 | 0-不开启自动轮播,>0开启自动轮播,数值表示时间间隔 | | loop | 是否循环 | Boolean | true | 默认开启循环 | | minMoveDistance | 最小滑动距离 | String | 20% | 成功触发切换 item 的最小滑动距离。支持格式: 20(20px)/20px/20% | | quickTouchTime | 快速滑动 | Number | 150 | 单位(ms)。快速滑动时,只要距离大于 10px 便可以触发滑动 | | speed | 切换动画时间 | Number | 300 | 单位(ms) | | isPreventDefault | 是否在滑动时禁用浏览器默认行为 | Boolean | true | 默认禁用。 |
# 模版调用的回调方法 {#模版调用的回调方法}
| 方法名 | 说明 | 回调参数 | 备注 |
|----------------------------|--------------------------------|-----------------------------------------------------------------------------------------|----|
| slideChangeTransitionStart | swiper从当前slide开始过渡到另一个slide时执行 | realIndex,切换后slide索引 | |
| slideChangeTransitionEnd | swiper从一个slide过渡到另一个slide结束时执行 | realIndex,切换后slide索引 | |
| onTouchStart | touchStart,碰触到组件时执行 | - | |
| onTouchMove | touchMove | (event, direction) event:可用于禁用浏览器默认操作。 direction:方向(horizontal
:水平方向 vertical
:垂直方向)。 | |
| onTouchEnd | touchEnd,触摸释放时执行 | - | |
# 模版插槽 {#模版插槽}
| 名字 | 说明 |
|------------|------------------------------------------------------------------------------|
| 默认插槽 | swiper组件内容展示。格式: <div class="oc-swiper-item"><div>swiper slide</div></div>
|
| pagination | 自定义分页样式展示 |
# 源码 {#源码}
点击查看 OcSwiper.vue 组件源码
<template>
<div
class="oc-swiper"
@touchstart="handleTouchstart"
@touchmove="handleTouchmove"
@touchend="handleTouchend"
@touchcancel="handleTouchend"
>
<div ref="wrapper" class="oc-swiper-wrapper">
<slot></slot>
</div>
<div v-if="pagination" class="oc-swiper-pagination">
<!-- 自定义分页器展示 -->
<slot name="pagination">
<div class="oc-swiper-pagination-bar">
<i
v-for="item in length"
:key="item"
:class="[
'oc-swiper-pagination-item',
item - 1 === realIndex ? 'active' : '',
]"
></i>
</div>
</slot>
</div>
</div>
</template>
<script>
export default {
name: "OcSwiper",
props: {
value: {
// 默认展示第N个,N从0开始
type: Number,
default: 0,
},
pagination: {
// 默认分页导航器
type: Boolean,
default: true,
},
autoplayTime: {
// 自动轮播时间间隔
type: Number,
default: 2500,
},
loop: {
// 循环滑动
type: Boolean,
default: true,
},
minMoveDistance: {
// 成功触发切换 item 的最小滑动距离。支持格式: 20(20px)/20px/20%
type: String,
default: "20%",
},
quickTouchTime: {
// 快速滑动时只要距离大于 10px 便可以触发滑动
type: Number,
default: 150,
},
speed: {
// 切换速度,即slider自动滑动开始到结束的时间(单位ms),也是触摸滑动时释放至贴合的时间。
type: Number,
default: 300,
},
isPreventDefault: {
// 是否在滑动时禁用浏览器默认行为。默认为true表示禁用
type: Boolean,
default: true,
},
},
data() {
return {
width: 0, // 组件宽度
hasMounted: false,
realIndex: this.value, // 当前活动块的索引,从0开始
isRealIndexChange: false, // 滑动后当前活动块的索引是否改变
pages: [], // swiperSlide dom list
length: 0,
touchStartTime: 0, // 滑动开始时间
startx: 0, // 滑动开始位置X
starty: 0, // 滑动开始位置Y
moveDistance: 0, // 滑动距离
animating: false, // 组件正在过渡(自由滑动)
isTouching: true, // 用户正在操作(touch事件未结束)
horizontalMove: true,
copyNum: 1, // 复制数量
autoplayTimer: null,
};
},
computed: {
ocMinMoveDistance() {
let value = this.minMoveDistance;
let mode = "";
if (/px$/.test(value)) {
mode = "pixel";
} else if (/^\d+$/.test(value)) {
mode = "pixel";
} else if (/%$/.test(value)) {
mode = "percent";
} else {
value = "20%";
mode = "percent";
}
const stgy = {
pixel() {
const parsedValue = parseInt(value, 10);
return `${value}px`;
},
percent() {
const parsedValue = parseInt(value, 10) / 100;
return this.width * parsedValue;
},
};
return stgy[mode].apply(this);
},
// 滑动结束后 translatex 的值
ocTranslatex() {
return -this.width * this.realIndex;
},
ocIsEnd() {
return this.realIndex === this.length - 1;
},
ocIsBegin() {
return this.realIndex === 0;
},
},
watch: {
realIndex(val) {
if (val !== this.value) {
this.$emit("input", val);
}
this.valueChangeHandler(val);
},
value(val) {
const realIndex = this.realIndex;
const length = this.pages.length;
if (val >= length) {
val = realIndex;
this.$emit("input", val);
}
this.realIndex = val;
},
autoplayTime() {
this.autoChange();
},
},
mounted() {
this.hasMounted = true;
this.init();
this.initOnce();
},
destroyed() {
this.autoplayTimer && clearTimeout(this.autoplayTimer);
},
methods: {
reset() {
this.init();
this.initOnce();
},
init() {
// 如果组件 mounted 前 init 方法被调用,则会引起报错。
// 因此使用 hasMounted 变量来保证不会报错。
if (!this.hasMounted) return;
// 设置部分 datas 的值
const success = this.initDatas();
if (!success) {
// Failed to init datas
return;
}
// 为 wrapper 定宽
this.$refs.wrapper.style.width = `${this.width}px`;
// 复制首尾 dom
this.clearCopies();
this.addCopies();
// 自动轮播
if (this.autoplayTime > 0) {
this.autoChange();
}
},
initOnce() {
this.setTranslate(this.ocTranslatex);
},
initDatas() {
const style = getComputedStyle(this.$el, false).width;
this.width = parseInt(style, 10);
if (!this.$slots.default) {
// console.warn('No child nodes in swipe component', this.$el);
return false;
}
this.pages = this.$slots.default
.filter(
(vnode) => vnode.tag && vnode.elm.classList.contains("oc-swiper-item")
)
.map((vnode) => vnode.elm);
if (!this.pages.length) {
// console.warn('The swipe component not contained swipe-item component', this.$el);
return false;
}
this.length = this.pages.length;
return true;
},
// 清除复制的首尾dom
clearCopies() {
const children = this.$refs.wrapper.querySelectorAll(
".oc-swiper-item-copy"
);
[...children].forEach((el) => {
this.$refs.wrapper.removeChild(el);
}, this);
this.$refs.wrapper.style.marginLeft = "0";
},
// 复制首尾dom
addCopies() {
// if(!this.loop) return
const fronts = [];
const ends = [];
const pages = this.pages;
// 只有一个或不需要循环时时不复制
if (!this.loop || pages.length < 2) return;
// copy 第一个和最后一个元素
pages.forEach((item, index) => {
if (index === 0) {
const copy = item.cloneNode(true);
copy.classList.remove("oc-swiper-item");
copy.classList.add("oc-swiper-item-copy");
fronts.push(copy);
}
if (index === pages.length - 1) {
const copy = item.cloneNode(true);
copy.classList.remove("oc-swiper-item");
copy.classList.add("oc-swiper-item-copy");
ends.push(copy);
}
}, this);
this.copyNum = ends.length;
// insert node
while (ends.length) {
const item = ends.shift();
const firstNode = this.$refs.wrapper.querySelector(".oc-swiper-item");
this.$refs.wrapper.insertBefore(item, firstNode);
}
while (fronts.length) {
const item = fronts.shift();
this.$refs.wrapper.appendChild(item);
}
this.$refs.wrapper.style.marginLeft = `-${this.width * this.copyNum}px`;
},
handleTouchstart(e) {
this.$emit("onTouchStart");
if (this.length <= 1 || this.animating) return;
this.startx = e.touches[0].pageX;
this.starty = e.touches[0].pageY;
this.touchStartTime = new Date().getTime();
// 滑动开始时,清除自动轮播的计时器
this.autoplayTimer && clearTimeout(this.autoplayTimer);
// if (this.autoChange) {
// this.autoChange(); // 重置自动轮播的计时器
// }
this.isTouching = true;
},
handleTouchmove(e) {
// if (this.length <= 1 || this.animating) return;
if (this.length <= 1) return;
if (this.animating) {
e.preventDefault();
return;
}
if (this.isPreventDefault) {
e.preventDefault();
}
let moveDistance = e.touches[0].pageX - this.startx;
this.moveDistance = moveDistance;
// 判断用户是横向滑动还是纵向滑动,以此来避免误滑
if (this.isTouching) {
this.isTouching = false;
const moveY = e.touches[0].pageY - this.starty;
this.horizontalMove = Math.abs(moveDistance) >= Math.abs(moveY);
}
// 用户非水平滑动屏幕
if (!this.horizontalMove) {
this.$emit("onTouchMove", e, "vertical");
return;
}
this.$emit("onTouchMove", e, "horizontal");
const translate = this.ocTranslatex + moveDistance;
// 正常触摸应该移动的距离
let finalTranslate = translate;
// 考虑 loop 的取值时
if (!this.loop) {
const leftBoundary = 0;
const rightBoundary = -this.width * (this.length - 1);
if (translate > leftBoundary) {
// 左边界
finalTranslate = leftBoundary;
} else if (translate < rightBoundary) {
// 右边界
finalTranslate = rightBoundary;
}
}
this.setTranslate(finalTranslate);
},
handleTouchend(e) {
this.$emit("onTouchEnd");
if (this.length <= 1 || this.animating) return;
if (!this.horizontalMove) return;
const isQuick =
new Date().getTime() - this.touchStartTime < this.quickTouchTime;
if (this.loop) {
// 如果是 loop 的话,有很多地方需要特殊处理
this.handleTouchend_loop(this.cartChange(this.moveDistance, isQuick));
} else {
// 根据轮播图滑动的方向来改变 realIndex
this.updateInsideValue(this.cartChange(this.moveDistance, isQuick));
}
// reset some data
this.moveDistance = 0;
// 滑动结束,开启自动轮播
this.autoChange();
},
// 考虑 this.loop 的取值对 translate 的影响
handleTouchend_loop(deviation) {
if (!this.loop) return;
const newValue = this.realIndex + deviation;
// left boundary
if (this.realIndex === 0 && newValue < this.realIndex) {
this.setTranslate(-this.width * this.length + this.moveDistance);
setTimeout(() => {
this.updateInsideValue(deviation);
}, 40);
return;
}
// right boundary
if (this.realIndex === this.length - 1 && newValue > this.realIndex) {
this.setTranslate(this.width + this.moveDistance);
setTimeout(() => {
this.updateInsideValue(deviation);
}, 40);
return;
}
this.updateInsideValue(deviation);
},
/**
* 根据传入的差值来正确的更新 realIndex
* @param {number} deviation value 改变的差值
*/
updateInsideValue(deviation) {
// 因为滑动后如果没有翻页成功,是无法改变 realIndex 的值的,所以需要手动触发 handler
if (deviation === 0) {
this.isRealIndexChange = false;
this.valueChangeHandler(deviation);
return;
}
// 新的 insidevalue 值应该是现在 realIndex 的值 和 改变的差值的和
const newValue = this.realIndex + deviation;
this.isRealIndexChange = true;
// 按顺序查看是否满足处理数据的要求,如果不满足则交给下一个函数处理
const chain = this.chain(
// 是否是 loop
this.updateInsideValue_loop,
// 什么特殊的设置都没有
this.updateInsideValue_normal,
// 通过更新 realIndex 来触发 valueChangeHandler
(newValue) => {
this.realIndex = newValue;
}
);
chain(newValue);
},
// 当考虑到 loop 的情况时
updateInsideValue_loop(newValue) {
if (!this.loop) return false;
this.isRealIndexChange = true;
if (newValue < 0) {
this.realIndex = this.length - 1;
return "done";
}
if (newValue > this.length - 1) {
this.realIndex = 0;
return "done";
}
return false;
},
// 普通状态下, loop === false
updateInsideValue_normal(newValue) {
if (newValue < 0) {
this.realIndex = 0;
this.isRealIndexChange = false;
// 因为这时候 realIndex 的值其实没变,所以需要手动触发 valueChangeHandler
this.valueChangeHandler(0);
return "done";
}
if (newValue > this.length - 1) {
this.realIndex = this.length - 1;
this.isRealIndexChange = false;
// 因为这时候 realIndex 的值其实没变,所以需要手动触发 valueChangeHandler
this.valueChangeHandler(this.length - 1);
return "done";
}
this.isRealIndexChange = true;
return false;
},
cartChange(moveDistance, quick) {
const absMove = Math.abs(moveDistance);
const absMin = Math.abs(this.ocMinMoveDistance);
// 策略组
const strategies = {
// 普通滑动
normal() {
if (absMove < absMin) return 0;
if (moveDistance > 0) return -1;
if (moveDistance < 0) return 1;
return 0;
},
// 快速滑动
quick() {
if (absMove < 10) return 0;
if (moveDistance > 0) return -1;
if (moveDistance < 0) return 1;
return 0;
},
};
let stgy = "normal";
// 当前策略
switch (true) {
case quick === true:
stgy = "quick";
break;
default:
stgy = "normal";
break;
}
return strategies[stgy].apply(this);
},
valueChangeHandler(value) {
// 添加过渡效果
this.duration();
this.setTranslate(this.ocTranslatex);
},
// 自动轮播
autoChange() {
this.autoplayTimer && clearTimeout(this.autoplayTimer);
const timer = () => {
if (
typeof this.autoplayTime !== "number" ||
this.autoplayTime <= 0 ||
this.length <= 1
)
return;
this.autoplayTimer = setTimeout(() => {
this.autoChangeHandler();
timer();
}, this.autoplayTime);
};
timer();
},
autoChangeHandler() {
this.isRealIndexChange = true;
// 如果是右边界,则先移动到左边被 copy 的相同的元素
if (this.ocIsEnd) {
this.setTranslate(this.width);
}
// 如果不延迟 40 ms 的话,在 setTranslate 的时候,就会触发 transition 效果,这是不想要的。
setTimeout(() => {
this.realIndex =
this.realIndex < this.length - 1 ? this.realIndex + 1 : 0;
}, 40);
},
/**
* 惰性函数,设置 dom 的 translate 值
* @param {dom} el 进行变换的元素
* @param {number|string} trans 进行变换的值
*/
setTranslate(d) {
let transform = `translate3d(${d}px, 0, 0)`;
this.$refs.wrapper.style.webkitTransform = transform;
this.$refs.wrapper.style.transform = transform;
},
/**
* 添加和删除过渡效果
* @param {Array} args 需要添加过渡动画的元素数组
*/
duration() {
this.animating = true;
// 回调函数,swiper从当前slide开始过渡到另一个slide时执行。
if (this.isRealIndexChange) {
this.$emit("slideChangeTransitionStart", this.realIndex);
}
const el = this.$refs.wrapper;
const speed = this.speed;
el.style.webkitTransitionDuration = `${speed}ms`;
el.style.transitionDuration = `${speed}ms`;
el.style.webkitTransitionTimingFunction = "ease-out";
el.style.transitionTimingFunction = "ease-out";
// 添加过渡效果
this.durationTimer && clearTimeout(this.durationTimer);
this.durationTimer = setTimeout(() => {
el.style.transitionDuration = "";
el.style.webkitTransitionDuration = "";
this.animating = false;
// 回调函数,swiper从一个slide过渡到另一个slide结束时执行。
if (this.isRealIndexChange) {
this.$emit("slideChangeTransitionEnd", this.realIndex);
}
}, speed);
},
// 职责链,函数 return false 则终止传递
chain(...fns) {
return (...args) => {
for (let index = 0; index < fns.length; index += 1) {
const result = fns[index](...args);
if (result === "done") break;
}
};
},
},
};
</script>
<style lang="scss" scoped>
.oc-swiper {
overflow: hidden;
}
.oc-swiper-wrapper {
height: 100%;
display: flex;
flex-direction: row;
}
.oc-swiper-item,
.oc-swiper-item-copy {
width: 100%;
height: 100%;
flex: none;
}
.oc-swiper-pagination {
position: relative;
height: 0;
}
.oc-swiper-pagination-bar {
position: absolute;
left: 0;
right: 0;
top: -16px;
bottom: 0;
display: flex;
justify-content: center;
align-items: flex-start;
}
.oc-swiper-pagination-item {
display: block;
width: 4px;
height: 4px;
border-radius: 2px;
background-color: rgba(#fff, 0.6);
margin: 0 4px;
transition: all 0.1s;
&.active {
width: 8px;
background-color: #fff;
}
}
</style>
点击查看 DEMO 源码
<template>
<div class="demo-swiper-page">
<section class="demo">
<oc-swiper
ref="swiper"
:autoplayTime="autoplayTime"
:loop="loop"
:minMoveDistance="minMoveDistance"
:pagination="pagination"
:isPreventDefault="false"
@slideChangeTransitionStart="slideChangeTransitionStart"
@slideChangeTransitionEnd="slideChangeTransitionEnd"
@onTouchStart="onTouchStart"
@onTouchMove="onTouchMove"
@onTouchEnd="onTouchEnd"
v-model="index"
>
<div
class="oc-swiper-item"
v-for="(item, idx) in list"
:key="idx"
@click="slideClick(item, idx)"
>
<div class="slide-item">{{ item }}</div>
</div>
<div v-if="userPagination" class="user-pagination" slot="pagination">
<ul v-if="isPageStyle1">
<li
v-for="page in list.length"
:key="page"
:class="{ 'cur-page': index === page - 1 }"
@click="index = page - 1"
>
{{ page }}
</li>
</ul>
<div v-else class="page-2">{{ index + 1 }} / {{ list.length }}</div>
</div>
</oc-swiper>
</section>
<button class="g-btn" @click="update">更新数据</button>
<button class="g-btn" @click="add">增加一条数据</button>
<button class="g-btn" @click="index = 2">滚动到第3个</button>
<button class="g-btn" @click="autoplayTime = 0">停止自动播放</button>
<button class="g-btn" @click="autoplayTime = 3000">3S自动播放</button>
<button class="g-btn" @click="loop = !loop">
{{ loop ? "关闭" : "开启" }}循环
</button>
<button class="g-btn" @click="minMoveDistance = '50%'">
最小滑动距离50%切换
</button>
<button class="g-btn" @click="userPagination = !userPagination">
{{ userPagination ? "关闭" : "开启" }}自定义分页
</button>
<button
class="g-btn"
v-if="userPagination"
@click="isPageStyle1 = !isPageStyle1"
>
自定义分页样式切换
</button>
<button class="g-btn" @click="pagination = !pagination">
{{ pagination ? "隐藏" : "显示" }}分页
</button>
</div>
</template>
<script>
export default {
name: "DemoSwiper",
data() {
return {
index: 0,
minMoveDistance: "20%",
autoplayTime: 3000,
loop: true,
list: [1, 2, 3, 4],
pagination: true,
userPagination: false,
isPageStyle1: true,
};
},
mounted() {
window.onresize = () => {
this.$refs.swiper.reset();
};
},
methods: {
add() {
this.list.push(this.list.length + 1);
this.$nextTick(() => {
this.$refs.swiper.reset();
});
},
update() {
this.list = ["a", "b", "c", "d"];
this.$nextTick(() => {
this.$refs.swiper.reset();
});
},
onTouchStart() {
console.log("onTouchStart:");
},
onTouchMove(e, direction) {
// 禁用水平方向滑动
if (direction === "horizontal") {
e.preventDefault();
}
console.log(`onTouchMove:${direction}`);
},
onTouchEnd() {
console.log("onTouchEnd:");
},
slideClick(item, idx) {
console.log(`slideClick: index-${idx}, item-${item}`);
},
slideChangeTransitionStart(idx) {
console.log(`slideChangeTransitionStart:${idx}`);
},
slideChangeTransitionEnd(idx) {
console.log(`slideChangeTransitionEnd:${idx}`);
},
},
};
</script>
<style lang="scss">
.oc-swiper {
height: 200px;
.slide-item {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #99a9bf;
font-size: 30px;
font-weight: bold;
}
}
.user-pagination {
position: relative;
ul {
margin-top: -24px;
display: flex;
justify-content: center;
align-items: flex-start;
li {
color: #fff;
background: rgba(#000, 0.4);
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
font-size: 12px;
border-radius: 50%;
display: inline-block;
margin: 0 4px;
&.cur-page {
background: #007aff;
}
}
}
.page-2 {
margin-top: -24px;
text-align: center;
color: #000;
}
}
</style>
<style lang="scss" scoped>
.demo-swiper-page {
margin: 10px;
.demo {
margin-bottom: 10px;
}
.g-btn {
margin-bottom: 10px;
}
}
</style>