手把手实现高性能Slideshow幻灯片组件的开发与优化
项目初期的技术选型
上个月接了个需求,要在首页加个轮播图,展示客户案例。一开始觉得这不就是个基础组件嘛,随便找个现成的库套一下就行。但产品非要支持手势滑动、自动播放、带指示器,还要在低端安卓机上流畅——这就有点麻烦了。
我先试了 Swiper,功能确实全,但打包后体积涨了 80KB,而且和我们现有的 CSS 框架有样式冲突,调起来特别费劲。后来又看了 Flickity,轻量不少,但对 touch 事件的支持不够细,滑动时偶尔会卡住。最后决定自己写一个,反正核心逻辑就那么几行:左右切换、自动轮播、touch 滑动。代码可控,体积也小,出了问题自己能改。
最大的坑:性能问题
自己写完第一版,本地测试没问题,结果一上真机,低端安卓机上滑动卡成 PPT。折腾了半天发现,问题出在频繁触发 transform 和 transition 上。每次手指移动都直接设置 translateX,导致每帧都在重排重绘,GPU 直接干冒烟了。
开始没想到要节流,后来加了个 requestAnimationFrame 包裹更新逻辑,稍微好点,但还是不够丝滑。再后来查资料发现,关键是要把 transform 的值缓存起来,只在真正需要渲染的时候才更新 DOM。于是改成了用一个变量 currentOffset 记录当前偏移,只在 touchend 或 touchcancel 时才触发动画切换,中间滑动过程只是计算位置,不碰 DOM。
还有一个隐藏坑:自动播放和手动滑动的冲突。用户正在滑动,定时器突然跳到下一张,体验极差。我加了个 isDragging 标志位,只要用户手指按下,就暂停自动播放;手指抬起后再延迟 3 秒恢复。这个逻辑看似简单,但一开始没处理 touchcancel(比如来电中断),导致有时候自动播放再也回不来。后来补上了 touchcancel 的监听,才算稳住。
核心代码就这几行
下面是我最终用的简化版代码,去掉了 loading、lazyload 等次要功能,保留了核心逻辑。亲测有效,低端机也能跑:
<div class="slideshow" id="slideshow">
<div class="slides" id="slides">
<div class="slide">1</div>
<div class="slide">2</div>
<div class="slide">3</div>
</div>
<div class="indicators" id="indicators"></div>
</div>
.slideshow {
position: relative;
overflow: hidden;
width: 100%;
height: 300px;
}
.slides {
display: flex;
transition: transform 0.3s ease-out;
will-change: transform;
}
.slide {
min-width: 100%;
height: 100%;
background: #eee;
display: flex;
align-items: center;
justify-content: center;
}
.indicators {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.5);
cursor: pointer;
}
.indicator.active {
background: white;
}
class Slideshow {
constructor(container) {
this.container = container;
this.slides = container.querySelector('.slides');
this.slideItems = container.querySelectorAll('.slide');
this.indicatorsContainer = container.querySelector('.indicators');
this.currentIndex = 0;
this.total = this.slideItems.length;
this.isDragging = false;
this.startX = 0;
this.currentX = 0;
this.offsetX = 0;
this.autoPlayTimer = null;
this.init();
}
init() {
this.renderIndicators();
this.bindEvents();
this.startAutoPlay();
}
renderIndicators() {
this.indicatorsContainer.innerHTML = '';
this.slideItems.forEach((_, i) => {
const indicator = document.createElement('div');
indicator.classList.add('indicator');
if (i === this.currentIndex) indicator.classList.add('active');
indicator.addEventListener('click', () => this.goTo(i));
this.indicatorsContainer.appendChild(indicator);
});
}
bindEvents() {
// Touch events
this.slides.addEventListener('touchstart', this.handleTouchStart.bind(this));
this.slides.addEventListener('touchmove', this.handleTouchMove.bind(this));
this.slides.addEventListener('touchend', this.handleTouchEnd.bind(this));
this.slides.addEventListener('touchcancel', this.handleTouchEnd.bind(this));
// Mouse events for desktop
this.slides.addEventListener('mousedown', this.handleMouseDown.bind(this));
window.addEventListener('mouseup', this.handleMouseUp.bind(this));
window.addEventListener('mousemove', this.handleMouseMove.bind(this));
}
handleTouchStart(e) {
this.isDragging = true;
this.startX = e.touches[0].clientX;
this.currentX = this.startX;
this.pauseAutoPlay();
}
handleTouchMove(e) {
if (!this.isDragging) return;
e.preventDefault();
this.currentX = e.touches[0].clientX;
this.offsetX = this.currentX - this.startX;
// 只计算,不更新 DOM
}
handleTouchEnd() {
if (!this.isDragging) return;
this.isDragging = false;
const threshold = 50; // 滑动超过 50px 才切换
if (this.offsetX > threshold && this.currentIndex > 0) {
this.prev();
} else if (this.offsetX < -threshold && this.currentIndex < this.total - 1) {
this.next();
} else {
// 回弹
this.updatePosition();
}
this.offsetX = 0;
this.startAutoPlay();
}
// 鼠标事件省略,逻辑类似 touch
updatePosition() {
const translateX = -this.currentIndex * 100;
this.slides.style.transform = translateX(${translateX}%);
this.updateIndicators();
}
goTo(index) {
this.currentIndex = index;
this.updatePosition();
this.resetAutoPlay();
}
next() {
this.currentIndex = (this.currentIndex + 1) % this.total;
this.updatePosition();
this.resetAutoPlay();
}
prev() {
this.currentIndex = (this.currentIndex - 1 + this.total) % this.total;
this.updatePosition();
this.resetAutoPlay();
}
updateIndicators() {
const indicators = this.indicatorsContainer.querySelectorAll('.indicator');
indicators.forEach((ind, i) => {
ind.classList.toggle('active', i === this.currentIndex);
});
}
startAutoPlay() {
this.autoPlayTimer = setInterval(() => {
this.next();
}, 4000);
}
pauseAutoPlay() {
if (this.autoPlayTimer) {
clearInterval(this.autoPlayTimer);
this.autoPlayTimer = null;
}
}
resetAutoPlay() {
this.pauseAutoPlay();
this.startAutoPlay();
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
new Slideshow(document.getElementById('slideshow'));
});
回顾与反思
这个轮子造完之后,体积只有 3KB(gzip 后),比任何第三方库都轻,而且完全可控。性能问题也基本解决了,低端机滑动虽然不是 60fps,但至少不会卡死。指示器、自动播放、手势滑动这些需求都满足了。
不过还是有几个小问题没彻底解决:一是快速连续滑动时,偶尔会跳过一张(因为 transition 还在进行中,新的 transform 被覆盖了);二是没做图片懒加载,首屏加载稍慢。但产品说影响不大,就没继续优化。
回头想想,如果项目时间紧,其实用 Swiper 也行,只要花点时间隔离样式。但自己写的好处是,出了问题不用翻文档,直接改代码就行。而且这种基础组件,写一次以后还能复用,长期看是划算的。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的实现方式,或者遇到类似问题,欢迎评论区交流。这个组件的拓展性其实不错,比如加上 fade 效果、预加载前后两张图,都不难。后续有空再分享这些优化点。

暂无评论