Element源码分析系列10 - Slider(滑块)

栏目: 编程工具 · 发布时间: 6年前

内容简介:滑块组件总体来说还是比较简单的,但是还是涉及到了很多原生的js知识,下图是一个最基本的滑块组件可以看出主要分为滑块轨道部分和滑块按钮这2大部分,而滑块轨道已滑过的蓝色部分也是一个部分,包含在滑块轨道内,然后上方的数字是Element的tooltip组件对于上面的组件,鼠标按住滑块按钮拖动便可以进行滑动,然后点击滑块轨道也能够将滑块移动到指定位置,因此主要逻辑就是拖动的实现和点击轨道的逻辑,官网代码点此

滑块组件总体来说还是比较简单的,但是还是涉及到了很多原生的js知识,下图是一个最基本的滑块组件

Element源码分析系列10 - Slider(滑块)

可以看出主要分为滑块轨道部分和滑块按钮这2大部分,而滑块轨道已滑过的蓝色部分也是一个部分,包含在滑块轨道内,然后上方的数字是Element的tooltip组件

对于上面的组件,鼠标按住滑块按钮拖动便可以进行滑动,然后点击滑块轨道也能够将滑块移动到指定位置,因此主要逻辑就是拖动的实现和点击轨道的逻辑,官网代码点此

组件的html结构

简化后的html结构如下

<div class="el-slider" ...
    //数字输入框
    <el-input-number v-if="showInput">
    </el-input-number>
    //滑块轨道
    <div class="el-slider__runway"
        //已经滑过的轨道
        <div  class="el-slider__bar" :style="barStyle">
        </div>
        //第一个滑块按钮
        <slider-button></slider-button>
        //第二个滑块按钮
        <slider-button></slider-button>
        //滑块轨道的间断点
        <div class="el-slider__stop"></div>
      </div>
    </div>
</div>
复制代码

上面的结构看着多,其实大多都是附属结构,上面的输入框就是由用户选项开启,然后有2个按钮,主要是用于范围选择,一般情况只用第一个按钮,最后的间断点其实也很少用到,上面的 <slider-button> 是单独的一个组件,因为这个组件会涉及到很多东西,所以单独做成了一个组件

再简单分析下css,由图中可以推断出蓝色部分的已滑过背景的div肯定是绝对定位的,然后滑块按钮也是绝对定位,而滑块轨道相对定位,通过改变蓝色部分的width来改变其长度,滑块按钮的位置是由left来确定,是个百分比

滑块按钮源码分析

首先先看下这个滑块组件的用法,最基础的组件仅仅需要如下代码就行

<el-slider v-model="value1"></el-slider>
复制代码

value1是data中的值,当滑动滑块时这个值也会改变。我们先从slider-button这个按钮组件进行分析,因为它才是核心,该组件的代码200多行,可见不简单啊,仅仅一个子组件就那么多,html结构如下

<template>
  <div
    class="el-slider__button-wrapper"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
    @mousedown="onButtonDown"
    @touchstart="onButtonDown"
    :class="{ 'hover': hovering, 'dragging': dragging }"
    :style="wrapperStyle"
    ref="button"
    tabindex="0"
    @focus="handleMouseEnter"
    @blur="handleMouseLeave"
    @keydown.left="onLeftKeyDown"
    @keydown.right="onRightKeyDown"
    @keydown.down.prevent="onLeftKeyDown"
    @keydown.up.prevent="onRightKeyDown"
  >
    <el-tooltip
      placement="top"
      ref="tooltip"
      :popper-class="tooltipClass"
      :disabled="!showTooltip">
      <span slot="content">{{ formatValue }}</span>
      <div class="el-slider__button" :class="{ 'hover': hovering, 'dragging': dragging }"></div>
    </el-tooltip>
  </div>
</template>
复制代码

这是一个wrapper里面嵌套了一个div作为button的主体,最内层的div是我们看到的按钮,而外层的div是一个比较大一点的div,它用来响应点击事件等。先看@mouseenter和@mouseleave,这2个方法对应的处理函数就是用来处理鼠标移动到按钮上显示tooltip与否

handleMouseEnter() {
    this.hovering = true;
    this.displayTooltip();
  },
  handleMouseLeave() {
    this.hovering = false;
    this.hideTooltip();
  },
复制代码

接下来@mousedown="onButtonDown",@touchstart="onButtonDown"这2个都是处理鼠标按下和移动端按下的逻辑,因为拖动按钮首先是按下按钮再移动鼠标进行拖动,最终抬起鼠标,onButtonDown代码如下

onButtonDown(event) {
    if (this.disabled) return;
    event.preventDefault();
    this.onDragStart(event);
    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);
  },
复制代码

首先如果组件禁用则直接返回,然后是preventDefault防止触发默认事件,但是这里为啥要给这个按钮preventDefautl??它只是一个普通的div而已啊,很奇怪,难道是移动端的处理?第三句 this.onDragStart(event) 处理了点击开始的逻辑,代码如下

onDragStart(event) {
    this.dragging = true;
    this.isClick = true;
    if (event.type === 'touchstart') {
      event.clientY = event.touches[0].clientY;
      event.clientX = event.touches[0].clientX;
    }
    if (this.vertical) {
      this.startY = event.clientY;
    } else {
      this.startX = event.clientX;
    }
    this.startPosition = parseFloat(this.currentPosition);
    this.newPosition = this.startPosition;
  },
复制代码

当用户点击滑块按钮时,将标志变量dragging设为true,标志着进入了拖动状态,这个变量不能少,因为在mousemove中需要进行位置的更新,而mousemove中则要判断是否在移动状态,是在移动状态才能更新位置。第二句this.isClick变量代表此次按下鼠标是一次单纯的点击还是一次拖动滑块操作,后面会讲解。然后如果是移动端touch操作,则将event.clientX和event.clientY赋值为移动端的值,简单回顾下clientX和clientY,这2个带表鼠标点击点距离浏览器可视区域的左侧和上侧的值,不包括滚动条,也就是客户区坐标

Element源码分析系列10 - Slider(滑块)
如上图,注意clientX和offsetX的区别,offsetX指的是点击点距离点击元素的左侧的距离。这里为啥要获得clientX并保存在startX中呢,是因为滑动滑块最后抬起鼠标时,需要计算抬起鼠标时的clientX的值和startX之间的差,这个差就是x轴移动的距离。然后 this.startPosition = parseFloat(this.currentPosition)

将初始位置记录下到startPosition中,currentPosition是计算属性

currentPosition() {
    return `${ (this.value - this.min) / (this.max - this.min) * 100 }%`;
},
复制代码

上述代码说明currentPostion是个百分比,里面的this.value是该组件v-model中的value,也就是父组件中的firstValue,这个firstValue又是由用户传入到滑块组件的v-model中来的,这里有点绕,总之用户最初传入滑块组件的data会反映到这里来,然后给currentPostion一个初始值, 下面看一下拖动鼠标过程中的逻辑

onDragging(event) {
    if (this.dragging) {
      this.isClick = false;
      this.displayTooltip();
      this.$parent.resetSize();
      let diff = 0;
      if (event.type === 'touchmove') {
        event.clientY = event.touches[0].clientY;
        event.clientX = event.touches[0].clientX;
      }
      if (this.vertical) {
        this.currentY = event.clientY;
        diff = (this.startY - this.currentY) / this.$parent.sliderSize * 100;
      } else {
        this.currentX = event.clientX;
        diff = (this.currentX - this.startX) / this.$parent.sliderSize * 100;
      }
      this.newPosition = this.startPosition + diff;
      this.setPosition(this.newPosition);
    }
复制代码

首先必须判断是否在拖动状态中,如果不在则什么都不做,然后 this.isClick = false 将是否是点击操作这个flag记为false,说明一但开始拖动,那么就不是一次点击操作。接下来 this.displayTooltip() 用于显示tooltip.然后 this.$parent.resetSize() 调用了父组件的resetSize方法,父组件就是slider组件,这个reset方法用于计算父组件的宽度

resetSize() {
    if (this.$refs.slider) {
      this.sliderSize = this.$refs.slider[`client${ this.vertical ? 'Height' : 'Width' }`];
    }
  },
复制代码

this.$refs.slider 获取到了滑块轨道的dom元素,,然后后面 [`client${ this.vertical ? 'Height' : 'Width' }`] 获取到了它的客户区宽度或者高度,clientWidth表示元素的内部宽度,包含width,padding,不包含border和margin以及滚动条宽度·

Element源码分析系列10 - Slider(滑块)

注意它和offsetWidth的区别,offsetWidth多了border和滚动条的宽度。那么resetSize的作用就是获取滑块轨道的客户区宽度并保存在父元素的this.sliderSize中,那么作用是啥呢?后面就会用到

接着声明了一个diff变量,diff在下面被更新

this.currentX = event.clientX;
diff = (this.currentX - this.startX) / this.$parent.sliderSize * 100;
复制代码

diff算出来就是鼠标移动的距离占滑块轨道的百分比,注意可能是负值,这里就用到了sliderSize。后面一句 this.newPosition = this.startPosition + diff 则是声明了滑块按钮的新位置(百分比),它就是初始位置加上diff,这个好理解,同样这个值可能小于或者大于100,最后调用setPostion进行位置更新。所以拖动滑块的过程就是不断获取最新位置并进行位置更新操作。

来看setPostion具体干了啥

setPosition(newPosition) {
    if (newPosition === null || isNaN(newPosition)) return;
    if (newPosition < 0) {
      newPosition = 0;
    } else if (newPosition > 100) {
      newPosition = 100;
    }
    const lengthPerStep = 100 / ((this.max - this.min) / this.step);
    const steps = Math.round(newPosition / lengthPerStep);
    let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min;
    value = parseFloat(value.toFixed(this.precision));
    this.$emit('input', value);
    this.$nextTick(() => {
      this.$refs.tooltip && this.$refs.tooltip.updatePopper();
    });
    if (!this.dragging && this.value !== this.oldValue) {
      this.oldValue = this.value;
    }
  }
复制代码

第一个if表明如果newPosition为非数字的情况,则不做处理,那么什么情况下newPostion会不是数字呢?看了下可能是竖向模式下用户可以设置滑块轨道的高度,如果此时设置的值不当则可能出现非数字的情况。

第二个if else规定了newPosition只能在0-100间,当鼠标一直往左拖或者右拖时会出现newPostion<0或者>100的情况。然后 const lengthPerStep = 100 / ((this.max - this.min) / this.step) 计算出了每一个步长对应的滑块轨道长度的百分比,里面的max和min是滑块组件的最大值和最小值,滑块被均分为100份长度。 const steps = Math.round(newPosition / lengthPerStep) 计算出了一共需要的步数,向下取整。然后 let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min 一句话计算出了最终滑块的值,然后通过emit将该值传递给父组件,然后父组件继续emit将该值传递给滑块组件的父组件,从而更新了用户传入的v-model的值,下面是一个nextTick,因为值改变了就要更新tooltip,那么用nextTick是为了保证获取的数据是dom更新后的

然后上面的代码仅仅更新了用户传入的value,那么滑块的实际移动时怎么是实现的呢? :style="wrapperStyle" 滑块button的这个sytle绑定就是实现

wrapperStyle() {
    return this.vertical ? { bottom: this.currentPosition } : { left: this.currentPosition };
}
currentPosition() {
    return `${ (this.value - this.min) / (this.max - this.min) * 100 }%`;
},
复制代码

wrapperStyle是个计算属性,返回了currentPostion这个计算属性,currentPosition又是通过this.value来计算的,所以就明白了原因,用户拖动滑块时会把value通过emit传递给父组件,最终更新了用户传入的值,然后反过来又触发了 <slider-button> 的计算属性从而更新了wrapperStyle

//slider组件的代码
<slider-button
    :vertical="vertical"
    v-model="firstValue"
    :tooltip-class="tooltipClass"
    ref="button1">
</slider-button>
复制代码
//slider组件的代码
watch: {
      value(val, oldVal) {
        if (this.dragging ||
          Array.isArray(val) &&
          Array.isArray(oldVal) &&
          val.every((item, index) => item === oldVal[index])) {
          return;
        }
        this.setValues();
      },

复制代码

上述2段代码说明了数据传递的流程。有点绕,firstValue是在setValues这个方法里被更新的,而滑块组件对用户传入的v-model的value进行了watch,当value变化时就触发setValues方法从而更新firstValue,进而更新滑块按钮的位置。

然后滑块按钮这个内置组件的最外层div里面居然绑定了键盘操作以及丢失焦点和获得焦点方法

<div
    class="el-slider__button-wrapper"
    ...
    tabindex="0"
    @focus="handleMouseEnter"
    @blur="handleMouseLeave"
    @keydown.left="onLeftKeyDown"
    @keydown.right="onRightKeyDown"
    @keydown.down.prevent="onLeftKeyDown"
    @keydown.up.prevent="onRightKeyDown"
  >
复制代码

主要这里设置了tabindex属性,为0表示最后才能通过tab键访问到该div,这么一来通过键盘的上下左右键也能够控制滑块了,注意focus和blur方法只有在tabindex属性存在且不为-1时才能触发(通过tab触发),看下onLeftKeyDown的代码

onRightKeyDown() {
    if (this.disabled) return;
    this.newPosition = parseFloat(this.currentPosition) + this.step / (this.max - this.min) * 100;
    this.setPosition(this.newPosition);
  },
复制代码

键盘按下左键时会使滑块组件的值减少一个步长的长度, this.step / (this.max - this.min) * 100 计算出了一个步长占滑块总长度的百分比(0-100间的整数),然后听过setPosition进行值的更新

滑块轨道代码分析

当用户点击滑块轨道时可以将滑块按钮移动到指定位置,这需要给滑块轨道绑定click事件

<div class="el-slider__runway"
      :class="{ 'show-input': showInput, 'disabled': sliderDisabled }"
      :style="runwayStyle"
      @click="onSliderClick"
      ref="slider">
复制代码

下面进入onSliderClick方法

onSliderClick(event) {
    if (this.sliderDisabled || this.dragging) return;
    this.resetSize();
    if (this.vertical) {
      const sliderOffsetBottom = this.$refs.slider.getBoundingClientRect().bottom;
      this.setPosition((sliderOffsetBottom - event.clientY) / this.sliderSize * 100);
    } else {
      const sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().left;
      this.setPosition((event.clientX - sliderOffsetLeft) / this.sliderSize * 100);
    }
    this.emitChange();
},
复制代码

首先判断是否禁用或者是否在拖动中,如果是则直接返回。这个this.dragging的值是由子组件滑块按钮内部的dragging传递给父组件的,当点击滑块按钮时click事件会冒泡到滑块轨道上,所以这里需要判断。然后是计算滑块轨道长度(clientWidth,像素),接下来的if else是判断组件的方向,分为垂直和水平,如果是水平的话,则通过 getBoundingClientRect().left 获取滑块轨道距离客户区的左侧距离,然后用 event.clientX - sliderOffsetLeft 获得到鼠标点击位置到滑块轨道左侧的距离,也就是目标位置距离轨道左侧的距离,然后将其换算为百分比,最后通过setPosition更新位置。这里的setPosition在上面的分析中出现过,不过不是同一个,上面那个是子组件的setPosition,现在这个是父组件的setPosition

setPosition(percent) {
        const targetValue = this.min + percent * (this.max - this.min) / 100;
        if (!this.range) {
          this.$refs.button1.setPosition(percent);
          return;
        }
        let button;
        if (Math.abs(this.minValue - targetValue) < Math.abs(this.maxValue - targetValue)) {
          button = this.firstValue < this.secondValue ? 'button1' : 'button2';
        } else {
          button = this.firstValue > this.secondValue ? 'button1' : 'button2';
        }
        this.$refs[button].setPosition(percent);
      },
复制代码

这段代码值得研究,首先计算实际的目标值,这个值只用于选择范围的情况下,所谓选择范围就是如下的模式

Element源码分析系列10 - Slider(滑块)

就是有2个按钮,控制最小值和最大值。当!this.range也就是不是选择范围模式时,直接调用子组件button1的setPosition设置按钮的位置。后面的if else就比较绕了,这里涉及到4种情况,先看下图

Element源码分析系列10 - Slider(滑块)
中间红色中轴线平分蓝色条,当鼠标点击到绿色箭头区域时实际上该移动minValue那个按钮,如果是红色箭头区域处,该移动maxValue按钮,这就是通过 Math.abs(this.minValue - targetValue) < Math.abs(this.maxValue - targetValue) 来确定。然后里面又是个三目运算符, button = this.firstValue < this.secondValue ? 'button1' : 'button2'

这里就很奇怪了,firstValue和secondValue指的是2个按钮的对应的值,分别绑定button1和button2,初始状态下firstValue对应用户传入范围的较小值,secondValue为较大值。

注意到你可以将左侧的firstValue的button1一直往右侧拖动,直到它大于了右侧的secondValue的button2。这个时候你再点击绿色箭头区域,那么移动的按钮肯定应该是左侧的button2,否则就会出bug。反之移动button1.所以这个三目运算符不能少!最后通过调用子组件的setPosition更新位置


以上所述就是小编给大家介绍的《Element源码分析系列10 - Slider(滑块)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Rework

Rework

Jason Fried、David Heinemeier Hansson / Crown Business / 2010-3-9 / USD 22.00

"Jason Fried and David Hansson follow their own advice in REWORK, laying bare the surprising philosophies at the core of 37signals' success and inspiring us to put them into practice. There's no jarg......一起来看看 《Rework》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具