实现流畅Carousel走马灯效果的那些坑与技巧总结

皇甫熙苒 组件 阅读 1,847
赞 8 收藏
二维码
手机扫码查看
反馈

又踩坑了,走马灯的自动播放怎么这么难伺候

最近在项目里写了个Carousel组件,本来以为是个简单需求,结果被自动播放功能折腾得够呛。问题出在移动端,页面滚动时走马灯还在自顾自地播放,导致性能消耗很大,用户体验也差。这里我踩了个坑:原以为监听scroll事件就能搞定,后来发现这玩意儿触发频率太高,反而让页面更卡了。

实现流畅Carousel走马灯效果的那些坑与技巧总结

折腾了半天发现,原来更好的方案是用IntersectionObserver来判断Carousel是否在可视区域。这个API虽然兼容性一般,但正好我们的项目不需要支持太老的浏览器,所以就愉快地用了。

核心代码就这几行

先直接上解决方案的代码吧:

class Carousel {
  constructor(container) {
    this.container = container;
    this.items = Array.from(container.children);
    this.currentIndex = 0;
    this.isPlaying = false;
    this.init();
  }

  init() {
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.start();
        } else {
          this.stop();
        }
      });
    }, { threshold: 0.8 });

    this.observer.observe(this.container);
    this.setupIndicators();
  }

  start() {
    if (this.isPlaying) return;
    this.isPlaying = true;
    this.timer = setInterval(() => {
      this.goToNext();
    }, 3000);
  }

  stop() {
    this.isPlaying = false;
    clearInterval(this.timer);
  }

  goToNext() {
    const lastIndex = this.items.length - 1;
    const nextIndex = this.currentIndex === lastIndex ? 0 : this.currentIndex + 1;
    this.goToItem(nextIndex);
  }

  goToItem(index) {
    this.items[this.currentIndex].classList.remove('active');
    this.items[index].classList.add('active');
    this.indicators[this.currentIndex].classList.remove('active');
    this.indicators[index].classList.add('active');
    this.currentIndex = index;
  }

  setupIndicators() {
    const indicatorContainer = document.createElement('div');
    indicatorContainer.className = 'indicators';

    this.indicators = this.items.map((_, i) => {
      const indicator = document.createElement('span');
      indicator.addEventListener('click', () => {
        this.goToItem(i);
        this.stop();
      });
      indicatorContainer.appendChild(indicator);
      return indicator;
    });

    this.container.parentNode.appendChild(indicatorContainer);
    this.indicators[0].classList.add('active');
  }
}

谁更灵活?谁更省事?

上面这段代码其实是我尝试了三种方案后的最终版。最初我是用setInterval硬写的,后来发现问题一大堆:

  • 页面最小化时还在消耗资源
  • 快速切换tab会导致动画跳帧
  • 滚动时不停触发重绘

中间我还试过用requestAnimationFrame改写,性能确实好了一些,但还是解决不了页面不可见时的资源浪费问题。最后才找到了IntersectionObserver这个神器,完美解决了只在可见时播放的需求。

这里有个小细节要注意:threshold我设置的是0.8,意思是元素有80%可见时就开始播放。这个值可以根据实际需求调整,但我试了几个值后发现0.8比较合适,既不会太早开始播放,也不会等到完全可见才触发。

踩坑提醒:这三点一定注意

第一个坑是关于CSS动画的。我最开始在item切换时用了transition,结果发现和js控制的定时器有时候会打架,尤其是在快速手动切换时。后来改成只用js控制样式变化,问题就解决了:

.carousel-item {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  opacity: 0;
  transition: none; /* 这里特意去掉了transition */
}

.carousel-item.active {
  opacity: 1;
}

第二个坑是关于触摸事件的处理。移动端必须考虑用户的手势操作,但也不能影响正常的页面滚动。这里我的解决方案是:只在横向滑动距离大于纵向时,才认为是有效的carousel操作:

let startX, startY;

container.addEventListener('touchstart', (e) => {
  const touch = e.touches[0];
  startX = touch.clientX;
  startY = touch.clientY;
});

container.addEventListener('touchmove', (e) => {
  if (!this.isDragging) return;
  const touch = e.touches[0];
  const dx = Math.abs(touch.clientX - startX);
  const dy = Math.abs(touch.clientY - startY);

  if (dx > dy && dx > 50) {
    e.preventDefault(); // 只有明确的横向滑动才阻止默认行为
    // 处理滑动逻辑...
  }
});

第三个坑是关于循环播放的边界处理。当从最后一张切回第一张时,如果处理不当会有明显的跳变。我的做法是在DOM结构上做文章,把第一张复制到最后,最后一张复制到最前:

cloneItems() {
  const firstClone = this.items[0].cloneNode(true);
  const lastClone = this.items[this.items.length - 1].cloneNode(true);
  this.container.prepend(lastClone);
  this.container.append(firstClone);
  this.items = Array.from(this.container.children); // 更新items数组
}

这样在视觉上就能实现无缝衔接了,不过记得在逻辑上要做相应的偏移处理。

以上是我踩坑后的总结

这个Carousel组件虽然已经能用了,但还有些可以优化的地方。比如现在的触摸滑动还不够丝滑,可能需要引入惯性滚动;还有就是目前的动画效果比较简单,后续可以加一些更炫酷的过渡效果。

总的来说,这个需求让我对IntersectionObserver有了更深的理解,也学到了不少性能优化的小技巧。如果你有更好的实现方案,或者对这个组件有什么改进建议,欢迎在评论区交流!

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

暂无评论