阅读 1668

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

简介

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

可以看出主要分为滑块轨道部分和滑块按钮这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个带表鼠标点击点距离浏览器可视区域的左侧和上侧的值,不包括滚动条,也就是客户区坐标

如上图,注意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以及滚动条宽度·

注意它和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);
      },
复制代码

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

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

中间红色中轴线平分蓝色条,当鼠标点击到绿色箭头区域时实际上该移动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更新位置