手把手实现高性能Slideshow幻灯片组件的开发与优化

打工人兰兰 组件 阅读 1,358
赞 19 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个需求,要在首页加个轮播图,展示客户案例。一开始觉得这不就是个基础组件嘛,随便找个现成的库套一下就行。但产品非要支持手势滑动、自动播放、带指示器,还要在低端安卓机上流畅——这就有点麻烦了。

手把手实现高性能Slideshow幻灯片组件的开发与优化

我先试了 Swiper,功能确实全,但打包后体积涨了 80KB,而且和我们现有的 CSS 框架有样式冲突,调起来特别费劲。后来又看了 Flickity,轻量不少,但对 touch 事件的支持不够细,滑动时偶尔会卡住。最后决定自己写一个,反正核心逻辑就那么几行:左右切换、自动轮播、touch 滑动。代码可控,体积也小,出了问题自己能改。

最大的坑:性能问题

自己写完第一版,本地测试没问题,结果一上真机,低端安卓机上滑动卡成 PPT。折腾了半天发现,问题出在频繁触发 transformtransition 上。每次手指移动都直接设置 translateX,导致每帧都在重排重绘,GPU 直接干冒烟了。

开始没想到要节流,后来加了个 requestAnimationFrame 包裹更新逻辑,稍微好点,但还是不够丝滑。再后来查资料发现,关键是要把 transform 的值缓存起来,只在真正需要渲染的时候才更新 DOM。于是改成了用一个变量 currentOffset 记录当前偏移,只在 touchendtouchcancel 时才触发动画切换,中间滑动过程只是计算位置,不碰 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 效果、预加载前后两张图,都不难。后续有空再分享这些优化点。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论