不到200行用Vue实现类似Swiper.js的轮播组件

前言

大家在开发过程中,或多或少都会用到轮播图之类的组件,PC和Mobile上使用 Swiper.js ,小程序上使用swiper组件等。

本文将详细讲解如何用Vue 一步步实现的类似Swiper.js的功能,无任何第三方依赖,干货满满。

最终效果

在线预览:zyronon.github.io/douyin/

项目源代码:github.com/zyronon/dou...

注意PC 必须将浏览器切到手机模式,先按 F12 调出控制台,再按 Ctrl+Shift+M才能正常预览

Demo代码

上面的预览地址是最终实现的效果,下面才是本文代码实现的效果

为提升阅读体验,正文中代码展示有部分省略处理,完整代码可以在codesandbox上查看: codesandbox.io/p/devbox/mu...

实现原理

布局

我们需要用到两个div,父元素 slide 设置 overflow: hidden 禁止滚动 ,子元素 slide-list 使用 flex 布局 ,然后将需要滚动的页面 做为孙元素 放在子元素 slide-list 中,由于子元素 slide-listflex 布局,页面会自然的平铺排列

因为父元素 slideoverflow: hidden属性会将内容裁减,不提供滚动条,也不允许用户滚动,所以我们只能看到父元素 slide 宽高的内容。

html 复制代码
<div class="slide">
  <div class="slide-list">
    <slot></slot>
  </div>
</div>
html 复制代码
.slide {
  touch-action: none;
  height: 100%;
  width: 100%;
  transition: height 0.3s;
  position: relative;
  overflow: hidden;
}

 .slide-list {
    height: 100%;
    width: 100%;
    display: flex;
    position: relative;
  }

滑动

实现滚动的关键点在于CSS3transform: translate(0, 0) 属性。

translate() 这个 CSS 函数在水平和/或垂直方向上重新定位元素,它的坐标定义了元素在每个方向上移动了多少。

因为子元素 slide-list 的内容是平铺的,我们只需要在子元素 slide-list 监听对应的事件,计算滑动的距离xy,再动态设置到子元素 slide-listtransform: translate(x, y)里面,就可以实现页面滑动了

总结

大家可以将整个流程理解为播放胶片电影:父元素 A 是放映机,子元素 B 是胶片,而页面是印刷在胶片上的内容。胶片每移动一格,我们就能看到新的一帧电影

实现

监听事件

PC 上的点击、移动,H5 的手势操作,都离不开 DOM 事件监听 。例如鼠标移动事件对应 mousemove,移动端因为没有鼠标则对应 touchmove

我们可以通过Pointer事件进行多端统一的事件监听,实现触屏和 PC 端通用

html 复制代码
<div class="slide horizontal">
  <div
      class="slide-list"
      ref="wrapperEl"
      @pointerdown="onPointerDown"
      @pointermove="onPointerMove"
      @pointerup="onPointerUp"
  >
    <slot></slot>
  </div>
</div>

初始化

组件默认变量

js 复制代码
//slide-list的ref引用
const wrapperEl = ref(null)

const state = reactive({
  judgeValue: 20,//一个用于判断滑动朝向的固定值
  type: SlideType.VERTICAL,//组件类型
  name: props.name,
  localIndex: props.index,//当前下标
  needCheck: true,//是否需要检测,每次按下都需要检测,up事件会重置为true
  next: false,//能否滑动
  isDown: false,//是否按下,用于move事件判断
  start: {x: 0, y: 0, time: 0},//按下时的起点坐标
  move: {x: 0, y: 0},//移动时的坐标
  wrapper: {width: 0, height: 0, childrenLength: 0}//slide-list的宽度和子元素数量
})
js 复制代码
function slidePointerDown(e, el, state) {
  Utils.$setCss(el, 'transition-duration', `0ms`)
  //记录起点坐标,用于move事件计算移动距离
  state.start.x = e.pageX
  state.start.y = e.pageY
  //记录按下时间,用于up事件判断滑动时间
  state.start.time = Date.now()
  state.isDown = true
}

虽然我们用 Pointer事件统一了移动端和PC端的监听事件,但 pointermove 事件在 PC 和移动端表现出来的效果却不一样,在 PC 上, pointermove 事件和 mousemove 事件一致,只要鼠标在目标元素上方,就会触发。而在移动端上却只有按下并移动时发才会触发

所以这里用一个 isDown 的变量保存是否按下的状态,pointermove事件虽然会一直触发,但仅当 isDown 时才执行我们的代码逻辑

移动过程

js 复制代码
function slidePointerMove(e,el,state) {
  if (!state.isDown) return;

  //计算移动距离
  state.move.x = e.pageX - state.start.x
  state.move.y = e.pageY - state.start.y

  //检测能否滑动
  let canSlideRes = canSlide(state)

  //是否是往下(右)滑动
  let isNext = state.type === SlideType.HORIZONTAL ? state.move.x < 0 : state.move.y < 0

  if (canSlideRes) {
    if (canNext(state, isNext)) {
      //能滑动,那就把事件捕获,不能给父组件处理
      Utils.$stopPropagation(e)

      //获取偏移量
      let t = getSlideOffset(state, el) + (isNext ? state.judgeValue : -state.judgeValue)
      let dx1 = 0,
        dx2 = 0
      //偏移量加当前手指移动的距离就是slide要偏移的值
      if (state.type === SlideType.HORIZONTAL) {
        dx1 = t + state.move.x
      } else {
        dx2 = t + state.move.y
      }
      Utils.$setCss(el, 'transition-duration', `0ms`)
      Utils.$setCss(el, 'transform', `translate(${dx1}px, ${dx2}px)`)
    }
  }
}

用鼠标当前的位置,再减去鼠标按下时的位置,就是鼠标移动的距离

移动距离再加上当前页面 * 每个页面的宽或高,即子元素 slide-list 整体要偏移的量

技术难点

1. 如何判断滑动方向?是在上下滑还是左右滑?
js 复制代码
//检测在对应方向上能否允许滑动,比如SlideHorizontal组件就只处理左右滑动事件,SlideVertical 
//只处理上下滑动事件 
export function canSlide(state) {
  //每次按下都需要检测,up事件会重置为true
  if (state.needCheck) {
    //判断move x和y的距离是否大于判断值,因为距离太小无法判断滑动方向
    if (Math.abs(state.move.x) > state.judgeValue || Math.abs(state.move.y) > state.judgeValue) {
      //放大再相除,根据长宽比判断方向,angle大于1就是左右滑动,小于是上下滑动
      let angle = (Math.abs(state.move.x) * 10) / (Math.abs(state.move.y) * 10)
      //根据当前slide的类型,判断能否滑动,并记录下来,后续不再判断,直接返回记录值
      state.next = state.type === SlideType.HORIZONTAL ? angle > 1 : angle <= 1
      state.needCheck = false
    } else {
      return false
    }
  }
  return state.next
}

放大移动距离后再相除,根据结果是否大于1判断出滑动方向

2. 如何处理嵌套组件中的事件冲突?什么时候拦截事件和放行事件?

由于事件的冒泡机制,事件是从最里面的元素一级一级的往上冒泡的,所以我们只需在满足下面两个条件时拦截事件即可

  1. 是否在往到头或尾滑动
    如果在第一页,不能往左/上滑动
    如果在最后一面, 不能往右/下滑动
js 复制代码
function canNext(state, isNext) {
  return !(
    (state.localIndex === 0 && !isNext) ||
    (state.localIndex === state.wrapper.childrenLength - 1 && isNext)
  )
}
  1. 滑动方向和组件类型相匹配
  • SlideHorizontal.vue 组件只允许向左/右滑动
  • SlideVertical.vue 组件只允许向上/下滑动

满足上述两个条件时拦截事件,不满足放行事件,交给上一级组件处理

js 复制代码
//检测在对应方向上能否允许滑动
let canSlideRes = canSlide(state)
//是否是往下(右)滑动
let isNext = state.type === SlideType.HORIZONTAL ? state.move.x < 0 : state.move.y < 0
if (canSlideRes) {
  if (canNext(state, isNext)) {
    //能滑动,那就把事件捕获,不能给父组件处理
    Utils.$stopPropagation(e)
    ...
    滑动逻辑
    ...
  }
}

结束滑动

js 复制代码
function slidePointerUp(e, state) {
  if (!state.isDown) return;
  let isHorizontal = state.type === SlideType.HORIZONTAL
  let isNext = isHorizontal ? state.move.x < 0 : state.move.y < 0
  if (state.next) {
    if (canNext(state, isNext)) {
      //结合时间、距离来判断是否成功滑动
      let endTime = Date.now()
      let gapTime = endTime - state.start.time
      let distance = isHorizontal ? state.move.x : state.move.y
      let judgeValue = isHorizontal ? state.wrapper.width : state.wrapper.height
      //1、距离太短,直接不通过
      if (Math.abs(distance) < 20) gapTime = 1000
      //2、距离太长,直接通过
      if (Math.abs(distance) > judgeValue / 3) gapTime = 100
      //3、若不在上述两种情况,那么只需要判断时间即可
      if (gapTime < 150) {
        if (isNext) state.localIndex++
        else state.localIndex--
      }
    }
  } 
  // 重置变量
  Utils.$setCss(el, 'transition-duration', `300ms`)
  let t = getSlideOffset(state, el)
  let dx1 = 0,dx2 = 0
  if (state.type === SlideType.HORIZONTAL) dx1 = t
  else dx2 = t
  Utils.$setCss(el, 'transform', `translate3d(${dx1}px, ${dx2}px, 0)`)
  ...
}

技术难点

  • 如何让滑动结束时的动画更丝滑?
    结合滑动时间、滑动距离来判断滑动下一条还是保持当前条
    1、距离太短,直接不通过
    2、距离太长,直接通过
    3、若不在上述两种情况,那么只需要判断时间即可,小于150毫秒以内就算是成功滑动

其他问题

PC 上滑动有图片的页面,图片"分叉"了:我们开始拖动它的"克隆"

这是因为浏览器有自己的对图片和一些其他元素的拖放处理。它会在我们进行拖放操作时自动运行,并与我们的拖放处理产生了冲突

禁用它:

js 复制代码
@dragstart="(e) => Utils.$stopPropagation(e)"

PC 上滑动结束后触发了click事件

问题分析

首先我们滑动是利用 pointerdown, pointermove, pointerup 三个事件组合形成的,但是 pointerup 执行之后, click 是一定会执行的,是无法避免的,是无法用preventDefault , stopPropagation , stopImmediatePropagation 阻止的, 因为pointer 事件和 click 事件本身就不是一个系列的,因此没有关系,所以当发生滑动之后,pointerup 一定会执行,click 也会在 pointerup 执行后执行

解决方案

我们设置一个全局变量

js 复制代码
window.isMoved = false

pointermove 事件中,将 window.isMoved 设为 true。然后在 pointerup 事件中,我们用一个定时器让这个变量在200毫秒之后发生改变为 false,因为 pointerup 之后 click 很快就触发了,不到200ms,因此可以保证变量还没有发生变化,click 事件里面去检测这个变量,如果是变化之前,那么不执行

如果 click 事件少还好说,直接复制几遍无所谓。

但是一般来说 click 事件在项目中使用还是挺多的,有没有什么一劳永逸的办法呢? 大部分监听 click 事件都是用 Vue@click 添加的,我们无法插手

这时给大家介绍一下 Proxy 这个对象了,Vue3 的双向绑定就用到了 Proxy 对象。

在项目入口,我们直接代理 HTMLElement.prototype.addEventListener 这个事件,代理了之后,Vue@click 语法糖添加事件时就会通知我们,这时再进行判断是不是 click 事件,是的话再判断 window.isMoved 的状态

js 复制代码
window.isMoved = false
HTMLElement.prototype.addEventListener = new Proxy(HTMLElement.prototype.addEventListener, {
  apply(target, ctx, args) {
    const eventName = args[0]
    const listener = args[1]
    if (listener instanceof Function && eventName === 'click') {
      args[1] = new Proxy(listener, {
        apply(target, ctx, args) {
          if (window.isMoved) return
          try {
            return target.apply(ctx, args)
          } catch (e) {
            console.error(`[proxyPlayerEvent][${eventName}]`, listener, e)
          }
        }
      })
    }
    return target.apply(ctx, args)
  }
})

设置了 overflow: auto 的页面在移动端不触发 pointermove 事件

再设置一个 touch-action:pan-y 就正常了

CSS 属性 touch-action 用于设置触摸屏用户如何操纵元素的区域 (例如,浏览器内置的缩放功能), pan-y 启用单指垂直平移手势

总结

核心代码加上注释一共217行,我们实现了一个可以在 PCMobile 上通用,并且可以无限嵌套的轮播组件

结束

以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~

相关推荐
gnip1 小时前
链式调用和延迟执行
前端·javascript
SoaringHeart1 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.2 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu2 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss2 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师2 小时前
React面试题
前端·javascript·react.js
木兮xg2 小时前
react基础篇
前端·react.js·前端框架
ssshooter2 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘3 小时前
HTML--最简的二级菜单页面
前端·html
yume_sibai3 小时前
HTML HTML基础(4)
前端·html