做一个有温度和有干货的技术分享作者 —— Qborfy
前言
循环轮播图,基本上大家用的都是现有组件,如果要让你自己设计实现一个,其实最主要的两个点:循环算法和滚动动画
手写难度:⭐️⭐️
涉及知识点:
- 循环播放的思路
- CSS 动画,transtion和 transform
- Web Component 自定义组件
轮播图
大家最常用的轮播图基本上就是 swiper.js,不仅适配 PC 端和移动端,同时包含多种实际应用场景。但是目前我们只需要实现其中一种场景即可——循环轮播图,大概示例图如下:
<swiper-container speed="500" loop="true">
<swiper-slide>Slide 1</swiper-slide>
<swiper-slide>Slide 2</swiper-slide>
<swiper-slide>Slide 3</swiper-slide>
...
</swiper-container>
Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
Slide 6
大概效果如下:
实现思路
在研究实现思路前,我们先确定一下要实现的目标,如下:
- 采用
Web Component
去实现两个自定义标签<swiper-container>
<swiper-slide>
<swiper-container>
标签支持属性配置,如:speed
loop
实现思路如下:
<swiper-container>
容器为 flex 容器,里面包含一个wrapper
容器用于装载所有的<swiper-slide>
<swiper-slide>
采用横向布局,当切换下一个的时候,使用transform:translate(x,y)
将wrapper
向左移动进行展示下一个slide
- 当
loop
为 true的时候,支持循环播放- 循环播放逻辑为,在最后一个
<swiper-slide>
后面复制第一个<swiper-slide>
- 当最后一个继续点击next的时候,会把复制第一个展示
- 当第一个(复制)展示后,点击下一步的时候,取消动画效果,将
wrapper
位置移动到第一个 - 然后利用
setTimeout(0)
延时执行,增加动画动画效果,将wrapper
位置移动到第二个
- 循环播放逻辑为,在最后一个
为了更好理解循环动画思路,为了更好的展示效果,我将container
取消了overflow:hidden
,具体动画如下:
整个轮播图的 DOM 结构如下:
代码实践
我们将通过Web Component
规范去定义上述两个组件,分别是<swiper-container>
和<swiper-slide>
Swiper-Container组件
Swiper-Container
负责实现容器和控制轮播图滚动事件,等于是整个轮播图的核心,具体代码划分如下:
<template id="swiper-container">
<style>
/**为节省文字,忽略样式,可以到 Github去看看开源完整示例代码 */
</style>
<div class="swiper-container">
<div class="swiper-container-wrapper">
<slot></slot>
</div>
<div class="swiper-pagination">
</div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
</template>
<script>
class SwiperContainer extends HTMLElement {
constructor() {
super();
const template = document.getElementById('swiper-container');
const templateContent = template.content;
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(templateContent.cloneNode(true));
this.loop = this.getAttribute('loop') === 'true';
this.speed = this.getAttribute('speed') || 500;
this.currentIndex = 0;
}
/**
* 当 custom element首次被插入文档DOM时,被调用。
*/
connectedCallback() {
// 由于 slot 的内容是异步的,所以需要等待 slot 的内容渲染完成后再初始化
setTimeout(() => {
this.wrapper = this.shadowRoot.querySelector('.swiper-container-wrapper');
this.slides = this.querySelectorAll('swiper-slide');
this.pagination = this.shadowRoot.querySelector('.swiper-pagination');
this.next = this.shadowRoot.querySelector('.swiper-button-next');
this.prev = this.shadowRoot.querySelector('.swiper-button-prev');
this.prev.classList.add('swiper-button-prev-disabled');
this.slideWidth = this.slides[0].offsetWidth;
this.slideCount = this.slides.length;
this.slides.forEach((slide) => {
slide.style.height = '100%';
});
this.init();
}, 0);
}
/**
* 初始化操作
*/
init() {
this.wrapper.style.width = this.slideWidth * this.slideCount + 'px';
this.wrapper.style.transform = `translate3d(-${this.slideWidth * this.currentIndex}px, 0, 0)`;
this.wrapper.style.transition = `transform ${this.speed}ms ease-in-out`;
// 判断是否可以循环
let slideCount = this.slideCount;
if (this.slideCount > 1 && this.loop) {
this.cloneFirstSlide();
slideCount = this.slideCount - 1;
}
this.pagination.innerHTML = '';
const bulletFragment = document.createDocumentFragment();
for (let i = 0; i < slideCount; i++) {
const bullet = document.createElement('div');
bullet.classList.add('swiper-pagination-bullet');
bullet.dataset.index = i;
bulletFragment.appendChild(bullet);
}
bulletFragment.children[0].classList.add('swiper-pagination-bullet-active');
this.pagination.appendChild(bulletFragment);
}
/**
* 绑定相关事件
*/
bindEvents(){
this.next.addEventListener('click', () => {
this.nextSlide();
});
this.prev.addEventListener('click', () => {
this.prevSlide();
});
this.pagination.addEventListener('click', (e) => {
const index = e.target.dataset.index;
if (index) {
this.goToSlide(index);
}
});
}
/**
* 将第一个 slider 复制到最后
*/
cloneFirstSlide() {
const firstSlide = this.slides[0].cloneNode(true);
this.wrapper.appendChild(firstSlide);
this.slideCount++;
}
/**
* 跳转到下一个的 slider
*/
nextSlide() {
// 如果不是循环的,且已经是最后一个,就不执行
if (!this.loop && this.currentIndex >= this.slideCount - 1) {
return;
}
this.currentIndex++;
// 改变下一个 icon 的状态
if (!this.loop && this.currentIndex >= this.slideCount - 1) {
this.next.classList.add('swiper-button-next-disabled');
} else {
this.next.classList.remove('swiper-button-next-disabled');
this.prev.classList.remove('swiper-button-prev-disabled');
}
console.log(this.currentIndex, this.slideCount)
// 判断是不是最后一个 如果最后一个,等动画执行完毕,瞬间跳到第一个
if (this.currentIndex >= this.slideCount) {
this.currentIndex = 0;
this.wrapper.style.transition = 'none';
this.wrapper.style.transform = `translate3d(-${this.slideWidth * this.currentIndex}px, 0, 0)`;
setTimeout(() => {
this.wrapper.style.transition = `transform ${this.speed}ms ease-in-out`;
this.currentIndex++;
this.goToSlide(this.currentIndex);
}, 0);
} else {
this.goToSlide(this.currentIndex);
}
}
/**
* 跳转到上一个的 slider
*/
prevSlide() {
if (!this.loop && this.currentIndex <= 0) {
return;
}
this.currentIndex--;
if (!this.loop && this.currentIndex <= 0) {
this.prev.classList.add('swiper-button-prev-disabled');
} else {
this.next.classList.remove('swiper-button-next-disabled');
this.prev.classList.remove('swiper-button-prev-disabled');
}
if (this.currentIndex < 0) {
this.currentIndex = this.slideCount - 1;
}
this.goToSlide(this.currentIndex);
}
/**
* 跳转到指定的 slider
* @param {*} index
*/
goToSlide(index) {
this.currentIndex = index;
this.wrapper.style.transform = `translate3d(-${this.slideWidth * this.currentIndex}px, 0, 0)`;
this.setActivePagination();
}
/**
* 设置当前的 pagination
*/
setActivePagination() {
const paginationBullets = this.shadowRoot.querySelectorAll('.swiper-pagination-bullet');
paginationBullets.forEach((bullet, index) => {
bullet.classList.remove('swiper-pagination-bullet-active');
});
if (this.currentIndex === this.slideCount - 1 && this.loop) {
paginationBullets[0].classList.add('swiper-pagination-bullet-active');
return;
}
paginationBullets.forEach((bullet, index) => {
if (index === this.currentIndex) {
bullet.classList.add('swiper-pagination-bullet-active');
}
});
}
}
// 注册swiper-container组件
customElements.define('swiper-container', SwiperContainer);
</script>
要是不想看代码,可以看这里的方法简要说明:
init
初始化函数,用了 setTimeout去解决 slot的异步渲染问题,获取一些 dom 节点- 其中需要判断是否循环loop,如果需要则需要复制第一个节点到最后
cloneFirstSlide
- 其中需要判断是否循环loop,如果需要则需要复制第一个节点到最后
bindEvents
绑定 prev、next、pagination等 dom 的 click事件nextSlide
和prevSlide
指的是跳转到下一个节点和上一个节点所需要执行的函数,- 其中
nextSlide
函数需要在最后一个节点判断当前是否为 loop,如果 loop为 true,则需要停止动画,同时将 wrapper 容器的 transform 迁移到第一个节点
- 其中
goToSlide
用执行当前需要展示哪个 slidesetActivePagination
用执行判断 哪个 pagination需要展示高亮样式
Swiper-Slide组件
swiper-slide
组件实现起来就很简单,只需要满足样式展示即可,不过有一点需要注意,就是由于swiper-container
是flex布局,所以需要设置swiper-slide
的样式不允许缩放flex-shrink: 0;
,完整代码如下:
/**
* 轮播组件,子元素组件
*/
class SwiperSlide extends HTMLElement {
constructor() {
super();
const template = document.createElement('template');
template.innerHTML = `
<style>
.swiper-slide {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
/* 防止缩小 */
flex-shrink: 0;
border: 1px solid #000;
background-color: #478703;
color: #fff;
font-size: 24px;
text-align: center;
}
</style>
<div class="swiper-slide"><slot></slot></div>
`;
const templateContent = template.content;
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(templateContent.cloneNode(true));
}
}
// 注册swiper-slide组件
customElements.define('swiper-slide', SwiperSlide);
本文所有代码都已放到100道前端精品面试题,中的前端面试100道手写题(7)—— 循环轮播图,如果有帮助到你,可以帮忙给个star 即可。