日常学习开发记录-slider组件

从零开始实现一个优雅的Slider滑块组件

前言

在Web开发中,滑块组件是一个常见的UI控件,用于数值范围的选择。本文将带领大家从零开始实现一个类似Element UI的Slider组件,我们将采用渐进式开发的方式,从基础功能开始,逐步添加更多特性。

一、基础实现

1. 组件结构设计

首先,我们需要设计一个基础的滑块组件结构:

<template>
  <div class="my-slider">
    <div class="my-slider__runway">
      <div class="my-slider__bar"></div>
      <div class="my-slider__button-wrapper">
        <div class="my-slider__button"></div>
      </div>
    </div>
  </div>
</template>

这个结构包含:

  • my-slider: 组件容器
  • my-slider__runway: 滑块轨道
  • my-slider__bar: 已选择区域的进度条
  • my-slider__button-wrapper: 滑块按钮容器
  • my-slider__button: 可拖动的滑块按钮

2. 基础样式实现

<style lang="scss" scoped>
  .my-slider {
    width: 100%;
    height: 10px;
    cursor: pointer;
    &__runway {
      width: 100%;
      height: 100%;
      border-radius: 5px;
      background-color: #f0f0f0;
      position: relative;
      .my-slider__bar {
        position: absolute;
        top: 0;
        left: 0;
        height: 100%;
        border-radius: 5px;
      }
      .my-slider__button-wrapper {
        height: 36px;
        width: 36px;
        position: absolute;
        top: -13px;
        transform: translateX(-50%);
        display: flex;
        align-items: center;
        justify-content: center;
        .my-slider__button {
          height: 16px;
          width: 16px;
          border-radius: 50%;
          border: 2px solid #007bff;
          background-color: #fff;
          transition: transform 0.2s;
        }
        &:hover {
          cursor: grab;
          .my-slider__button {
            transform: scale(1.2);
          }
        }
      }
    }
  }
</style>

结果:
在这里插入图片描述

3. 基础交互实现

<template>
  <div class="my-slider" :class="{ disabled: disabled }">
    <div class="my-slider__runway" @click="handleSliderClick" ref="slider">
      <div class="my-slider__bar" :style="barStyle"></div>
      <div class="my-slider__button-wrapper" :class="{ disabled: disabled }" :style="wrapperStyle">
        <div class="my-slider__button"></div>
      </div>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'MySlider',
    props: {
      min: {
        type: Number,
        default: 0,
      },
      max: {
        type: Number,
        default: 100,
      },
      value: {
        type: [Array, Number],
        default: 0,
      },
      disabled: {
        type: Boolean,
        default: false,
      },
      step: {
        type: Number,
        default: 1,
      },
    },
    data() {
      return {
        currentValue: this.value,
        sliderSize: 1, // 滑块大小
      }
    },
    computed: {
      // 滑块的样式,高亮展示已移动的区域(单个滑块-左侧,多个滑块-中间高亮)
      barStyle() {
        return {
          width: `${this.currentValue}%`,
          left: `0%`,
        }
      },
      wrapperStyle() {
        return {
          left: `${this.currentValue}%`,
        }
      },
      precision() {
        //确定 min、max 和 step 中最大的小数位数
        let precisions = [this.min, this.max, this.step].map(item => {
          let decimal = ('' + item).split('.')[1]
          return decimal ? decimal.length : 0
        })
        return Math.max.apply(null, precisions)
      },
    },
    mounted() {
      this.resetSliderSize()
    },
    methods: {
      handleSliderClick(event) {
        if (this.disabled) return
        const sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().left
        this.setPosition(((event.clientX - sliderOffsetLeft) / this.sliderSize) * 100)
      },
      setPosition(percentage) {
        //percentage为百分比位置
        this.currentValue = this.min + ((this.max - this.min) * percentage) / 100
        //每步的步长 max 50 min 0 ,每步步长 100 / 50 = 2
        const lengthPerStep = 100 / ((this.max - this.min) / this.step)
        //根据当前滑块的百分比位置(percentage)和每一步的长度(lengthPerStep),计算出当前所在的步数(steps) 四舍五入
        const steps = Math.round(percentage / lengthPerStep)
        //当前显示值 步长 * 步数* 每步的步长+最小值
        let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min

        value = parseFloat(value.toFixed(this.precision))
        this.currentValue = value
        //this.$emit('update:value', this.currentValue)
        //v-model 默认监听的是 input 事件,而不是 update:value 事件
        this.$emit('input', this.currentValue)
      },
      resetSliderSize() {
        this.sliderSize = this.$refs.slider.offsetWidth
      },
    },
  }
</script>



结果:
在这里插入图片描述
实现思路:

1. 模板结构
外层容器:<div class="my-slider">,用于包裹整个滑块组件,支持根据 disabled 属性动态添加禁用样式。
滑道:<div class="my-slider__runway">,表示滑块的背景轨道,点击滑道可以快速定位滑块位置。
滑块高亮区域:<div class="my-slider__bar">,表示滑块已移动的区域,宽度根据 currentValue 动态计算。
滑块按钮:<div class="my-slider__button-wrapper">,包含一个圆形按钮,用于拖动滑块,支持禁用状态样式。
2. Props 属性
min:滑块的最小值,默认 0。
max:滑块的最大值,默认 100。
value:滑块的当前值,支持数字或数组类型,默认 0。
disabled:是否禁用滑块,默认 false。
step:滑块的步长,默认 13. 数据与计算属性
currentValue:滑块的当前值,初始值为 props.value。
sliderSize:滑道的宽度,用于计算滑块的百分比位置。
barStyle:计算滑块的样式,动态设置高亮区域的宽度和位置。
wrapperStyle:计算滑块按钮的样式,动态设置按钮的左侧位置。
precision:计算 min、max 和 step 中最大的小数位数,用于确保数值精度。
4. 方法
handleSliderClick(event):处理滑道点击事件,计算点击位置的百分比并设置滑块位置。
setPosition(percentage):根据百分比位置计算滑块的当前值,并触发 input 事件更新父组件的 v-model 绑定值。
resetSliderSize():在组件挂载时重置滑道的宽度。
5. 样式
滑道:灰色背景,圆角矩形。
高亮区域:蓝色背景,表示滑块已移动的区域。
滑块按钮:圆形按钮,支持悬停放大效果,禁用状态下变为灰色。
禁用状态:滑道和高亮区域变为灰色,按钮不可拖动。
6. 交互逻辑
点击滑道:快速定位滑块到点击位置。
拖动滑块:通过 setPosition 方法动态更新滑块位置,并触发 input 事件。
步长控制:根据 step 属性调整滑块的移动步长,确保滑块位置符合步长要求。
禁用状态:当 disabled 为 true 时,禁止所有交互操作。
7. 事件
input 事件:当滑块值发生变化时触发,用于实现 v-model 双向绑定。

主要是在于动态style的计算达到视觉上的效果。

二、功能增强

1. 添加拖动功能

<template>
  <div class="my-slider" :class="{ disabled: disabled }">
    <div class="my-slider__runway" @click="handleSliderClick" ref="slider">
      <div class="my-slider__bar" :style="barStyle"></div>
      <div
        class="my-slider__button-wrapper"
        :class="{ disabled: disabled, dragging: dragging }"
        :style="wrapperStyle"
        @mousedown="onButtonDown"
        @touchstart="onButtonDown"
        ref="button"
      >
        <div class="my-slider__button"></div>
      </div>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'MySlider',
    ///
    data() {
      return {
        currentValue: this.value, // 当前值
        sliderSize: 1, // 滑块大小
        dragging: false, // 是否正在拖拽
        startX: 0, // 开始拖拽时的 x 坐标
        currentX: 0, // 当前拖拽时的 x 坐标
        startPosition: 0, // 开始拖拽时的位置
        newPosition: null, // 新位置
        oldValue: this.value, // 旧值
      }
    },
    computed: {
      // 滑块的样式,高亮展示已移动的区域(单个滑块-左侧,多个滑块-中间高亮)
      barStyle() {
        return {
          width: `${this.currentValue}%`,
          left: `0%`,
        }
      },
      wrapperStyle() {
        return {
          left: `${this.currentValue}%`,
        }
      },
      precision() {
        //确定 min、max 和 step 中最大的小数位数
        let precisions = [this.min, this.max, this.step].map(item => {
          let decimal = ('' + item).split('.')[1]
          return decimal ? decimal.length : 0
        })
        return Math.max.apply(null, precisions)
      },
    },
    watch: {
      value(val) {
        this.currentValue = val
      },
    },
    mounted() {
      this.resetSliderSize()
    },
    methods: {
      /**
       * 点击滑块
       * @param {Event} event - 事件对象
       */
      handleSliderClick(event) {
        if (this.disabled) return
        // 防止点击滑块按钮时触发
        if (this.$refs.button && this.$refs.button.contains(event.target)) {
          return
        }
        const sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().left
        this.setPosition(((event.clientX - sliderOffsetLeft) / this.sliderSize) * 100)
        this.emitChange()
      },
      onButtonDown(event) {
        if (this.disabled) return
        event.preventDefault() // 阻止默认行为
        this.dragging = true // 标记开始拖动

        // 处理触屏事件
        if (event.type === 'touchstart') {
          event.clientX = event.touches[0].clientX
        }

        // 记录初始位置
        this.startX = event.clientX
        this.startPosition = parseFloat(this.currentValue)
        this.newPosition = this.startPosition

        // 添加全局事件监听
        window.addEventListener('mousemove', this.onDragging)
        window.addEventListener('touchmove', this.onDragging)
        window.addEventListener('mouseup', this.onDragEnd)
        window.addEventListener('touchend', this.onDragEnd)
        window.addEventListener('contextmenu', this.onDragEnd)

        this.resetSliderSize() // 重新计算滑块尺寸
      },
      /**
       * 拖拽中
       */
      onDragging(event) {
        if (this.dragging) {
          // 获取当前鼠标位置
          let clientX
          if (event.type === 'touchmove') {
            clientX = event.touches[0].clientX
          } else {
            clientX = event.clientX
          }

          // 计算移动距离并转换为百分比
          const diff = ((clientX - this.startX) / this.sliderSize) * 100
          // 计算新位置
          this.newPosition = this.startPosition + diff
          // 更新滑块位置
          this.setPosition(this.newPosition)
        }
      },
      /**
       * 拖拽结束
       */
      onDragEnd() {
        if (this.dragging) {
          // 使用setTimeout确保在mouseup事件之后执行
          setTimeout(() => {
            this.dragging = false
            this.setPosition(this.newPosition)
            this.emitChange() // 触发change事件
          }, 0)

          // 移除所有事件监听
          window.removeEventListener('mousemove', this.onDragging)
          window.removeEventListener('touchmove', this.onDragging)
          window.removeEventListener('mouseup', this.onDragEnd)
          window.removeEventListener('touchend', this.onDragEnd)
          window.removeEventListener('contextmenu', this.onDragEnd)
        }
      },
      /**
       * 设置滑块位置
       * @param {number} position - 滑块位置 0-100
       */
      setPosition(position) {
        if (position === null || isNaN(position)) return
        if (position < 0) {
          position = 0
        } else if (position > 100) {
          position = 100
        }

        //每步的步长 max 50 min 0 ,每步步长 100 / 50 = 2
        const lengthPerStep = 100 / ((this.max - this.min) / this.step)
        //根据当前滑块的百分比位置(percentage)和每一步的长度(lengthPerStep),计算出当前所在的步数(steps) 四舍五入
        const steps = Math.round(position / lengthPerStep)
        //当前显示值 步长 * 步数* 每步的步长+最小值
        let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min

        value = parseFloat(value.toFixed(this.precision))
        this.currentValue = value

        // 更新 v-model 绑定值,但不触发 change 事件
        this.$emit('input', this.currentValue)
      },
      emitChange() {
        // 拖动结束时触发 change 事件
        this.$emit('change', this.currentValue)
      },
      resetSliderSize() {
        this.sliderSize = this.$refs.slider.offsetWidth
      },
    },
  }
</script>



效果:
在这里插入图片描述

实现思路:

使用 mousedown/touchstart 开始拖动
使用 mousemove/touchmove 处理拖动过程
使用 mouseup/touchend 结束拖动

2. 支持范围选择

添加range方法,重点是拖动至重合时候的处理,要记住当前拖动的是哪一个滑块

   // 判断当前点击的是哪个滑块
        const target = event.target.closest('.my-slider__button-wrapper')
        if (target === this.$refs.button) {
          this.startPosition = this.firstValue
          this.currentSlider = 'first'
        } else if (target === this.$refs.button1) {
          this.startPosition = this.secondValue
          this.currentSlider = 'second'
        }

3. 添加垂直模式

通过prop属性vertical来判断是否开启垂直模式
在这里插入图片描述

三、高级特性

1. 键盘操作支持

@keydown.left,@keydown.right, @keydown.up,@keydown.down,根据键盘方向事件,更新调用setposition方法直接更新滑块位置

2. 禁用状态

.my-slider {
  &.is-disabled {
    cursor: not-allowed;
    opacity: 0.6;
    
    .my-slider__button-wrapper {
      cursor: not-allowed;
    }
  }
}

五、使用示例

最后实现的效果:

在这里插入图片描述

六、总结

通过这个渐进式的实现过程,我们完成了一个功能完整的Slider组件。主要特点包括:

  1. 基础功能:

    • 单滑块/双滑块支持
    • 自定义数值范围
    • 平滑的拖动效果
  2. 增强功能:

    • 刻度标记
    • 禁用状态
  3. 高级特性:

    • 键盘操作支持
    • 垂直模式
    • 自定义格式化
  4. 性能优化:

    • 防抖处理
    • 计算属性缓存

这个实现不仅满足了基本需求,还考虑到了用户体验、可访问性和性能优化等多个方面。通过这样的渐进式开发,我们可以确保每一步都有坚实的基础,同时逐步增加功能复杂度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值