原生 JS 实现滑动轮播效果 <附: 单独使用 Vue 同时实现该轮播效果>

897 阅读2分钟

根据 UI 库源码的理解,跟进上一篇 picker 选择器的实现,该篇主要通过原生 js 实现一个滑动轮播图,可在此基础上进行扩展到自己的项目中(最后附上通过 Vue 实现的代码)

实现效果图如下:

实现原理很简单,和大多数的轮播图一样:

  1. 首先创建显示容器
  2. 接着创建轨道容器,主要用来放置滑动卡片,并且通过css样式控制一行排列
  3. 然后通过js配合css控制轨道容器的滑动距离,实现过渡效果
  4. 最后对于滑到左侧与右侧进行边界处理

原生代码实现如下 (html 及 css 部分):

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>轮播</title>
<style>
  html,body {
      margin: 0;
      padding: 0;
  }
  .swiper-demo {
      /* width: 375px; */
      width: 100%;
      height: 150px;
  }
  .swiper-item {
      width: 100%;
      height: 100%;
      border: 1px solid #999;
      box-sizing: border-box;
  }
  .swiper {
      width: 100%;
      height: 100%;
      position: relative;
      overflow: hidden;
      cursor: grab;
  }
  .track-container {
      display: flex;
      height: 100%;
  }
  .indicator {
      position: absolute;
      z-index: 9;
      right: 10px;
      bottom: 10px;
  }
</style>
</head>
<body>
  <div class="swiper-demo">
    <!-- 滑动组件 -->
    <div class="swiper">
      <div ref="track" class="track-container">
        <div class="swiper-item">1</div>
        <div class="swiper-item">2</div>
        <div class="swiper-item">3</div>
        <div class="swiper-item">4</div>
      </div>
      <!-- 指示器 -->
      <div class="indicator"></div>
    </div>
  </div>
</body>
<script src="./index.js"></script>
</html>

原生 JS 代码

class Swiper {
  // 常量
  MIN_DISTANCE = 10
  supportsPassive = false

  constructor(option = {}) {
      this.initValue(option)
      this._initValue()
      this.initComputed()

      // 初始化
      this.onMounted()
  }

  // 用户变量
  initValue(option) {
      this.width = option.width || 0 // 卡片元素宽
      this.height = option.height || 0 // 卡片元素高
      this.autoplay = option.autoplay || 0 // 自动播放时间间隔
      this.vertical = option.vertical || false // 默认横向滑动
      this.loop = option.loop || true // 默认无缝衔接
      this.duration = option.duration || 500 // 滑动时长
      this.touchable = option.touchable || true // 可滑动
      this.initialSwipe = option.initialSwipe || 0 // 初始化滑动轮播下标
      this.showIndicators = option.showIndicators || true // 下标指示器
      this.stopPropagationBool = option.stopPropagationBool || true // 阻止事件冒泡
      this.isShowIndicator = option.isShowIndicator || false // 默认隐藏指示器
  }

  // 私有变量
  _initValue() {
      const swiper = document.querySelector('.swiper') // 显示容器
      this.direction = '' // 滑动方向
      this.startX = 0 // 初始位置水平方向
      this.startY = 0 // 初始位置
      this.deltaX = 0 // 水平方向滑动距离
      this.deltaY = 0
      this.offsetX = 0 // 滑动距离--水平方向
      this.offsetY = 0
      this.offset = 0 // 滑动距离
      this.active = 0 // 当前显示的滑动卡片下标
      this.swiping = false // 滑动状态
      this.computedWidth = 0 // 单个滑动卡片宽度
      this.computedHeight = 0
      this.rect = swiper.getBoundingClientRect()
      this.$el = swiper // 显示区域 父容器
      this.track = document.querySelector('.track-container') // 包含滑动卡片的轨道元素
      this.indicator = document.querySelector('.indicator') // 指示器
      this.timer = null // 定时器
  }

  // 计算属性--动态计算滑动需要的数据
  initComputed(option) {
      // 滑动卡片子元素个数
      this.count = this.track.children.length
      // 卡片元素宽/高尺寸
      this.size = this[this.vertical ? 'computedHeight' : 'computedWidth']
      // 最大偏移量
      this.minOffset = (this.vertical ? this.rect.height : this.rect.width) - this.size * this.count
      // 最大偏移数量
      this.maxCount = Math.ceil(Math.abs(this.minOffset) / this.size)
      // 滑动距离
      this.delta = this.vertical ? this.deltaY : this.deltaX
      // 当前激活元素的下标
      this.activeIndicator = (this.active + this.count) % this.count
      // 判断实际滑动方向与预期滑动方向是否一致
      this.isCorrectDirection = this.direction === (this.vertical ? 'vertical' : 'horizontal')
      // 滑动轨道容器宽度
      this.trackSize = this.count * this.size
      // 滑动动画
      this.setTransform()
  }

  // 滑动动画关键
  setTransform() {
      // 滑动动画关键步骤--主要利用css3 translate 配合 transition过渡效果实现
      // 动态重置轨道容器样式,配合边界处理,实现滑动效果
      const mainAxis = this.vertical ? 'height' : 'width'
      const crossAxis = this.vertical ? 'width' : 'height'
      this.trackStyle = {
          [mainAxis]: `${this.trackSize}px`,
          [crossAxis]: this[crossAxis] ? `${this[crossAxis]}px` : '',
          transitionDuration: `${this.swiping ? 0 : this.duration}ms`,
          transform: `translate${this.vertical ? 'Y' : 'X'}(${this.offset}px)`,
      }
  }

  // 页面初始化
  onMounted() {
      this.bindTouchEvent(this.track)
      this.initialize()
  }

  // 初始化数据、滑动
  initialize(active = +this.initialSwipe) {
      if (!this.$el || this.isHidden(this.$el)) {
          return;
      }

      clearTimeout(this.timer)
      this.swiping = true;
      this.active = active;
      this.computedWidth = Math.floor(+this.width || this.rect.width);
      this.computedHeight = Math.floor(+this.height || this.rect.height);
      this.offset = this.getTargetOffset(active);
      Array.from(this.track.children).forEach((swipe) => {
          swipe.offset = 0;
      });
      // 重新刷新计算属性
      this.setIndicator()
      this.initComputed()
      this.setSwiperStyle()
      this.autoPlay();
  }

  // 设置指示器
  setIndicator() {
      if (this.isShowIndicator && this.indicator) {
          this.indicator.innerHTML = `当前下标: ${this.activeIndicator} --- ${this.activeIndicator + 1}/${this.track.children.length}`
      }
  }

  // 设置轮播组件样式
  setSwiperStyle() {
      this.setTransform()
      this.setSwiperItemdStyle()
      this.setSwiperTrackStyle()
  }

  // 设置轨道样式
  setSwiperTrackStyle() {
      this.track.style.width = this.trackStyle.width
      this.track.style.height = this.trackStyle.height
      this.track.style.transitionDuration = this.trackStyle.transitionDuration
      this.track.style.transform = this.trackStyle.transform
  }

  // 设置每个卡片偏移量--样式
  setSwiperItemdStyle() {
      Array.from(this.track.children).forEach(swiperItem => {
          // if (swiperItem.offset) {
              swiperItem.style.transform = `translate${this.vertical ? "Y" : "X"}(${swiperItem.offset}px)`
          // }
      })
  }

  // 自动播放函数
  autoPlay() {
      const { autoplay } = this;

      if (autoplay > 0 && this.count > 1) {
          this.clear();
          this.timer = setTimeout(() => {
              this.next();
              this.autoPlay();
          }, autoplay);
      }
  }

  // xiayizhang 
  next() {
      this.correctPosition();
      this.resetTouchStatus();

      this.doubleRaf(() => {
          this.swiping = false;
          this.move({
              pace: 1,
              emitChange: true,
          });
      });
  }

  // 重置滑动中状态
  correctPosition() {
      this.swiping = true;

      // 画到最左边的边界处理
      if (this.active <= -1) {
          this.move({ pace: this.count });
      }

      // 滑动最右边的边界处理
      if (this.active >= this.count) {
          this.move({ pace: -this.count });
      }
  }

  // 动画滑动函数
  move({ pace = 0, offset = 0, emitChange }) {
      const { loop, count, active, trackSize, minOffset } = this;
      const children = this.track.children

      if (count <= 1) {
          return;
      }

      const targetActive = this.getTargetActive(pace);
      const targetOffset = this.getTargetOffset(targetActive, offset);

      if (loop) {
          // 滑到右边最后一张边界处理
          if (children[0] && targetOffset !== minOffset) {
              const outRightBound = targetOffset < minOffset;
              children[0].offset = outRightBound ? trackSize : 0;
          }

          // 滑到左边第一张边界处理
          if (children[count - 1] && targetOffset !== 0) {
              const outLeftBound = targetOffset > 0;
              children[count - 1].offset = outLeftBound ? -trackSize : 0;
          }
      }

      this.active = targetActive;
      this.offset = targetOffset;
      this.setSwiperStyle()

      // 当前激活元素的下标
      this.activeIndicator = (this.active + this.count) % this.count

      if (emitChange && targetActive !== active) {
          this.setIndicator()
      }
  }

  // 确定下次要展示在容器中的滑动卡片元素下标
  getTargetActive(pace) {
      const { active, count, maxCount } = this;

      if (pace) {
          if (this.loop) {
              return this.range(active + pace, -1, count);
          }

          return this.range(active + pace, 0, maxCount);
      }

      return active;
  }

  // 获取滑动偏移量
  getTargetOffset(targetActive, offset = 0) {
      let currentPosition = targetActive * this.size;
      if (!this.loop) {
          currentPosition = Math.min(currentPosition, -this.minOffset);
      }

      let targetOffset = Math.round(offset - currentPosition);
      if (!this.loop) {
          targetOffset = this.range(targetOffset, this.minOffset, 0);
      }

      return targetOffset;
  }

  // 清空定时器
  clear() {
      clearTimeout(this.timer)
  }

  // 滑动动画回调函数
  doubleRaf(fn) {
      this.raf(() => {
          this.raf(fn);
      });
  }

  raf(fn) {
      return window.requestAnimationFrame(fn)
  }

  // 滑动范围限制
  range(num, min, max) {
      return Math.min(Math.max(num, min), max);
  }

  // 公共滑动方法
  touchStart(event) {
      this.resetTouchStatus()
      this.startX = event.touches[0].clientX
      this.startY = event.touches[0].clientY
  }

  touchMove(event) {
      const touch = event.touches[0]
      this.deltaX = touch.clientX - this.startX
      this.deltaY = touch.clientY - this.startY
      this.offsetX = Math.abs(this.deltaX)
      this.offsetY = Math.abs(this.deltaY)
      this.direction = this.direction || this.getDirection(this.offsetX, this.offsetY)
  }

  resetTouchStatus() {
      this.direction = ''
      this.deltaX = 0
      this.deltaY = 0
      this.offsetX = 0
      this.offsetY = 0
  }

  bindTouchEvent(el) {
      const { onTouchStart, onTouchMove, onTouchEnd } = this;

      this.on(el, 'touchstart', onTouchStart);
      this.on(el, 'touchmove', onTouchMove);

      if (onTouchEnd) {
          this.on(el, 'touchend', onTouchEnd);
          this.on(el, 'touchcancel', onTouchEnd);
      }
  }

  on(target, event, handler, passive = false) {
      target.addEventListener(
          event,
          handler,
          this.supportsPassive ? { capture: false, passive } : false
      );
  }

  getDirection(x, y) {
      if (x > y && x > this.MIN_DISTANCE) {
          return 'horizontal';
      }

      if (y > x && y > this.MIN_DISTANCE) {
          return 'vertical';
      }

      return '';
  }

  isHidden(el) {
      const style = window.getComputedStyle(el);
      const hidden = style.display === 'none';
      const parentHidden = el.offsetParent === null && style.position !== 'fixed';

      return hidden || parentHidden;
  }

  preventDefault = (event, isStopPropagation) => {
      if (typeof event.cancelable !== 'boolean' || event.cancelable) {
          event.preventDefault();
      }

      if (isStopPropagation) {
          this.stopPropagation(event);
      }
  }

  // 阻止事件冒泡
  stopPropagation(event) {
      event.stopPropagation();
  }

  // touchstart 滑动事件监听
  onTouchStart = (event) => {
      if (!this.touchable) return;

      this.clear();
      this.touchStartTime = Date.now();
      this.touchStart(event);
      this.correctPosition();
  }

  // touchmove 滑动事件监听
  onTouchMove = (event) => {
      if (!this.touchable || !this.swiping) return;

      this.touchMove(event);

      // 断实际滑动方向与预期滑动方向是否一致
      this.isCorrectDirection = this.direction === (this.vertical ? 'vertical' : 'horizontal')
      // 滑动距离
      this.delta = this.vertical ? this.deltaY : this.deltaX

      if (this.isCorrectDirection) {
          this.preventDefault(event, this.stopPropagationBool);
          this.move({ offset: this.delta });
      }
  }

  onTouchEnd = () => {
      if (!this.touchable || !this.swiping) return;

      const { size, delta } = this;
      const duration = Date.now() - this.touchStartTime;
      const speed = delta / duration;
      const shouldSwipe = Math.abs(speed) > 0.25 || Math.abs(delta) > size / 2;

      if (shouldSwipe && this.isCorrectDirection) {
          const offset = this.vertical ? this.offsetY : this.offsetX;

          let pace = 0;

          if (this.loop) {
              pace = offset > 0 ? (delta > 0 ? -1 : 1) : 0;
          } else {
              pace = -Math[delta > 0 ? 'ceil' : 'floor'](delta / size);
          }

          // 解决动画滑动过程更平滑
          this.swiping = false
          this.move({
              pace,
              emitChange: true,
          });
      } else if (delta) {
          // 解决动画滑动过程更平滑
          this.swiping = false
          this.move({ pace: 0 });
      }

      this.swiping = false
      this.autoPlay();
  }
}

new Swiper({ autoplay: 1500, isShowIndicator: true })

以上滑动轮播图的原生实现,可以在项目中结合框架,进行单独组件的提取,合理进行扩展,将不部分公共方法属性提取出来,提高可复用性

附:Vue 版实现滑动轮播图效果(可再进行扩展)

  • 最外层展示组件
  • <template>
      <div class="swiper-demo">
        <Swiper :autoplay="100000">
          <SwiperItem>1</SwiperItem>
          <SwiperItem>2</SwiperItem>
          <SwiperItem>3</SwiperItem>
          <SwiperItem>4</SwiperItem>
        </Swiper>
      </div>
    </template>
    
    <script>
    import Swiper from "@/components/Swiper/Swiper";
    import SwiperItem from "@/components/SwiperItem/SwiperItem";
    export default {
      name: "SwiperDemo",
    	components: { Swiper, SwiperItem }
    }
    </script>
    
    <style lang="scss" scoped>
    .swiper-demo {
      width: 100%;
      height: 150px;
    }
    .swiper-item {
      width: 100%;
      height: 100%;
      border: 1px solid #999;
      box-sizing: border-box;
    }
    </style>
    
  • 父组件: Swiper
  • <template>
      <div class="swiper" ref="swiper">
        <div ref="track" :style="trackStyle" class="track-container">
          <slot/>
        </div>
        <!-- 指示器 -->
      </div>
    </template>
    
    <script>
    import {TouchMixin, doubleRaf,range, isHidden, preventDefault} from './touch'
    export default {
    	name: 'Swiper',
    	mixins: [TouchMixin],
    	props: {
        width: [Number, String],
        height: [Number, String],
        autoplay: [Number, String], // 自动播放间隔
        vertical: Boolean
        loop: {
          type: Boolean,
          default: true,
        },
        duration: {
          type: [Number, String],
          default: 500,
        },
        touchable: {
          type: Boolean,
          default: true,
        },
        initialSwipe: {
          type: [Number, String],
          default: 0,
        },
        showIndicators: {
          type: Boolean,
          default: true,
        },
        stopPropagation: {
          type: Boolean,
          default: true,
        },
    	},
    	data() {
        return {
          rect: null,
          offset: 0,
          active: 0,
          deltaX: 0,
          deltaY: 0,
          swiping: false,
          computedWidth: 0,
          computedHeight: 0,
          $el: null, // 显示区域 父容器
        };
      },
      computed: {
        // 子元素个数
        count() {
          return this.$slots.default.length;
        },
        // 父显示区域宽/高尺寸
        size() {
          return this[this.vertical ? 'computedHeight' : 'computedWidth'];
        },
        // 最大偏移量
        minOffset() {
          return (
            (this.vertical ? this.rect.height : this.rect.width) -
            this.size * this.count);
        },
        // 最大偏移数量
        maxCount() {
          return Math.ceil(Math.abs(this.minOffset) / this.size);
        },
        // 滑动距离
        delta() {
          return this.vertical ? this.deltaY : this.deltaX;
    		},
        // 当前激活元素的下标
        activeIndicator() {
          return (this.active + this.count) % this.count;
        },
        // 判断实际滑动方向与预期滑动方向是否一致
        isCorrectDirection() {
          const expect = this.vertical ? 'vertical' : 'horizontal';
          return this.direction === expect;
        },
        // 滑动轨道容器宽度
        trackSize() {
          return this.count * this.size;
        },
        // 滑动动画关键步骤
        trackStyle() {
          const mainAxis = this.vertical ? 'height' : 'width'
          const crossAxis = this.vertical ? 'width' : 'height'
    	  return {
                [mainAxis]: `${this.trackSize}px`,
                [crossAxis]: this[crossAxis] ? `${this[crossAxis]}px` : '',
                transitionDuration: `${this.swiping ? 0 : this.duration}ms`,
                transform: `translate${this.vertical ? 'Y' : 'X'}(${this.offset}px)`,
               }
           	}
      },
      mounted() {
        this.$el = this.$refs.swiper
        this.bindTouchEvent(this.$refs.track)
        this.initialize()
      },
      methods: {
        // 初始化数据、滑动
        initialize(active = +this.initialSwipe) {
          if (!this.$el || isHidden(this.$el)) {
            return;
          }
    
          clearTimeout(this.timer);
    
          const rect = this.$el.getBoundingClientRect();
    
          this.rect = rect;
          this.swiping = true;
          this.active = active;
          this.computedWidth = Math.floor(+this.width || rect.width);
          this.computedHeight = Math.floor(+this.height || rect.height);
          this.offset = this.getTargetOffset(active);
          // this.children.forEach((swipe) => {
          this.$children.forEach((swipe)=>{
            swipe.offset = 0;
          });
          this.autoPlay();
        },
        autoPlay() {
          const { autoplay } = this;
    
          if (autoplay > 0 && this.count > 1) {
            this.clear();
            this.timer = setTimeout(() => {
              this.next();
              this.autoPlay();
            }, autoplay);
          }
        },
    
        next() {
          this.correctPosition();
          this.resetTouchStatus();
    
          doubleRaf(() => {
            this.swiping = false;
            this.move({
              pace: 1,
              emitChange: true,
            });
          });
        },
        correctPosition() {
          this.swiping = true;
    
          if (this.active <= -1) {
            this.move({ pace: this.count });
          }
    
          if (this.active >= this.count) {
            this.move({ pace: -this.count });
          }
        },
        move({ pace = 0, offset = 0, emitChange }) {
          const { loop, count, active, trackSize, minOffset } = this;
          const children = this.$children
    
          if (count <= 1) {
            return;
          }
    
          const targetActive = this.getTargetActive(pace);
          const targetOffset = this.getTargetOffset(targetActive, offset);
    
          // auto move first and last swipe in loop mode
          if (loop) {
            // console.log(children)
            // 滑到右边最后一张边界处理
            if (children[0] && targetOffset !== minOffset) {
              const outRightBound = targetOffset < minOffset;
              children[0].offset = outRightBound ? trackSize : 0;
            }
    
            // 滑到左边第一张边界处理
            if (children[count - 1] && targetOffset !== 0) {
              const outLeftBound = targetOffset > 0;
              children[count - 1].offset = outLeftBound ? -trackSize : 0;
            }
          }
    
          this.active = targetActive;
          this.offset = targetOffset;
    
          if (emitChange && targetActive !== active) {
            this.$emit('change', this.activeIndicator)
          }
        },
        getTargetActive(pace) {
          const { active, count, maxCount } = this;
    
          if (pace) {
            if (this.loop) {
              return range(active + pace, -1, count);
            }
    
            return range(active + pace, 0, maxCount);
          }
    
          return active;
        },
        getTargetOffset(targetActive, offset = 0) {
          let currentPosition = targetActive * this.size;
          if (!this.loop) {
            currentPosition = Math.min(currentPosition, -this.minOffset);
          }
    
          let targetOffset = Math.round(offset - currentPosition);
          if (!this.loop) {
            targetOffset = range(targetOffset, this.minOffset, 0);
          }
    
          return targetOffset;
        },
        clear() {
          clearTimeout(this.timer)
        },
        onTouchStart(event) {
          if (!this.touchable) return;
    
          this.clear();
          this.touchStartTime = Date.now();
          this.touchStart(event);
          this.correctPosition();
        },
    
        onTouchMove(event) {
          if (!this.touchable || !this.swiping) return;
    
          this.touchMove(event);
    
          if (this.isCorrectDirection) {
            preventDefault(event, this.stopPropagation);
            this.move({ offset: this.delta });
          }
        },
    
        onTouchEnd() {
          if (!this.touchable || !this.swiping) return;
    
          const { size, delta } = this;
          const duration = Date.now() - this.touchStartTime;
          const speed = delta / duration;
          const shouldSwipe = Math.abs(speed) > 0.25 || Math.abs(delta) > size / 2;
    
          if (shouldSwipe && this.isCorrectDirection) {
            const offset = this.vertical ? this.offsetY : this.offsetX;
    
            let pace = 0;
    
            if (this.loop) {
              pace = offset > 0 ? (delta > 0 ? -1 : 1) : 0;
            } else {
              pace = -Math[delta > 0 ? 'ceil' : 'floor'](delta / size);
            }
    
            this.move({
              pace,
              emitChange: true,
            });
          } else if (delta) {
            this.move({ pace: 0 });
          }
    
          this.swiping = false;
          this.autoPlay();
        },
      }
    }
    </script>
    
    <style lang="scss" scoped>
    .swiper {
      width: 100%;
      height: 100%;
      position: relative;
      overflow: hidden;
      cursor: grab;
    }
    .track-container {
      display: flex;
      height: 100%;
    }
    </style>
    
  • 子组件:SwiperItem
  • <template>
      <div class="swiper-item" :style="style">
        <slot />
      </div>
    </template>
    
    <script>
    export default {
      name: "SwiperItem",
      data() {
        return {
          offset: 0,
          mounted: false,
        };
      },
      mounted() {
        this.$nextTick(() => {
          this.mounted = true;
        });
      },
      computed: {
        style() {
          const style = {};
          const { size, vertical } = this.$parent
    
          style[vertical ? "height" : "width"] = `${size}px`;
          if (this.offset) {
            style.transform = `translate${vertical ? "Y" : "X"}(${this.offset}px)`;
          }
          return style;
        }
      },
    };
    </script>
    
  • 子组件:TouchMixin 文件
  • export let supportsPassive = false;
    export function on(target, event, handler, passive = false) {
      target.addEventListener(
        event,
        handler,
        supportsPassive ? { capture: false, passive } : false
      );
    }
    
    const MIN_DISTANCE = 10;
    
    function getDirection(x, y) {
    	if (x > y && x > MIN_DISTANCE) {
    		return 'horizontal';
    	}
    
    	if (y > x && y > MIN_DISTANCE) {
    		return 'vertical';
    	}
    
    	return '';
    }
    
    export const TouchMixin = {
      data() {
          return { direction: '' };
      },
    
      methods: {
        touchStart(event) {
          this.resetTouchStatus();
          this.startX = event.touches[0].clientX;
          this.startY = event.touches[0].clientY;
        },
    
        touchMove(event) {
          const touch = event.touches[0];
          this.deltaX = touch.clientX - this.startX;
          this.deltaY = touch.clientY - this.startY;
          this.offsetX = Math.abs(this.deltaX);
          this.offsetY = Math.abs(this.deltaY);
          this.direction = this.direction || getDirection(this.offsetX, this.offsetY);
        },
    
        resetTouchStatus() {
          this.direction = '';
          this.deltaX = 0;
          this.deltaY = 0;
          this.offsetX = 0;
          this.offsetY = 0;
        },
    
        // avoid Vue 2.6 event bubble issues by manually binding events
        // https://github.com/youzan/vant/issues/3015
        bindTouchEvent(el) {
          const { onTouchStart, onTouchMove, onTouchEnd } = this;
    
          on(el, 'touchstart', onTouchStart);
          on(el, 'touchmove', onTouchMove);
    
          if (onTouchEnd) {
              on(el, 'touchend', onTouchEnd);
              on(el, 'touchcancel', onTouchEnd);
          }
        },
      },
    };
    // 利用 window.requestAnimationFrame(fn) 动画
    // 对应 MDN 文档地址:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
    const root = window
    const iRaf = root.requestAnimationFrame || fallback;
    
    export function raf(fn) {
      return iRaf.call(root, fn);
    }
    
    // animation 效果
    export function doubleRaf(fn) {
      raf(() => {
          raf(fn);
      });
    }
    
    export function range(num, min, max) {
      return Math.min(Math.max(num, min), max);
    }
    
    export function isHidden(el) {
      const style = window.getComputedStyle(el);
      const hidden = style.display === 'none';
    
      const parentHidden = el.offsetParent === null && style.position !== 'fixed';
    
      return hidden || parentHidden;
    }
    
    export function stopPropagation(event) {
      event.stopPropagation();
    }
    
    export function preventDefault(event, isStopPropagation) {
      /* istanbul ignore else */
      if (typeof event.cancelable !== 'boolean' || event.cancelable) {
          event.preventDefault();
      }
    
      if (isStopPropagation) {
          stopPropagation(event);
      }
    }
    

    以上根据 UI 库源码简单实现,可根据具体需求进行扩展