对于竖向无缝滚动组件的构思

1,597 阅读10分钟

image.png

对于我们前端项目,经常有一种需求,就是有一个信息组件,当信息内容过多时,需要滚动展示,尤其是在数据大屏项目。

image.png

如上图,但是对于不采用无缝滚动时,容易产生割裂感,即当信息滚动到底部时,会进入新一轮的滚动(即从头开始),所以在那一帧,整个组件会抽一下,不美观。

所以就需要使用无缝滚动对其效果进行优化,

接下来为大家介绍两种无缝滚动实现思路

复制首屏dom到底部用于滚动切换

首先对于常见的无缝轮播(无缝滚动)思路,都是复制对应的dom数量,将其添加的最后一个元素尾部,用于做滚动跳转。

image.png

我们用一张图来描述,对于上图来说,外层大矩形是我们的容器矩形,有固定的高度,内部矩形则是我们需要滚动的容器,最里层小矩形则是我们的信息,

可以看到,目前矩形首屏最多只能容纳4个信息块,所以我们可以先复制首屏数量的dom,添加到其尾部,其上图的 信息 -- copy矩形,

先介绍下,如果不复制dom的话,对于产生组件抖动感觉有何而来,也算是用图来描述一下上面引出的问题

对于我们的内层滚动容器,需要不断的上移,达到我们的动画效果,但是当它移动到第四个信息的位置时,此时底下没有其他信息,

如果想要继续重新滚动则需要回到最开始的位置,所以会从原本位置在第四个信息,一下子切换到第一个,大家很容易就可以想象到那个效果,会出现明显的切换的样子,不太雅观

这时,我们就可以开始滚动了,当滚动的第四个信息后,会出现如下图的效果

image.png

此时滚动原始dom数量的最后一个,会发现,到底后,其实底下还有我们复制的dom兜底,所以不会出现下面内容一片白的效果。

此时我们只需要在把内层滚动容器的位置再设置成第一张图片那样即可。

接下来为大家介绍下具体的实现思路

准备html结构

我们先写好一些样式,该html效果具体如下图

image.png

    <!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <style type="text/css">
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        background-color: #795548;
      }

      .header {
        margin: 100px auto 0;
        width: 500px;
        display: flex;
        padding: 0 9px;
        align-items: center;
        height: 36px;
        background-color: rgba(33, 231, 209, 0.3);
        box-sizing: border-box;
      }

      .header span {
        width: 25%;
        color: #21e7d1;
        font-size: 14px;
        font-weight: bold;
      }

      .resultList {
        position: relative;
        margin: 0px auto;
        width: 500px;
        height: 320px;
        overflow: hidden;
      }
      .resultBox {
        position: relative;
        overflow: hidden;
        scroll-behavior: smooth;
      }

      .resultItem {
        /* position: absolute; */
        margin-top: 10px;
        display: flex;
        padding: 0 9px;
        align-items: center;
        height: 36px;
        background-color: rgba(33, 231, 209, 0.15);
        border: 1px solid rgba(33, 231, 209, 0.15);
        color: white;
        font-size: 14px;
        scroll-behavior: smooth;
      }
      .resultItem .money {
        color: #21e7d1;
        font-weight: bold;
      }
      .resultItem span {
        width: 25%;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    </style>
  </head>
  <body>
    <div class="header">
      <span>城市名称</span>
    </div>
    <div class="resultList" id="resultList">
      <div class="resultBox">
        <div class="resultItem" data-i="0">
          <span>厦门</span>
        </div>
        <div class="resultItem" data-i="1">
          <span>福州</span>
        </div>
        <div class="resultItem" data-i="2">
          <span>漳州公司</span>
        </div>
        <div class="resultItem" data-i="3">
          <span>北京</span>
        </div>
        <div class="resultItem" data-i="4">
          <span>上海</span>
        </div>
        <div class="resultItem" data-i="5">
          <span>天津</span>
        </div>
        <div class="resultItem" data-i="6">
          <span>内蒙古</span>
        </div>
        <div class="resultItem" data-i="7">
          <span>嘎达</span>
        </div>
        <div class="resultItem" data-i="8">
          <span>雅马哈</span>
        </div>
      </div>
    </div>
    <script language="javascript">
    </script>
  </body>
</html>

实现滚动逻辑

简单创建一个 Carousel 类,用于实现我们的无缝滚动效果。

初始化一些后续需要使用的参数

class Carousel {
    constructor({el, duration = 100, step = 5}) {
      this.el = document.getElementById(el);
      this.scrollEl = this.el.firstElementChild; // 滚动容器
      this.elHeight = 0; // 外层容器高度
      this.validDistance = 0; // 有效的滚动距离
      this.timer = null;
      this.duration = duration; // 滚动时间
      this.step = step; // 滚动步长
      this.nodeHeight = 0; // 节点高度
      this.start = false; // 记录是否开始
      this.resizeTimer = null;
      this.nodeArray = []; // 记录复制的dom
      this.resizeObserver = null;
      this.moveDistance = 0; // 已移动的距离
      this.first = true;
    }
}

类和参数定义后,我们来实现它的init方法

对于init方法,我们需要做几件事

  1. 判断this.el是否是元素,如果不是元素直接终止
  2. 开始计算高度(外层容器高度),可滚动的高度(滚动容易高度 - 外层容器高度),每个信息块的高度(信息块高度 + margin)
  3. 判断可滚动高度是否小于0,即元素数量不足,无需处理后续逻辑,终止
  4. 复制首屏dom到外部
  5. 事件绑定(处理鼠标引入移出的情况)
  6. 开始轮播

首先定义init方法

class Carousel {
    init() {
      this.start = false;
      if (!this.el) {
        return console.error('未获取到相关dom元素,请检查id值是否传入正确');
      }
      this.calcHeight();
      if (this.validDistance <= 0) {
        return;
      }
      this.start = true;
      this.copyNode();
      this.bindEvent();
      this.autoPlay();
    }
}

接下来介绍每一方法内需要做什么事

calcHeight 计算元素高度

计算高度(外层容器高度),可滚动的高度(滚动容易高度 - 外层容器高度),每个信息块的高度(信息块高度 + margin)

具体可以参照以下代码

    calcHeight() {
      this.elHeight = this.el.offsetHeight; // 计算外层容器高度
      this.validDistance = this.scrollEl.scrollHeight - this.elHeight; // 可滚动距离
      let scrollElFirstChildrenElement =
        this.scrollEl && this.scrollEl.children[0]; // 获取第一个信息元素
      this.nodeHeight =
        scrollElFirstChildrenElement &&
        scrollElFirstChildrenElement.offsetHeight +
          scrollElFirstChildrenElement.offsetTop;
    }

copyNode 复制节点

该方法用于复制外层容易高度范围内的dom数量,追加到尾部

这里有一个优化点,就是追加元素的时候,不要一个一个往滚动容器里加,最好先创建的文档碎片,先往文档碎片里加,后续统一再往滚动容器里加

  copyNode() {
      this.nodeArray = [];
      let node;
      // 倘若轮播失效,请修改 floor 为 ceil 试试看
      const maxNodeLen = Math.ceil(this.elHeight / this.nodeHeight);
      const fragment = document.createDocumentFragment();
      for (let i = 0; i < maxNodeLen; i++) {
        node = this.scrollEl.children[i].cloneNode(true);
        fragment.appendChild(node);
        this.nodeArray[i] = node;
      }
      this.scrollEl.appendChild(fragment);
      this.calcHeight();
    }

bindEvent 事件绑定

主要绑定鼠标移入和移出,还有滚轮滚动事件,最后一个屏幕缩放事件

鼠标移入时,滚动需要停止

鼠标移出时,滚动继续

滚轮滚动,移除默认事件

屏幕缩放,重新计算高度

    bindEvent() {
      this.el.addEventListener(
        'mouseenter',
        this.handleMouseEnter.bind(this)
      );
      this.el.addEventListener(
        'mouseleave',
        this.handleMouseLeave.bind(this)
      );
      this.el.addEventListener('mousewheel', this.handleMouseWheel);
      const that = this;

      this.resizeObserver = new ResizeObserver((entries) => {
        if (entries[0].contentRect.width) {
          that.handleResize.apply(that);
        }
      });
      this.resizeObserver.observe(document.body);
    }
    
    handleMouseEnter() {
      clearInterval(this.timer);
      this.timer = null;
    }

    handleMouseLeave() {
      if (!this.timer) {
        this.autoPlay();
      }
    }

    handleMouseWheel(e) {
      e.preventDefault();
    }

    handleResize() {
      if (this.resizeTimer) {
        clearTimeout(this.resizeTimer);
      }

      this.resizeTimer = setTimeout(() => {
        this.calcHeight();
      }, this.duration);
    }

核心 autoPlay 滚动事件

autoPlay事件中会调用run方法,run方法就是实现滚动的核心逻辑

实现思路:

  1. 根据步长我们开始移动,为了优化移动效果,我们又再初始化时添加了一个过度效果
  2. 每次滚动减去对应的步长,
  3. 当滚动到底部时,有一个注意点,需要设置取消其过度效果,要不然当改变其滚动位置时,也会有一个过度,看起来很奇怪
  4. 当滚动到底部时,会偏移一些值,需要对元素进行取余操作,然后再设置回去,这样会更加流畅
    autoPlay() {
      this.timer = setInterval(this.run.bind(this), this.duration);
    }

    run() {
      if (this.first) {
        this.scrollEl.style.transition = `${this.duration / 1000}s linear`;
        this.first = false;
      }

      this.moveDistance -= this.step;
      // 说明到底了
      if (this.moveDistance <= -(this.validDistance + this.step)) {
        this.scrollEl.style.transition = 'none';
        // 滚动到底部,余下多少,下次重新开始,就设置多少的偏移,联顺
        this.moveDistance = this.moveDistance % this.nodeHeight; 
        this.first = true;
      }
      this.scrollEl.style.transform = `translate3d(0px, ${this.moveDistance}px, 0px)`;
    }

具体效果

20240529_114403.gif

完整代码

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <style type="text/css">
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        background-color: #795548;
      }

      .header {
        margin: 100px auto 0;
        width: 500px;
        display: flex;
        padding: 0 9px;
        align-items: center;
        height: 36px;
        background-color: rgba(33, 231, 209, 0.3);
        box-sizing: border-box;
      }

      .header span {
        width: 25%;
        color: #21e7d1;
        font-size: 14px;
        font-weight: bold;
      }

      .resultList {
        position: relative;
        margin: 0px auto;
        width: 500px;
        height: 320px;
        overflow: hidden;
      }
      .resultBox {
        position: relative;
        overflow: hidden;
        scroll-behavior: smooth;
      }

      .resultItem {
        /* position: absolute; */
        margin-top: 10px;
        display: flex;
        padding: 0 9px;
        align-items: center;
        height: 36px;
        background-color: rgba(33, 231, 209, 0.15);
        border: 1px solid rgba(33, 231, 209, 0.15);
        color: white;
        font-size: 14px;
        scroll-behavior: smooth;
      }
      .resultItem .money {
        color: #21e7d1;
        font-weight: bold;
      }
      .resultItem span {
        width: 25%;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    </style>
  </head>
  <body>
    <div class="header">
      <span>城市名称</span>
    </div>
    <div class="resultList" id="resultList">
      <div class="resultBox">
        <div class="resultItem" data-i="0">
          <span>厦门</span>
        </div>
        <div class="resultItem" data-i="1">
          <span>福州</span>
        </div>
        <div class="resultItem" data-i="2">
          <span>漳州公司</span>
        </div>
        <div class="resultItem" data-i="3">
          <span>北京</span>
        </div>
        <div class="resultItem" data-i="4">
          <span>上海</span>
        </div>
        <div class="resultItem" data-i="5">
          <span>天津</span>
        </div>
        <div class="resultItem" data-i="6">
          <span>内蒙古</span>
        </div>
        <div class="resultItem" data-i="7">
          <span>嘎达</span>
        </div>
        <div class="resultItem" data-i="8">
          <span>雅马哈</span>
        </div>
      </div>
    </div>
    <script language="javascript">
      /**
       * 用于做竖直屏幕的无缝滚动
       */
      class Carousel {
        constructor({ el, duration = 100, step = 5 }) {
          this.el = document.getElementById(el);
          this.scrollEl = this.el.firstElementChild; // 滚动容器
          this.elHeight = 0; // 容器高度
          this.validDistance = 0; // 有效的滚动距离
          this.timer = null;
          this.duration = duration; // 滚动时间
          this.step = step; // 滚动步长
          this.nodeHeight = 0; // 节点高度
          this.start = false; // 记录是否开始
          this.resizeTimer = null;
          this.nodeArray = [];
          this.resizeObserver = null;
          this.moveDistance = 0;
          this.first = true;
        }

        init() {
          this.start = false;
          if (!this.el) {
            return console.error('未获取到相关dom元素,请检查id值是否传入正确');
          }
          this.calcHeight();
          if (this.validDistance <= 0) {
            return;
          }
          this.start = true;
          this.copyNode();
          this.bindEvent();
          this.autoPlay();
        }

        calcHeight() {
          this.elHeight = this.el.offsetHeight;
          this.validDistance = this.scrollEl.scrollHeight - this.elHeight;
          let scrollElFirstChildrenElement =
            this.scrollEl && this.scrollEl.children[0];
          this.nodeHeight =
            scrollElFirstChildrenElement &&
            scrollElFirstChildrenElement.offsetHeight +
              scrollElFirstChildrenElement.offsetTop;
        }

        copyNode() {
          this.nodeArray = [];
          let node;
          // 倘若轮播失效,请修改 floor 为 ceil 试试看
          const maxNodeLen = Math.ceil(this.elHeight / this.nodeHeight);
          const fragment = document.createDocumentFragment();
          for (let i = 0; i < maxNodeLen; i++) {
            node = this.scrollEl.children[i].cloneNode(true);
            fragment.appendChild(node);
            this.nodeArray[i] = node;
          }
          this.scrollEl.appendChild(fragment);
          this.calcHeight();
        }

        bindEvent() {
          this.el.addEventListener(
            'mouseenter',
            this.handleMouseEnter.bind(this)
          );
          this.el.addEventListener(
            'mouseleave',
            this.handleMouseLeave.bind(this)
          );
          this.el.addEventListener('mousewheel', this.handleMouseWheel);
          const that = this;

          this.resizeObserver = new ResizeObserver((entries) => {
            if (entries[0].contentRect.width) {
              that.handleResize.apply(that);
            }
          });
          this.resizeObserver.observe(document.body);
        }

        handleMouseEnter() {
          clearInterval(this.timer);
          this.timer = null;
        }

        handleMouseLeave() {
          if (!this.timer) {
            this.autoPlay();
          }
        }

        handleMouseWheel(e) {
          e.preventDefault();
        }

        handleResize() {
          if (this.resizeTimer) {
            clearTimeout(this.resizeTimer);
          }

          this.resizeTimer = setTimeout(() => {
            this.calcHeight();
          }, this.duration);
        }

        autoPlay() {
          this.timer = setInterval(this.run.bind(this), this.duration);
        }

        run() {
          if (this.first) {
            this.scrollEl.style.transition = `${this.duration / 1000}s linear`;
            this.first = false;
          }

          this.moveDistance -= this.step;
          // 说明到底了
          if (this.moveDistance <= -(this.validDistance + this.step)) {
            this.scrollEl.style.transition = 'none';
            // 滚动到底部,余下多少,下次重新开始,就设置多少的偏移,联顺
            this.moveDistance = this.moveDistance % this.nodeHeight; 
            this.first = true;
          }
          this.scrollEl.style.transform = `translate3d(0px, ${this.moveDistance}px, 0px)`;
        }

        removeOtherNodes() {
          this.nodeArray.map((node) => {
            this.scrollEl.removeChild(node);
          });
          this.nodeArray = [];
        }

        unbind() {
          this.el.removeEventListener('mouseenter', this.handleMouseEnter);
          this.el.removeEventListener('mouseleave', this.handleMouseLeave);
          this.el.removeEventListener('mousewheel', this.handleMouseWheel);
          clearInterval(this.timer);
          clearTimeout(this.resizeTimer);
          this.timer = null;
          this.resizeTimer = null;
          this.resizeObserver.unobserve(document.body);
          this.removeOtherNodes();
        }
      }

      new Carousel({
        el: 'resultList',
        step: 2,
      }).init();
    </script>
  </body>
</html>

滚动一个处理一个

第二种思路会比第一种简单点。

我们不需要复制首屏dom的数量到尾部。

只需要再滚动时,计算滚去的高度是否等于一个元素的高度,如果等于就将当前第一个元素添加到尾部

如下图所示

image.png

具体逻辑步骤分析如下:

  1. 先记录一个信息的高度,及它的offsetHeight
  2. 内层滚动容器开始向上滚动
  3. 当滚动的高度超过 一个信息的高度 时,把当前第一个元素追加到内层滚动容器的底部
  4. 滚动高度重置为0,开始继续第二步

核心逻辑 scroll 方法

轮询滚动目前采用的是 requestAnimationFrame ,相对于 setTimeout, setInterval,性能更佳

思路:通过对滚动高度的判断,是否滚动了一个信息的位置,是的话,就把当前头部元素追加到尾部,并设置其滚动高度为0,循环往复

const scroll = () => {
    cancelAnimationFrame(timeAnimate);
    timeAnimate = requestAnimationFrame(function fn() {
      // 说明滚动走了一个元素
      if (Math.abs(step) >= nodeHeight) {
        cancelAnimationFrame(timeAnimate);
        step = 0;
        resultBox.style.transform = `translate3d(0px, ${0}px,  0px)`;
        // 把元素加入到尾部
        resultBox.appendChild(resultItems[0]);
        setTime();
      } else {
        step -= 1;
        resultBox.style.transform = `translate3d(0px, ${step}px,  0px)`;
        timeAnimate = requestAnimationFrame(fn);
      }
    });
  };

完整效果

20240529_141217.gif

具体代码

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <style type="text/css">
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        background-color: #795548;
      }

      .header {
        margin: 100px auto 0;
        width: 500px;
        display: flex;
        padding: 0 9px;
        align-items: center;
        height: 36px;
        background-color: rgba(33, 231, 209, 0.3);
        box-sizing: border-box;
      }

      .header span {
        width: 25%;
        color: #21e7d1;
        font-size: 14px;
        font-weight: bold;
      }

      .resultList {
        position: relative;
        margin: 0px auto;
        width: 500px;
        height: 320px;
        overflow: hidden;
      }
      .resultBox {
        /* margin-top: -32px; */
      }

      .resultItem {
        /* position: absolute; */
        padding-top: 10px;
      }
      .inner {
        display: flex;
        padding: 0 9px;
        align-items: center;
        height: 36px;
        background-color: rgba(33, 231, 209, 0.15);
        border: 1px solid rgba(33, 231, 209, 0.15);
        color: white;
        font-size: 14px;
        overflow: hidden;
      }

      .inner .money {
        color: #21e7d1;
        font-weight: bold;
      }
      .inner span {
        width: 25%;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    </style>
  </head>
  <body>
    <div class="header">
      <span>供应商名称</span>
      <span>所属区县</span>
      <span>融资类型</span>
      <span>融资金额(元)</span>
      <span>所属银行</span>
    </div>
    <div class="resultList" id="resultList">
      <div class="resultBox">
        <div class="resultItem" data-i="0">
          <div class="inner">
            <span>福建厦门公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>中国农业银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="1">
          <div class="inner">
            <span>福建福州公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>工商银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="2">
          <div class="inner">
            <span>福建漳州公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>建设银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="3">
          <div class="inner">
            <span>福建龙岩公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>新业银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="4">
          <div class="inner">
            <span>福建南平公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>农村信用社</span>
          </div>
        </div>
        <div class="resultItem" data-i="5">
          <div class="inner">
            <span>福建松溪公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>建设银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="6">
          <div class="inner">
            <span>江苏徐州公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>邮政储蓄</span>
          </div>
        </div>
        <div class="resultItem" data-i="7">
          <div class="inner">
            <span>浙江宁波公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>招商银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="8">
          <div class="inner">
            <span>四川成都公司</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>浦发银行</span>
          </div>
        </div>
        <div class="resultItem" data-i="9">
          <div class="inner">
            <span>1111</span>
            <span>福建省本级</span>
            <span>采购合同融资111</span>
            <span class="money">649.000.00</span>
            <span>浦发银行</span>
          </div>
        </div>
      </div>
    </div>
    <script language="javascript">
      const resultList = document.getElementById('resultList');
      const resultBox = resultList.firstElementChild;
      const resultItems = resultBox.children;
      const nodeHeight = resultItems[0].offsetHeight;

      let timeAnimate = null;
      let time = null;
      let step = 0;

      const setTime = () => {
        if (time) clearTimeout(time);
        time = setTimeout(() => {
          scroll();
        }, 2000);
      };

      const scroll = () => {
        cancelAnimationFrame(timeAnimate);
        timeAnimate = requestAnimationFrame(function fn() {
          // 说明滚动走了一个元素
          if (Math.abs(step) >= nodeHeight) {
            cancelAnimationFrame(timeAnimate);
            step = 0;
            resultBox.style.transform = `translate3d(0px, ${0}px,  0px)`;
            // 把元素加入到尾部
            resultBox.appendChild(resultItems[0]);
            setTime();
          } else {
            step -= 1;
            resultBox.style.transform = `translate3d(0px, ${step}px,  0px)`;
            timeAnimate = requestAnimationFrame(fn);
          }
        });
      };

      resultList.addEventListener('mouseenter', function () {
        clearTimeout(time);
        cancelAnimationFrame(timeAnimate);
      });

      resultList.addEventListener('mouseleave', function () {
        setTime();
      });

      setTime();
    </script>
  </body>
</html>

其他思路,采用第三方库

例如 fusion 的 Slider组件

image.png

或者基于Swiper实现一个,都可以。

结语

以上为文章的全部内容,希望大家看好喝好!!