日常学习开发记录-slider组件
- 从零开始实现一个优雅的Slider滑块组件
- 前言
- 一、基础实现
- 1. 组件结构设计
- 2. 基础样式实现
- 3. 基础交互实现
- 二、功能增强
- 1. 添加拖动功能
- 2. 支持范围选择
- 3. 添加垂直模式
- 三、高级特性
- 1. 键盘操作支持
- 2. 禁用状态
- 五、使用示例
- 六、总结
从零开始实现一个优雅的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) returnconst sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().leftthis.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 = 2const 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.minvalue = 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:滑块的步长,默认 1。
3. 数据与计算属性
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><divclass="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().leftthis.setPosition(((event.clientX - sliderOffsetLeft) / this.sliderSize) * 100)this.emitChange()},onButtonDown(event) {if (this.disabled) returnevent.preventDefault() // 阻止默认行为this.dragging = true // 标记开始拖动// 处理触屏事件if (event.type === 'touchstart') {event.clientX = event.touches[0].clientX}// 记录初始位置this.startX = event.clientXthis.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 clientXif (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 = falsethis.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)) returnif (position < 0) {position = 0} else if (position > 100) {position = 100}//每步的步长 max 50 min 0 ,每步步长 100 / 50 = 2const 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.minvalue = 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.firstValuethis.currentSlider = 'first'} else if (target === this.$refs.button1) {this.startPosition = this.secondValuethis.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组件。主要特点包括:
-
基础功能:
- 单滑块/双滑块支持
- 自定义数值范围
- 平滑的拖动效果
-
增强功能:
- 刻度标记
- 禁用状态
-
高级特性:
- 键盘操作支持
- 垂直模式
- 自定义格式化
-
性能优化:
- 防抖处理
- 计算属性缓存
这个实现不仅满足了基本需求,还考虑到了用户体验、可访问性和性能优化等多个方面。通过这样的渐进式开发,我们可以确保每一步都有坚实的基础,同时逐步增加功能复杂度。