手把手教你使用JavaScript实现一个slider无限滚动插件

1,134 阅读6分钟

1. 滚动插件的使用缘由

在项目开发中,图片滚动的应用场景特别多。有很多设计依靠滚动来实现。那么此时我们可以选择不同的滚动插件达到相同的效果。但是,外部插件却是是比较优秀,但是同时因为功能性代码太多,根据我们 组的开发情况,我们基本杜绝了外部插件,除非特殊情况必须使用。
而我编写的这个插件,可以实现目前设计中的各种滚动场景,支持自定义滚动距离,只要你是以下结构,你就可以直接将该滚动插件套用。当然,不是说一定要url>li 结构,而是只要是这种列表形式的dom结构都可以。

1621564832.png

PC

1621560803 (1).png

Mobile

1621560827.png

2. 需求驱动开发

我的页面并不是只有一个滚动,而是有很多个滚动效果。因此,并不能将代码写死在具体某一个dom(或者class名称)上。毕竟,在不同的结构中,class名称不可能一模一样。也不能因为滚动,就添加额外的class名称来控制。最简单的就是按照需要,提取公共的参数。

  • parent: 滚动列表的父容器,
  • children: 具体滚动列表集合,
  • scrollStep:每次滑动的宽度,
  • currentIndex:当前滚动显示的索引,
  • childCls: child列表的class名称,
  • isInfiniteScroll:是否需要支持无限滚动,
  • paginationMethods:该插件根据需求支持3钟分页按钮,该方法可以自定义需要哪些,不局一个分页,
  • paginationStep:每一页显示的个数(总共有4个card,默认4页,值设置为2,那么页数为2页),
  • customInitDom:当进行无限循环,每页有多个card, 当最后一个只有1个card,需要将剩下的card使用占位符占位,该值表示使用什么节点进行占位,默认为li

tip: 我们利用CSS transform 来实现移动。

3. 基本结构

下面呈现了该组件的基本结构,其实就是一个 slider 组件类,然后new 了一个组件出来。 我们还在组件内部,进行初始化了一系列的私有变量,每一个变量已近进行了详细注释。看到这里,可能对变量不是很清楚使用,这个没关系,我们后续每隔方法中会使用到。 但是注意 prototype上的 originListDoms 变量,它的作用是:当界面进行大小拖拽时,界面处于PC/Mobile的样式之间来回切换,如果mobile和pc一页显示的数量不一致,例如上面示例图中的情况,那么就需要存储原有的dom,这个参数就是用于存储改值的。

// 组件
function Slider(parent, children, scrollStep, currentIndex, childCls, isInfiniteScroll, paginationMethods, paginationStep, customInitDom) {
 // dom 相关参数初始化
  paginationStep = paginationStep || 1; // 默认每页1个card
  var parent = parent;
  var childCount = Math.ceil(children.length / paginationStep); // 多少个card
  var scrollStep = scrollStep; // 每次滑动的距离
  var currentIndex = currentIndex; // 当前选中的index(分页显示使用或者 正常滑动的索引)
  var infiniteCurrentIndex = currentIndex; // 无限滚动的索引(无限滚动时,分页的索引使用currentIndex)

 // 位置相关参数初始化
  var startPosition, // 滑动开始的位置
  endPosition, // 滑动结束位置
  deltaX, // 横向滑动距离
  deltaY, // 纵向滑动距离
  isTouchStartFirst = 1// 是否第一次touch滚动
  isScroll; // isScroll 为0时,表示纵向滑动,1为横向滑动
  var isScrollProgress = false; // 是否在滑动中,用于计算滑动时不能点击card

 // 如果滑动元素有a标签,滑动中禁止点击
  var hrefAs = parent.find('a'); // 计算滑动中,将card link禁用
  var isPc = window.iSPc();
 
  this.initMobileOrPcSlider = function() {
 
  }
}
Slider.prototype.originListDoms = {};


//实例化Slider
var slider = new Slider(gallery, children, scrollStep, 0, childCls, isInfiniteScroll, paginationMethods, paginationStep, customInitDom);

slider.initMobileOrPcSlider();

4. 组件初始化

下面进行了伪代码的编写,我们首先进行了事件 dom等一系列的初始化,为什么呢?为了当自适应的时候,pc/mobile样式切换时进行,重置之前所有的参数,方便后续初始化。

为什么需要将 手机滚动事件、pc滚动事件也一起注册,同样是为了应付PC 调整尺寸大小时,为了平板尺寸大小时,能够进行滑动。

this.initMobileOrPcSlider = function() {

  // 清除所有的事件,dom初始化等
  gallery.siblings('.gallery-pagination').remove();
  gallery.siblings('.gallery-pagination-circle').remove();
  gallery.css('marginLeft''0px');
  gallery.css('transform'' translate3d(0px, 0px, 0px)');
  // 2. 先卸载移动事件,避免resize时,重复注册
  gallery.off('touchstart');
  gallery.off('mousedown');



  // 是如果无限滚动处,进行dom处理
  isInfiniteScroll && infiniteScrollDomInit();

  // 注册动画滚动结束事件 
  initTransitionend();
  
  // 注册手机滑动效果
  registerMobileScroll();
 

 // 注册PC 滑动效果
 registerPCScroll();

// 动态添加小圆点
if (paginationMethods) {
 //初始化自定义的小圆点
getCustomPagination(paginationMethods);
} else {
 // 根据pc mobile 初始化小圆点或者分页按钮

isPc ? initPCGalleryPagination() : initMobileGalleryPagination();}

}
preventHrefLink();

处理card上的a标签,滑动不能点击

  var preventHrefLink = function() {
    hrefAs.on('click'function(e) {
      if (isScrollProgress) {
        e.preventDefault();
        return false;
      }
    });
  };

好的,那么接下来,我们就开始按照上面的伪代码,丰满每一个小的步骤。

4.1 无限循环处理

无限滚动,实际利用了一个视觉的障眼法。按照以下步骤实现:

  • 将第一张,拷贝到最后一个位置,最后一个张内容,拷贝到第一个位置。例如下面的卡片。
  • dom准备好后,将滚动区域向前移动一个card距离(每次滚动的的间距)
  • 注册滚动结束时的事件。滚动可以向前滚动,可以向后滚动。如果向前滚动到最前面的一张(card3备份,索引为0),那么将其索引设置为3(card3原图)。并在滚动结束时滚动到card3原图。因为滚动的时候使用了动画,那么在滚动到card3原图时,不使用动画,直接跳转,视觉上和card3备份图内容一致,感觉不到任何差异,就完成了无缝切换的效果。当然,滚动到最后一张图也是类似操作,当最后一张card1复制 滚动结束时,将索引设置为Card1,并消除动画,移动到card1原图,实现无缝切换。

1621818371.png
到此,无限滚动处理完成,相关代码如下:

 /**
   * 无限循环gallery, 初始化时,将第一个添加到最后,将最后一个添加到第一个
   */
  var infiniteScrollDomInit = function() {
    
    var children = parent.find(childCls);

    // 获取第一页元素和最后一页元素
    var preToEndDom = children.slice(0, paginationStep);
    var endToPreDom = children.slice(paginationStep * (childCount - 1), paginationStep * (childCount - 1) + 2);

    // endToPreDom 如果不够一页内容,则使用li填充
    var endPageCount = endToPreDom.length;
    if (endPageCount < paginationStep) {

      // 最后一页数量只有一条,但是每一页需要显示paginationStep,则现在父组件末尾添加一些空的占位符
      for (var i = endPageCount; i < paginationStep; i++ ) {
        customInitDom = customInitDom || '<li></li>';
        parent.append($(customInitDom));
      }
      endToPreDom = parent.find(childCls).slice(paginationStep * (childCount - 1), paginationStep * (childCount - 1) + 2);
    }
    
    // 添加到最前面和最后面
    parent.prepend($(endToPreDom.clone()));
    parent.append($(preToEndDom.clone()));
    parent.css('marginLeft', -(scrollStep) + getUnit());

    // 更新a标签(添加的dom也需要追加)
    hrefAs = parent.find('a');
  }



  /**
   * 当动画滚动结束后,将isScrollProgress设置为false,表示滚动结束
   * - 当滚动到最前面或者最后面,初始化index索引,并将其滚动到与其内容相同的card上
   */
  var initTransitionend = function() {
    parent.on('transitionend'function() {


      // 移动完成,将表示设置为false
      isScrollProgress = false;

      // 如果滑动到最最后面,索引修改为 第一个
      if (infiniteCurrentIndex == childCount) {
        infiniteCurrentIndex = 0;
        move(infiniteCurrentIndex, 0);
      }
  
      // 如果滑动到最前面,索引修改为 最后一个
      if (infiniteCurrentIndex < 0) {
        infiniteCurrentIndex = childCount - 1;
        move(infiniteCurrentIndex, 0);
      }
    })
  }

4.2 给slider注册滑动事件

现在,我们需要给Slider注册上滚动事件,让我们手动滑动时,能够进行滚动。当然,如果你是希望自动播放,那么你可以通过setInterval等相关的操作实现。
我们上面在定义参数时,isScrollPrgress就是表示,是否在滚动中。滚动中就不进行第二次滚动触发。不管是PC/Mobile都是一样的。
我们还需要注意一点:滚动时,如果滚动的角度小(例如是纵向滑动,就不应该滚动),那么避免滚动。

  • Mobile 滑动效果,通过 touchstart, touchmove, touched来实现。
     // -------手机滑动效果 start
    parent.on('touchstart'function(e) {
      // 如果在移动中,不再进行下一次移动
      if (isScrollProgress) {
        return;
      }
      var touch = e.originalEvent.targetTouches[0];
      startPosition = {
          x: touch.clientX,
          y: touch.clientY
      }
      isTouchStartFirst = 1;

      parent.on('touchmove'function(e) {
        isScrollProgress = true;
        var touch = e.targetTouches[0];
        endPosition = {
            x: touch.clientX,
            y: touch.clientY
        };
        deltaX = endPosition.x - startPosition.x;
        deltaY = endPosition.y - startPosition.y;
        //  只有刚开始的touchstart,才去判断滑动的方向
        if(isTouchStartFirst === 1){
            isScroll = (Math.abs(deltaX)  * 1.3Math.abs(deltaY)) > 0 ? 1 : 0;
        }
        isTouchStartFirst++;
        //  isScrolling为0时,表示纵向滑动,1为横向滑动
        if (isScroll === 1) {
          e.preventDefault();
          if (deltaX !== 0 && isInfiniteScroll) {
            mouseMoveTransation(deltaX);
          }
        }
      });
      parent.on('touchend'function(){
        if ((Math.abs(deltaY) > 10 && Math.abs(deltaX) < 10) || isScroll === 0) {
            return;
        }
        if(deltaX < 0) {
          movePre();
        } else if(deltaX > 0) {
          moveNext();
        }

        parent.off('touchmove');
        parent.off('touchend');
      });
    });
    // --------Mobile滑动效果 end
  • PC 滑动效果,通过 mousedown, mousemove, mouseup来实现。
    parent.on('mousedown'function (ev) {
      // 如果在移动中,不再进行下一次移动
      if (isScrollProgress) {
        return;
      }
      ev.preventDefault();
      ev.stopPropagation();
      ev.cancelable = false;
      startPosition = {
          x: ev.pageX
      }
      $("body").on('mousemove'function(e) {
        isScrollProgress = true;
          endPosition = {
              x: e.pageX,
          };
          deltaX = endPosition.x - startPosition.x;
          if (deltaX !== 0 && isInfiniteScroll) {
            mouseMoveTransation(deltaX);
          }
      });

      $("body").on('mouseup'function() {
        if(deltaX < 0) {
          movePre();
        } else if(deltaX > 0) {
          moveNext();
        }

        $("body").off('mousemove');
        $("body").off('mouseup');
      });
    });

4.3 根据拖拽移动

加入我们开始滑动,但是鼠标左右拖拽,那么此时将card随着鼠标的位置进行移动,这样会显得我们的slider比较活跃。当然,该操作是在 mousemove/touchmove时触发的。

 var mouseMoveTransation =  function(mouseMoveWidth) {
    mouseMoveWidth = isPc ? ( mouseMoveWidth / parent.width() * 100) : mouseMoveWidth / 100;
    var transtationWidth = tranlateX + mouseMoveWidth;
    parent.css('transform''translate3d(' + transtationWidth + getUnit()  + ', 0px, 0px)');
    parent.css('transitionDuration''0s');
  }

4.4 移动处理

上面的准备操作依据完成了,movePremoveNext还是一个空壳。 那么下面开始直接实现移动效果。

每次移动后,进行了分页刷新 reloadPagination,这里先知道就可以了,分页的方法放在最后。

/**
   * 计算滚动距离
   * @param{*}current 当前索引
   */
vargetScrollWidth = function(current) {
returnscrollStep * current;
  }

vargetUnit = function() {
// var unit = isPc ? '%' : '%';
return'%';
  }

/**
   * 移动一个图片
   * @param{*}current 当前选中页面
   * @param{*}transitionDuration 滑动消费时间
   */
varmove = function(current, transitionDuration) {
tranlateX = -getScrollWidth(current);
parent.css('transform''translate3d(' + tranlateX + getUnit()  + ', 0px, 0px)');
varnewTransitionDuration = (transitionDuration === undefined || transitionDuration === null) ? 0.6 : transitionDuration;
parent.css('transitionDuration', (parseFloat(newTransitionDuration) * paginationStep) + 's');
reloadPagination();
  };

上面的方法,是移动一个图片,通过css的属性实现的。对应无限滚动和普通滚动都是一致的。那么接下来我们就处理movePremoveNext方法。

/**
   * 滑动方式左移动
   */
  var movePre = function() {
    isInfiniteScroll ? infiniteMovePre() : finiteMovePre();
  };

  /**
   * 滑动方式右移动
   */
  var moveNext = function() {
    isInfiniteScroll ? infiniteMoveNext() : finiteMoveNext();
  } 

普通滑动事件处理

  /**
   * 向左边移动
   */
  var finiteMovePre = function() {
    if (currentIndex >= childCount - 1) {
      isScrollProgress = false;
      return;
    }
    currentIndex++;
    move(currentIndex);
  }
  
  /**
   * 向右边移动
   */
  var finiteMoveNext = function() {
    if (currentIndex <= 0) {
      isScrollProgress = false;
        return;
    }
    currentIndex--;
    move(currentIndex);
  }

无限滚动滑动事件处理

/** 
   * 无限循环向左移动
   * curentIndex 用于显示pagination,因此保持更新
  */
  var infiniteMovePre = function() {

    // 处理pagination 的index,逻辑保持不变
    if (currentIndex >= childCount - 1) {
      currentIndex = 0;
    } else {
      currentIndex++;
    }

    infiniteCurrentIndex++;
    move(infiniteCurrentIndex);
  }
  
  /**
   * 无限循环向右边移动
   * curentIndex 用于显示pagination,因此保持更新
   */
  var infiniteMoveNext = function() {
    if (currentIndex <= 0) {
      currentIndex = childCount - 1;
    } else {
      currentIndex--;
    }

    infiniteCurrentIndex--;
    move(infiniteCurrentIndex);
  }

按钮方式点击分页

  /**
   * 通过按钮左移动
   */
  var clickPreMove = function() {
    if (!isScrollProgress) {
      isScrollProgress = true;
      movePre();
    }
  };
  /**
   * 通过按钮右移动
   */
  var clickNextMove = function() {
    if (!isScrollProgress) {
      isScrollProgress = true;
      moveNext();
    }
  }  

4.5 分页处理

这里,我选择了2中分页方式,一种小圆点方式,一种是按钮方式。小圆点方式可以用于PC,也可以用于Mobile。而按钮建议用于PC.

小圆点分页

   var initMobileGalleryPagination = function() {
    var pagination = '<div class="gallery-pagination-circle">${items}</div>', items = '';
    for(var i = 0; i < childCount; i++) {
      let spanDom = i === 0 
        ? '<span class="select-span" indexValue="' + i + '" ></span>' 
        : '<span indexValue="' + i + '"></span>';
      items += spanDom;
    }
    pagination = pagination.replace('${items}', items);
    parent.after(pagination);
    
    // pagination 注册点击事件
    var paginationDom = parent.next()[0];
    if (paginationDom) {
      $(paginationDom).on('click''span'function(e) {
        var index = $(e.target).attr('indexValue');
        currentIndex = index;
        infiniteCurrentIndex = index;
        move(index);
      })
    }
  }

按钮分页

  var initPreAndNextPagination = function() {
    
    var prePagination = $('<span class="pre-pagination-btn"></span>');
    var nextPagination = $('<span class="next-pagination-btn"></span>');

    prePagination.on('click', clickNextMove);
    nextPagination.on('click', clickPreMove);

    // 在父级的父级身上添加按钮
    var wrapper = parent.parent();
    wrapper.parent().css('position''relative');

    wrapper.siblings('.pre-pagination-btn').remove()
    wrapper.siblings('.next-pagination-btn').remove();

    wrapper.after(nextPagination);
    wrapper.after(prePagination);
  }

**通过名字,自定义分页 **

 /**
   * 自定义分页:分页有很多种,当想自定义不同类型的分页,可以传递方法名称
   * 
   * @param {*} paginationArr 自定义方法名称字符串 [initMobileGalleryPagination, initPCGalleryPagination, initPreAndNextPagination]
   */
  var getCustomPagination = function(paginationArr) {
    var originPagiantions = {
      initMobileGalleryPagination: initMobileGalleryPagination,
      initPCGalleryPagination: initPCGalleryPagination,
      initPreAndNextPagination: initPreAndNextPagination,
    };
    paginationArr.map(function(method) {
      originPagiantions[method] && originPagiantions[method]();
    });
  }

不同分页方式分页reload 该方法在move函数中使用到的,每次通过说移动结束,需要手动更新分页显示。

var reloadPagination = function() {
    var paginationDom = parent.next();

    // PC 页码方式分页
    var currentPage = paginationDom.find('.current-page');
    if (currentPage.length > 0) {
      currentPage.text(currentIndex + 1);
    }

    // 底部Circle分页 点击分页后,更新页码

    if (paginationDom.hasClass('gallery-pagination-circle')) {
      var circleSpans = paginationDom.children('span');
      circleSpans.removeClass('select-span');
      circleSpans.eq(currentIndex).addClass('select-span');
    }
  }

5. 窗口调整Resize初始化

用户在使用的过程中,肯定会遇到拖拽,那么在拖拽的时候,我们不仅仅是css进行适应,还应该有JS注册事件。在上面我们已经在初始化中清除了所有的事件,方便resize时,重新初始化。但是我们可能做得更好,在进行resize时,如果是PC样式,那么就不在重复渲染。

我们公共resize和节流函数进行配合达到这个目的。

定义全局变量,通过 windowIsResize ,我们可以直到窗口是否移动。

window.windowIsResize = false;
; (function ($) {
  // 上一次记录
  var preWindowWidth = $(window).width();
  var preDevice = iSPc();



  $(window).resize(throttle(function () {

    // resize 获取
    var currentWindowWidth = $(window).width();
    var currentDdevice = iSPc();

    // 宽度相等或者设备相等,直接设置为false
    if (currentWindowWidth === preWindowWidth || currentDdevice === preDevice) {
      window.windowIsResize = false;
    } else {
      window.windowIsResize = true;
    }

    // 更新
    preWindowWidth = currentWindowWidth;
    preDevice = currentDdevice;
  }, 200));
})($);

通过设备宽度判断,是否重新渲染slider

;(function($, document) {
  resizeGallery();
  $(window).resize(throttle(function() {
    // 宽度变化且设备变化,重新初始化gallery
    if (window.windowIsResize) {
      

    var slider= new Slider(gallery, children, scrollStep, 0, childCls, isInfiniteScroll, paginationMethods, paginationStep, customInitDom);
    slider.initMobileOrPcGallery();

    }
  }, 200));
})($);

至此,Slider组件已经完成,肯定会存在一些小的问题,大家事件了可以告知我~~