Skip to content

列表无缝滚动

列表无缝滚动相关处理再前端也是经常需要得,一般会需要以下几个功能点:

    1. 鼠标悬停。
    1. 暴漏重置滚动。
    1. 能进行拖动。
    1. 自定义滚动速度。
    1. 循环次数定义。

以上算是一个完整常见的列表无缝滚动,当然可能有些其他需求。

功能效果如下:

01

市场相关插件调研对比

vue版本插件vue-seamless-scrollJQ无缝滚动

说下JQ版本:

  • JQ这个插件的话他支持也是蛮丰富的,但是缺点
  1. 是使用JQ必须要在现在的框架中引入。
  2. 应该是出现较早的缘故,他是使用setTimeout定时器去完成该无缝滚动的,缺点就是再非全文字下掉帧, 一顿一顿的。

说下VUE这个版本:

  • VUE版本的插件其实这个能满足大多产品的需求了,动画也是使用requestAnimationFrame动画帧做的,配置很丰富,但是不满足我们的产品需求,需要能拖动

实现第一版本

因为产品再快速迭代,还不想引入JQ就实现了一版本基础可拖动的无缝滚动组件:

注意点如下:

    1. 需要多个slot去做滚动。
    1. 再鼠标需要拖动摁下时,需要停止滚动。
    1. 使用new ResizeObserver 监听容器变化。
vue
<template>
  <div
    class="marquee-container"
    @mouseover="onMouseOver"
    @mouseout="onMouseOut"
  >
    <div class="marquee-move" :style="moveStyle">
      <div class="marquee-move-item" :style="moveItemStyle">
        <slot />
      </div>
      <div class="marquee-move__clone" :style="cloneStyleTop">
        <slot />
      </div>
      <div class="marquee-move__clone" :style="cloneStyleBottom">
        <slot />
      </div>
    </div>
  </div>
</template>

<script>
const DIRECTION_TYPE = {
  LEFT: "left",
  UP: "up",
  DOWN: "down",
  RIGHT: "right",
};

export default {
  name: "VueMarqueeScroll",

  props: {
    direction: {
      type: String,
      required: false,
      default: "left", // 滚动方向,可选 left / right / up / down
    },

    loop: {
      type: Number,
      required: false,
      default: -1, // 循环次数,-1 为无限循环
    },

    scrollamount: {
      type: Number,
      required: false,
      default: 50, // 滚动速度,越大越快
    },

    drag: {
      type: Boolean,
      required: false,
      default: true, // 鼠标可拖动
    },

    circular: {
      type: Boolean,
      required: false,
      default: true, // 鼠标可拖动
    },

    hoverstop: {
      type: Boolean,
      required: false,
      default: true, // 鼠标悬停暂停
    },

    width: {
      type: String,
      required: false,
      default: "100%",
    },

    height: {
      type: String,
      required: false,
      default: "100%",
    },
  },

  data() {
    return {
      targetHeight: 0,
      targetWidth: 0,
      resizeObserver: null,
      time: null,
      moveValue: 0,
      MAX_VALUE: 0,
      MIN_VALUE: 0,
      isDragging: false,
      startX: 0,
      startY: 0,
      lastDiffX: 0,
      lastDiffY: 0,
    };
  },

  computed: {
    cloneStyleTop() {
      if (
        this.direction === DIRECTION_TYPE.DOWN ||
        this.direction === DIRECTION_TYPE.UP
      ) {
        return `top: 100%; bottom: auto; height: ${this.targetHeight}px;`;
      }
      return `left: 100%; right: auto; top: 0; width: ${this.targetWidth}px;`;
    },

    cloneStyleBottom() {
      if (
        this.direction === DIRECTION_TYPE.DOWN ||
        this.direction === DIRECTION_TYPE.UP
      ) {
        return `bottom: 100%; top: auto; height: ${this.targetHeight}px;`;
      }
      return `right: 100%; left: auto; top: 0;width: ${this.targetWidth}px;`;
    },

    moveItemStyle() {
      return `width: ${this.width};`;
    },

    moveStyle() {
      if (this.direction === DIRECTION_TYPE.UP) {
        return `top: ${this.moveValue}px; ${this.moveItemStyle}`;
      }

      if (this.direction === DIRECTION_TYPE.DOWN) {
        return `top: ${-this.moveValue}px; ${this.moveItemStyle}`;
      }

      if (this.direction === DIRECTION_TYPE.LEFT) {
        return `left: ${this.moveValue}px; ${this.moveItemStyle}`;
      }

      if (this.direction === DIRECTION_TYPE.RIGHT) {
        return `left: ${this.moveValue}px; ${this.moveItemStyle}`;
      }
      return "";
    },
  },

  mounted() {
    this.$nextTick(() => {
      this.resizeObserver = new ResizeObserver(this.getMoveRect);
      this.resizeObserver.observe(this.$el);
      this.$el.addEventListener("mousedown", this.startDrag);
      document.addEventListener("mousemove", this.moveDrag);
      document.addEventListener("mouseup", this.endDrag);
    });
  },

  beforeDestroy() {
    this.resizeObserver.unobserve(this.$el);
    this.$el.removeEventListener("mousedown", this.startDrag);
    document.removeEventListener("mousemove", this.moveDrag);
    document.removeEventListener("mouseup", this.endDrag);
  },

  methods: {
    getMoveRect() {
      const moveItem = this.$el.querySelector(".marquee-move-item");
      if (moveItem && moveItem.getBoundingClientRect) {
        const { width, height } = moveItem.getBoundingClientRect();
        this.targetHeight = height;
        this.targetWidth = width;

        if (this.targetHeight && this.targetWidth) {
          this.startMove();
        }
      }
    },

    onMouseOver() {
      this.endMove();
    },

    onMouseOut() {
      this.startMove();
    },

    startMove() {
      this.MAX_VALUE =
        this.direction === DIRECTION_TYPE.DOWN ||
        this.direction === DIRECTION_TYPE.UP
          ? this.targetHeight
          : this.targetWidth;
      this.MIN_VALUE = -this.MAX_VALUE;
      const addValue =
        this.direction === DIRECTION_TYPE.DOWN ||
        this.direction === DIRECTION_TYPE.RIGHT
          ? 2
          : -2;
      if (this.time) clearInterval(this.time);
      this.time = setInterval(() => {
        this.setMoveValue(addValue);
      }, 120 - this.scrollamount);
    },

    endMove() {
      if (this.time) {
        clearInterval(this.time);
      }
    },

    setMoveValue(addValue = 0) {
      const currentMoveValue = this.moveValue + addValue;
      if (
        currentMoveValue > this.MAX_VALUE ||
        currentMoveValue < this.MIN_VALUE
      ) {
        this.moveValue = addValue > 0 ? this.MIN_VALUE : this.MAX_VALUE;
      } else {
        this.moveValue = currentMoveValue;
      }
    },

    startDrag({ clientX: x1, clientY: y1 }) {
      this.isDragging = true;
      document.onselectstart = () => false;
      this.startX = x1;
      this.startY = y1;
    },

    moveDrag({ clientX: x2, clientY: y2 }) {
      if (this.isDragging) {
        const currentDiffX = x2 - this.startX;
        const currentDiffY = y2 - this.startY;
        const value =
          this.direction === DIRECTION_TYPE.DOWN ||
          this.direction === DIRECTION_TYPE.UP
            ? currentDiffY - this.lastDiffY
            : currentDiffX - this.lastDiffX;
        this.setMoveValue(value);
        this.lastDiffX = currentDiffX;
        this.lastDiffY = currentDiffY;
      }
    },

    endDrag() {
      document.onselectstart = () => true;
      this.isDragging = false;
      this.lastDiffX = 0;
      this.lastDiffY = 0;
    },
  },
};
</script>

<style lang="scss" scoped>
.marquee-container {
  position: relative;
  overflow: hidden;
  cursor: move;
  width: 100%;
  height: 100%;
  .marquee-move {
    width: 100%;
    position: absolute;
  }

  .marquee-move__clone {
    position: absolute;
    width: 100%;
  }
}
</style>

使用示例如下:

html
<vue-marquee-scroll v-if="data.length" direction="up">
  <div v-for="(item,idx) in data" :key="idx">111111</div>
</vue-marquee-scroll>

总结: 优点就是轻量级,引入即可以用。缺点:非全文字下动画不够流畅。

实现第二版本发包

使用requestAnimationFrame,以及vue组合式API + TS,实现一个精装修的组件,基础功能还是如上面,不同点如下:

    1. 支持合并window.requestAnimationFrame动画,在你的一个界面有多个区域需要滚动时,性能会好点。
    1. 动画流畅度UPUP。

入口VUE文件代码如下:

vue
<template>
  <div
    ref="containerRef"
    class="marquee-container"
    @mouseenter="onMouseenter"
    @mouseleave="onMouseleave"
  >
    <div
      :class="['marquee-move', `marquee-move__${direction}`]"
      :style="currentMoveStyle"
    >
      <div ref="elementRef" class="marquee-move-item">
        <slot />
      </div>
      <div
        v-if="!isOverflow"
        class="marquee-move__clone"
        :style="initStylePrefix"
      >
        <slot />
      </div>
      <div
        v-if="!isOverflow"
        class="marquee-move__clone"
        :style="initStyleSuffix"
      >
        <slot />
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  PropType,
  onMounted,
  onBeforeUnmount,
  ref,
  nextTick,
} from "@vue/composition-api";
import { Direction } from "./types/index";

// fix: ResizeObserver is not defined  框架不支持 暂时引入resize-observer-polyfill
import ResizeObserver from "resize-observer-polyfill";

import { useInitStyle, useMove, useDrag } from "./hooks/index";

export default defineComponent({
  name: "VueMarqueeScroll",

  props: {
    /**
     * 初始宽度
     * @value left right up down
     */
    direction: {
      type: String as PropType<Direction>,
      default: Direction.LEFT,
      required: false,
    },
    /**
     * 循环次数,-1 为无限循环
     */
    loop: {
      type: Number,
      required: false,
      default: -1,
    },

    /**
     * 滚动速度,越大越快
     */
    scrollamount: {
      type: Number,
      required: false,
      default: 50,
    },
    /**
     * 鼠标可拖动
     */
    drag: {
      type: Boolean,
      required: false,
      default: true,
    },
    /**
     * 鼠标悬停暂停
     */
    hoverStop: {
      type: Boolean,
      required: false,
      default: true,
    },

    /**
     * 是否合并动画,适合一个屏幕上多长使用该组件照成界面卡顿问题
     */
    isMergeAnimation: {
      type: Boolean,
      required: false,
      default: false,
    },
  },

  setup(props) {
    const elementRef = ref<null | HTMLElement>(null);
    const containerRef = ref<null | HTMLElement>(null);
    const isMouseenter = ref<boolean>(false);

    const {
      initStylePrefix,
      initStyleSuffix,
      setTargetValue,
      targetHeight,
      targetWidth,
    } = useInitStyle(props);

    const { getMoveInfo, start, move, end, currentMoveStyle, isOverflow } =
      useMove({
        direction: props.direction,
        element: elementRef,
        containerElement: containerRef,
        setTargetValue,
        targetHeight,
        targetWidth,
        scrollamount: props.scrollamount,
        isMergeAnimation: props.isMergeAnimation,
      });

    const { isDragging } = useDrag({
      direction: props.direction,
      element: containerRef,
      move,
      end,
      start,
      drag: props.drag,
      isMouseenter,
    });

    const onMouseleave = () => {
      isMouseenter.value = false;
      if (props.hoverStop && !isDragging.value) {
        start();
      }
    };

    const onMouseenter = () => {
      isMouseenter.value = true;
      if (props.hoverStop && !isDragging.value) {
        end();
      }
    };

    const resizeOberver = new ResizeObserver(getMoveInfo);

    onMounted(() => {
      nextTick(() => {
        if (elementRef.value) {
          resizeOberver.observe(elementRef.value);
        } else {
          console.log("[Marquee Scroll Error]: 获取 elementRef 失败!");
        }
      });
    });

    onBeforeUnmount(() => {
      if (elementRef.value) {
        resizeOberver.unobserve(elementRef.value);
      }
      end();
    });

    return {
      initStylePrefix,
      initStyleSuffix,
      elementRef,
      containerRef,
      currentMoveStyle,
      onMouseleave,
      onMouseenter,
      isOverflow,
    };
  },
});
</script>

<style lang="scss" scoped>
.marquee-container {
  position: relative;
  overflow: hidden;
  cursor: move;
  width: 100%;
  height: 100%;

  .marquee-move {
    position: absolute;
  }

  .marquee-move__up,
  .marquee-move__down {
    width: 100%;
  }

  .marquee-move__clone {
    position: absolute;
    width: 100%;
  }
}
</style>

核心Hooks如下:

    1. use-drag.ts 主要负责拖拽相关事件的处理。
    1. use-move.ts 主要负责动画的计算相关处理。
    1. use-style.ts主要负责动画的样式初始化以及更改处理。
    1. use-screen-hz.ts 主要负责兼容电脑不同刷新率。

use-drag.ts 主要是处理拖拽的摁下、拖动、松开的相关处理(startDrag、moveDrag、endDrag)。

相关代码如下:

ts
import {
  onMounted,
  onBeforeUnmount,
  Ref,
  nextTick,
  ref,
} from "@vue/composition-api";
import { Direction, MoveType } from "@/types";

interface DragProps {
  element: Ref<HTMLElement | null>;
  isMouseenter: Ref<boolean>;
  direction: Direction;
  move: MoveType;
  start: () => void;
  end: () => void;
  drag: boolean;
}

export const useDrag = (props: DragProps) => {
  const { element, direction, move, end, drag, start, isMouseenter } = props;

  const isDragging = ref<boolean>(false);
  const startX = ref<number>(0);
  const startY = ref<number>(0);
  const lastDiffX = ref<number>(0);
  const lastDiffY = ref<number>(0);

  const startDrag = (ev: MouseEvent) => {
    end();
    const { clientX, clientY } = ev;
    isDragging.value = true;
    document.onselectstart = () => false;
    startX.value = clientX;
    startY.value = clientY;
  };

  const moveDrag = (ev: MouseEvent) => {
    const { clientX: x2, clientY: y2 } = ev;

    if (isDragging.value) {
      const currentDiffX = x2 - startX.value;
      const currentDiffY = y2 - startY.value;

      const valueY = currentDiffY - lastDiffY.value;
      const valueX = currentDiffX - lastDiffX.value;

      const moveAddValue =
        direction === Direction.DOWN || direction === Direction.UP
          ? valueY
          : valueX;
      move({
        isDragging: true,
        currentValue: moveAddValue,
      });
      lastDiffX.value = currentDiffX;
      lastDiffY.value = currentDiffY;
    }
  };

  const endDrag = () => {
    document.onselectstart = () => true;
    isDragging.value = false;
    lastDiffX.value = 0;
    lastDiffY.value = 0;

    // 拖拽结束下 ,再鼠标溢出容器下 才需要重新启动
    if (!isMouseenter.value) {
      start();
    }
  };

  onMounted(() => {
    nextTick(() => {
      if (element.value && drag) {
        element.value.addEventListener("mousedown", startDrag);
        document.addEventListener("mousemove", moveDrag);
        document.addEventListener("mouseup", endDrag);
      }
    });
  });

  onBeforeUnmount(() => {
    if (element.value && drag) {
      element.value.removeEventListener("mousedown", startDrag);
      document.removeEventListener("mousemove", moveDrag);
      document.removeEventListener("mouseup", endDrag);
    }
  });

  return {
    startDrag,
    moveDrag,
    endDrag,
    isDragging,
  };
};

use-move.ts 主要是处理一些自动滚动以及拖拽后的计算处理如下:

ts
import {
  Ref,
  computed,
  ref,
  onMounted,
  onBeforeUnmount,
} from "@vue/composition-api";
import { SetTargetValue, Direction, MoveType } from "../types/index";
import { v4 as uuidv4 } from "uuid";
import {
  registerMergeAnimation,
  unregisterMergeAnimation,
} from "../utils/merge-animation";
import { useScreenHZ } from "./use-screen-hz";

interface MoveProps {
  element: Ref<HTMLElement | null>;
  containerElement: Ref<HTMLElement | null>;
  direction: Direction;
  setTargetValue: SetTargetValue;
  targetHeight: Ref<number>;
  targetWidth: Ref<number>;
  scrollamount: number;
  isMergeAnimation: boolean;
}

export const useMove = (props: MoveProps) => {
  const moveValue = ref<number>(0);
  const time = ref<number | null>(null);
  const currentAnimationKey = ref<string>(uuidv4());
  const isOverflow = ref<boolean>(true);

  const { hz, computedHz } = useScreenHZ();

  const {
    setTargetValue,
    element,
    containerElement,
    targetWidth,
    targetHeight,
    scrollamount,
    direction,
    isMergeAnimation,
  } = props;

  const maxValue = computed(() => {
    return direction === Direction.DOWN || direction === Direction.UP
      ? targetHeight.value
      : targetWidth.value;
  });

  const minValue = computed(() => -maxValue.value);

  const getMoveInfo = () => {
    if (element.value && containerElement.value) {
      const { width: containerWidth, height: containerHeight } =
        containerElement?.value.getBoundingClientRect() || {};
      const { width, height } = element?.value.getBoundingClientRect() || {};

      if (direction === Direction.DOWN || direction === Direction.UP) {
        isOverflow.value = containerHeight > height;
      } else {
        isOverflow.value = containerWidth > width;
      }

      if (width && height && !isOverflow.value) {
        setTargetValue(height, width);
        start();
      }
    }
  };

  const getEveryTimeValue = computed(() => {
    const value = scrollamount / 50 / hz.value;
    return direction === Direction.DOWN || direction === Direction.RIGHT
      ? value
      : -value;
  });

  const move: MoveType = (
    props = { isDragging: false, currentValue: getEveryTimeValue.value || 0 },
  ) => {
    if (isOverflow.value) return; // 判定是不是内容没有超出
    const { currentValue, isDragging } = props;
    const currentMoveValue = moveValue.value + (currentValue || 0);
    moveValue.value = currentMoveValue;

    if (
      currentMoveValue > maxValue.value ||
      currentMoveValue < minValue.value
    ) {
      moveValue.value = currentValue > 0 ? minValue.value : maxValue.value;
    }

    // 拖拽中条件判断 && 不合并动画
    if (!isDragging && !isMergeAnimation) {
      computedHz();
      time.value = window.requestAnimationFrame(move.bind(null, props));
    }
  };

  const start = () => {
    end();
    if (!isMergeAnimation) {
      time.value = window.requestAnimationFrame(
        move.bind(null, {
          isDragging: false,
          currentValue: getEveryTimeValue.value || 0,
        }),
      );
    } else {
      registerMergeAnimation({
        key: currentAnimationKey.value,
        callback: move,
      });
    }
  };

  const end = () => {
    if (!isMergeAnimation) {
      if (time.value !== null) {
        cancelAnimationFrame(time.value);
        time.value = null;
      }
    } else {
      unregisterMergeAnimation(currentAnimationKey.value);
    }
  };

  const currentMoveStyle = computed(() => {
    if (direction === Direction.UP) {
      return `top: ${moveValue.value}px;`;
    }

    if (direction === Direction.DOWN) {
      return `top: ${-moveValue.value}px;`;
    }

    if (direction === Direction.LEFT) {
      return `left: ${moveValue.value}px;`;
    }

    if (direction === Direction.RIGHT) {
      return `left: ${moveValue.value}px;`;
    }
    return "";
  });

  onMounted(() => {
    if (isMergeAnimation) {
      registerMergeAnimation({
        key: currentAnimationKey.value,
        callback: move,
      });
    }
  });

  onBeforeUnmount(() => {
    if (isMergeAnimation) {
      unregisterMergeAnimation(currentAnimationKey.value);
    }
  });

  return {
    isOverflow,
    getMoveInfo,
    currentMoveStyle,
    start,
    move,
    end,
  };
};

use-style.ts 主要用来处理样式初始化相关:

ts
import { computed, ref } from "@vue/composition-api";

import { Direction, SetTargetValue } from "../types/index";

export const useInitStyle = (props: { direction: Direction }) => {
  const targetHeight = ref<number>(0);
  const targetWidth = ref<number>(0);

  const initStylePrefix = computed(() => {
    if (
      props.direction === Direction.DOWN ||
      props.direction === Direction.UP
    ) {
      return `top: 100%; bottom: auto; height: ${targetHeight.value}px;`;
    }
    return `left: 100%; right: auto; top: 0; width: ${targetWidth.value}px;`;
  });

  const initStyleSuffix = computed(() => {
    if (
      props.direction === Direction.DOWN ||
      props.direction === Direction.UP
    ) {
      return `bottom: 100%; top: auto; height: ${targetHeight.value}px;`;
    }
    return `right: 100%; left: auto; top: 0;width: ${targetWidth.value}px;`;
  });

  const setTargetValue: SetTargetValue = (height: number, width: number) => {
    targetHeight.value = height;
    targetWidth.value = width;
  };

  return {
    targetHeight,
    targetWidth,
    initStylePrefix,
    initStyleSuffix,
    setTargetValue,
  };
};

use-screen-hz.ts主要处理120hzh60hz下动画速度稳定

ts
//fix: 解决再不同得电脑高刷屏下滚动速度的问题,120HZ / 60HZ

import { ref } from "@vue/composition-api";

const BASIC_HZ = 60;

/**
 * 原理是,正常而言 requestAnimationFrame 这个方法在一秒内会执行 60 次,也就是不掉帧的情况下。假设动画在时间 A 开始执行,在时间 B 结束,耗时 x ms。而中间 requestAnimationFrame 一共执行了 n 次,则此段动画的帧率大致为:n / (B - A)。
 */
export const useScreenHZ = () => {
  const hz = ref<number>(1);
  let frame = 0;
  let lastTime = Date.now();

  const computedHz = () => {
    const now = Date.now();
    // 不置 0,在动画的开头及结尾记录此值的差值算出 FPS
    frame++;
    if (now > 1000 + lastTime) {
      const fps = (frame * 1000) / (now - lastTime);
      const m = fps / BASIC_HZ;
      hz.value = m > 2 ? 2 : 1;
      frame = 0;
      lastTime = now;
    }
  };

  return {
    hz,
    computedHz,
  };
};

以上就是整个代码核心。还可以。完整代码地址如下:

使用CSS实现第三版本

第二版本其实已经能解决大多使用无缝滚动的场景了,但是JS使用动画帧确实太耗性能了,大屏场景如下:

  • 1、中间是一个3d中国行政地图或者是世界地图,地图飞线打点。
  • 2、侧边栏以及顶部有大于5个列表区域需要滚动。
  • 3、侧边有部分echarts图表。

在以上场景中不管是市场中的插件或者我自研组件都会明显看到性能卡顿,动画速度直线下降。30码演变成10码(这个也是跟不同的性能笔记本上表现不同)。

相关代码如下:

vue
<template>
  <div
    ref="refContainer"
    class="animation-box"
    :class="isHoverPause ? 'hover-pause' : ''"
    :style="{
      width: `${width}`,
      height: `${height}`,
    }"
    @click="onAnimationClick"
  >
    <div ref="refBox" class="box-content">
      <slot />
      <div class="box-content__image">
        <img
          v-if="direction === 'up' && data.length"
          v-show="isShowRepeatBox"
          src="./images/line.png"
          alt=""
          srcset=""
        />
      </div>
    </div>
    <div v-if="isShowRepeatBox" ref="refRepeatBox" class="box-content">
      <slot />
      <div class="box-content__image">
        <img
          v-if="direction === 'up' && data.length"
          src="./images/line.png"
          alt=""
          srcset=""
        />
      </div>
    </div>
  </div>
</template>

<script>
import { uniqueId } from "lodash";

export default {
  name: "SituationAnimationBox",

  props: {
    animation: {
      type: Boolean,
      default: true,
    },
    time: {
      type: [Number, String],
      default: 10,
    },
    direction: {
      type: String,
      default: "left",
    },
    width: {
      type: String,
      default: "100%",
    },
    height: {
      type: String,
      default: "100%",
    },
    isHoverPause: {
      type: Boolean,
      default: true,
    },
    data: {
      type: Array,
      default: () => [],
    },
  },
  data() {
    return {
      isShowRepeatBox: false,
    };
  },
  watch: {
    animation: {
      handler() {
        this.init();
      },
    },

    data() {
      this.init();
    },
  },
  mounted() {
    this.init();
    window.addEventListener("resize", this.init);
  },
  beforeDestroy() {
    window.removeEventListener("resize", this.init);
  },
  methods: {
    createKeyframes(offset) {
      const keyframesName = uniqueId(`${this.direction}_animation_`);

      const keyframesObj = {
        left: ["translateX(0)", `translateX(-${offset}px)`],
        right: [`translateX(-${offset}px)`, "translateX(0)"],
        up: ["translateY(0)", `translateY(-${offset}px)`],
        down: [`translateY(-${offset}px)`, "translateY(0)"],
      };

      document.styleSheets[0].insertRule(
        `@keyframes ${keyframesName} { 
          0% { transform: ${keyframesObj[this.direction][0]} }
          100% { transform: ${keyframesObj[this.direction][1]} }
        }`,
        0,
      );

      return keyframesName;
    },
    async getOffsetNum() {
      await this.$nextTick();

      const refContainer = this.$refs.refContainer;
      const containerClientRect = refContainer.getBoundingClientRect();
      const containerWidth = containerClientRect.width;
      const containerHeight = containerClientRect.height;

      const refBox = this.$refs.refBox;
      const boxClientRect = refBox.getBoundingClientRect();
      const boxWidth = boxClientRect.width;
      const boxHeight = boxClientRect.height;

      let offset = 0;

      if (["up", "down"].includes(this.direction)) {
        if (containerHeight > boxHeight) return 0;
        offset = boxHeight;
      } else if (["left", "right"].includes(this.direction)) {
        if (containerWidth > boxWidth) return 0;
        offset = boxWidth;
      }

      return offset;
    },
    async init() {
      this.clear();
      if (!this.animation || !Number(`${this.time}`))
        return (this.isShowRepeatBox = false);

      const refContainer = this.$refs.refContainer;
      const refBox = this.$refs.refBox;

      if (["up", "down"].includes(this.direction)) {
        refContainer.style.whiteSpace = "normal";
      } else if (["left", "right"].includes(this.direction)) {
        refContainer.style.whiteSpace = "nowrap";
        refBox.style.display = "inline-block";
      }

      const offset = await this.getOffsetNum();

      if (!offset) return (this.isShowRepeatBox = false);

      const keyframesName = this.createKeyframes(offset);
      refBox.style.animation = `${keyframesName} ${this.time}s linear infinite`;

      this.isShowRepeatBox = true;

      await this.$nextTick();

      const refRepeatBox = this.$refs.refRepeatBox;
      refRepeatBox.style.animation = `${keyframesName} ${this.time}s linear infinite`;

      if (["left", "right"].includes(this.direction)) {
        refRepeatBox.style.display = "inline-block";
      }
    },

    clear() {
      this.isShowRepeatBox = false;
      const refBox = this.$refs.refBox;
      refBox.style.animation = "";
    },

    onAnimationClick() {
      // this.init();
    },
  },
};
</script>

<style lang="scss" scoped>
.animation-box {
  overflow: hidden;
  display: inline-block;
  transition: all 0ms ease-in 0s;
  // fix: CSS动画照成界面白屏闪动
  backface-visibility: hidden;
  perspective: 1000;
  transform: translate3d(0, 0, 0);

  .box-content {
    width: auto;
    height: auto;
    position: relative;
    > .box-content__image {
      width: 100%;
      height: 0.625vw;
      display: flex;
      align-items: center;
      margin: 0.4167vw 0;

      > img {
        width: 100%;
      }
    }
  }
}

.hover-pause {
  &:hover {
    .box-content {
      animation-play-state: paused !important;
    }
  }
}
</style>

总结

以上就是完整的无缝滚动动画了,CSS、JS、VUE都有,希望对您有帮助。