前言

起初我是要编写一个画布的组件功能的。考虑到目前我的项目代码有 vue2,vue3,本人还在学习 React。所以我想编写一个可以不受框架限制的组件。正好借着这个机会学习一下面相对象开发组件。

分析组件需求

这一步要明确组件需要哪些基本功能。首先从使用方面来说,只需要满足指定的 dom 就行,然后就是满足基本的轮播图功能。那么使用场景和需求确定了。下面就初步构想下组件的功能了。

  1. 使用一个 Swiper 类来编写,这样就可以在 new 的时候传入已经存在的 dom 结构,来生成组件

  2. dom 结构我参考了知名开源项目 Swiper 的结构。

    <!-- 主要结构,下面的ul主要是为了后面的页码点击跳转到指定图 -->
    <div class="canvas-list swiper" data-v-1703472734957="true">
      <div
        class="swiper-wrapper"
        data-v-1703472734957="true"
        style="transform: translateX(-703px); transition: transform 0.3s ease-in-out 0s;">
        <div id="1703472746911" class="swiper-slide" data-index="1">
          <div
            class="konvajs-content"
            role="presentation"
            style="position: relative; user-select: none; width: 703px; height: 876px;">
            <canvas
              width="703"
              height="876"
              style="padding: 0px; margin: 0px; border: 0px; background: transparent; position: absolute; top: 0px; left: 0px; width: 703px; height: 876px; display: block;"></canvas>
          </div>
        </div>
      </div>
      <ul class="pagination-list" data-v-1703472734959="true">
        <li class="pagination-item" data-page="0">1</li>
      </ul>
    </div>
    

下面之后的想法是在项目开发过程中改进的。

  1. 外部传入参数来控制自动轮播图或者轮播图的切换事件
  2. 添加了新的轮播图或者轮播图切换的时候对外部进行通知

开始之前: SwiperOptions

在开始创建我们的 Swiper 类之前,首先需要设定一个名为 SwiperOptions 的预定义接口。这个接口收纳了我们的轮播器中需要的配置,包括是否启动自动播放 (autoplay)、播放间隔 (delay)、是否显示分页指示器 (pagination),及用户交互行为的启用 (enablePlayenablePagination)。我们同样还定义了两个回调函数,onSlideAddedonSlideChanged,以便有新图片添加或者当前图片更换时触发。

核心功能: Swiper

接下来就是我们的主角,Swiper 类。它需要接收一个 HTML 元素(或其选择器),以及我们刚才定义的 SwiperOptions 接口作为构造函数的参数。在这个类中,我们会获取并设置图片容器,轮播元素,以及其他相关参数。

我们的轮播器中提供了三个关键的方法在进行图片切换操作:slideToNextslideToPrevslideTo ,这三个方法分别用于切换到下一张、上一张或指定索引的图片。并且与一个数据 currentSlideIndex 携手工作,准确地记住和展示当前的图片。

动态添加和删除

为了允许图片的动态增删,我们采用了 MutationObserver 来监视 DOM 的变化。在新的图片添加或移除时,相应的方法 handleSlideAddedhandleSlideDeleted 会被调用,这样就能动态地刷新轮播器的状态,并触发对应的轮播器事件。

自动播放与悬停停止

此外,我们的轮播器还具备自动播放的特性。在配置中讲 enablePlayautoplay 设为 true,并指定合适的 delay 延迟时长,轮播器就会自动切换图片了。为了用户体验,当鼠标指针移动到轮播器上时,自动播放会被暂停,鼠标离开又会恢复。

个性化定制

最后,为了做到个性化定制,我们可以提供更多的自定义配置选项,通过直接调整 SwiperOptions 接口或者继承并扩展 Swiper 类中的方法来实现。

详细代码

interface SwiperOptions {
  autoplay?: boolean;
  delay?: number;
  enablePlay?: boolean;
  enablePagination?: boolean;
  pagination?: any;
  onSlideAdded?: ((newSlide: HTMLElement) => void) | null;
  onSlideChanged?: ((index: number) => void) | null;
}

interface Pagination {
  updatePageNumber: (length: number) => void;
  activePageNumberByIndex: (index: number) => void;
}

export class Swiper {
  lastPagination: HTMLElement | null = null;
  settings: SwiperOptions;
  container: HTMLElement;
  wrapper: HTMLElement;
  slides: NodeListOf<HTMLElement>;
  currentSlideIndex: number = 0;
  slideWidth: number;
  autoSlideInterval: any;
  observer: MutationObserver;

  constructor(selector: string | HTMLElement, options: SwiperOptions) {
    const defaultOptions: SwiperOptions = {
      autoplay: true,
      delay: 3000,
      enablePlay: true,
      enablePagination: false,
      pagination: null,
    };

    this.settings = Object.assign(
      {
        onSlideAdded: null,
        onSlideChanged: null,
      },
      defaultOptions,
      options
    );

    this.container =
      typeof selector === "string"
        ? (document.querySelector(selector)! as HTMLElement)
        : selector;
    this.wrapper = this.container.querySelector(
      ".swiper-wrapper"
    )! as HTMLElement;
    this.slides = this.wrapper.querySelectorAll(
      ".swiper-slide"
    ) as NodeListOf<HTMLElement>;

    this.slideWidth = this.slides[0].clientWidth;
    this.autoSlideInterval = setTimeout(() => {}, 0);

    this.observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === "childList" && mutation.addedNodes.length) {
          this.handleSlideAdded(mutation.addedNodes[0] as HTMLElement);
        } else if (
          mutation.type === "childList" &&
          mutation.removedNodes.length
        ) {
          this.handleSlideDeleted(mutation.removedNodes[0] as HTMLElement);
        }
      });
    });

    this.observer.observe(this.wrapper, { childList: true });

    if (this.settings.enablePlay) {
      this.container.addEventListener(`mouseenter`, () => {
        this.stopAutoSlide();
      });

      this.container.addEventListener(`mouseleave`, () => {
        this.startAutoSlide();
      });
    }

    this.slideTo = this.slideTo.bind(this);
  }

  destroy() {
    this.container.removeEventListener(`mouseenter`, () => {
      this.stopAutoSlide();
    });

    this.container.removeEventListener(`mouseleave`, () => {
      this.startAutoSlide();
    });
  }

  handleSlideDeleted(deletedSlide: HTMLElement) {
    this.refreshSlide();
    this.updatePageNumber();
    let slideIndex = this.currentSlideIndex % this.slides.length;
    this.slideTo(slideIndex);
  }

  handleSlideAdded(newSlide: HTMLElement) {
    this.refreshSlide();
    this.updatePageNumber();
    if (typeof this.settings.onSlideAdded === "function") {
      this.settings.onSlideAdded(newSlide);
    }
    this.slideTo(this.currentSlideIndex);
  }

  updatePageNumber() {
    if (this.settings.enablePagination) {
      (this.settings.pagination as Pagination).updatePageNumber(
        this.slides.length
      );
    }
  }

  activePageNumberByIndex(index: number) {
    if (this.settings.enablePagination) {
      (this.settings.pagination as Pagination).activePageNumberByIndex(index);
    }
  }

  refreshSlide() {
    this.slides = this.wrapper.querySelectorAll(
      ".swiper-slide"
    ) as NodeListOf<HTMLElement>;
  }

  slideToNext() {
    this.currentSlideIndex++;
    if (this.currentSlideIndex > this.slides.length - 1) {
      this.currentSlideIndex = 0;
    }
    this.activePageNumberByIndex(this.currentSlideIndex);
    this.wrapper.style.transition = "transform 0.3s ease-in-out";
    this.wrapper.style.transform = `translateX(-${
      this.slideWidth * this.currentSlideIndex
    }px)`;
  }

  startAutoSlide() {
    this.autoSlideInterval = setInterval(() => {
      this.slideToNext();
    }, this.settings.delay!);
  }

  stopAutoSlide() {
    clearInterval(this.autoSlideInterval);
  }

  init() {
    this.wrapper.style.transform = `translateX(-${
      this.slideWidth * this.currentSlideIndex
    }px)`;
    this.refreshSlide();
    this.updatePageNumber();
    if (this.settings.enablePlay && this.settings.autoplay) {
      this.startAutoSlide();
    }
    this.slideTo(this.currentSlideIndex);
  }

  slideTo(index: number) {
    this.currentSlideIndex = index;
    if (this.currentSlideIndex < 0) {
      this.currentSlideIndex = this.slides.length - 1;
    } else if (this.currentSlideIndex > this.slides.length - 1) {
      this.currentSlideIndex = 0;
    }
    this.activePageNumberByIndex(index);
    if (typeof this.settings.onSlideChanged === "function") {
      this.settings.onSlideChanged(index);
    }
    this.wrapper.style.transition = "transform 0.3s ease-in-out";
    this.wrapper.style.transform = `translateX(-${
      this.slideWidth * this.currentSlideIndex
    }px)`;
  }

  slideToPrev() {
    this.currentSlideIndex--;
    if (this.currentSlideIndex < 0) {
      this.currentSlideIndex = this.slides.length - 1;
    }
    this.activePageNumberByIndex(this.currentSlideIndex);
    this.wrapper.style.transition = "transform 0.3s ease-in-out";
    this.wrapper.style.transform = `translateX(-${
      this.slideWidth * this.currentSlideIndex
    }px)`;
  }
}

结语

这只是一个菜鸟的开发学习过程,如果有更好的改进代码的方法,希望各位大佬不要惜言。