Element原始碼分析系列10 - Slider(滑塊)

超級索尼發表於2018-08-31

簡介

滑塊元件總體來說還是比較簡單的,但是還是涉及到了很多原生的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更新位置

相關文章