Element原始碼分析系列7-InputNumber(數字輸入框)

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

簡介

數字輸入框,如下圖,就是一個有著加減按鈕的input而已,多用於購物車商品數目新增減少,這個輸入框元件初看上去應該不是很難,但是Element的具體實現卻有很多值得學習的地方,看完原始碼才感覺真難!官網程式碼點此

Element原始碼分析系列7-InputNumber(數字輸入框)

數字輸入框的html結構

這個元件的html結構較為簡單,第一眼看上去我會以為是外層一個div,內層一個input,左右各一個span作為按鈕,檢視原始碼後也確實是這樣,簡化後的html結構如下

<div class='el-input-number'>
    <span class="el-input-number__decrease"></span>
    <span class="el-input-number__increase"></span>
    <el-input></el-input>
</div>
複製程式碼

前2個span是加和減的按鈕,最後的<el-input>是之前封裝的輸入框元件,注意不是原生的input,這裡值得一提的是2個span都是絕對定位,且<el-input>的左右padding都是50px,如下圖

Element原始碼分析系列7-InputNumber(數字輸入框)
也就是說這裡的2個加減按鈕是放在input的padding位置上的,是個包含關係而不是並排關係,2個span絕對定位,左邊的left:1,右邊的right:1,這種實現方式的好處我覺得是這樣,如下圖

Element原始碼分析系列7-InputNumber(數字輸入框)
當輸入框獲得焦點,輸入框的border會高亮,給人一種這3部分是一個整體的感覺,css處理起來很簡單,如果是3個部分並排,則還要單獨處理左右2個span的border

具體各部分分析

先來看外層的div

<div
    @dragstart.prevent
    :class="[
      'el-input-number',
      inputNumberSize ? 'el-input-number--' + inputNumberSize : '',
      { 'is-disabled': inputNumberDisabled },
      { 'is-without-controls': !controls },
      { 'is-controls-right': controlsAtRight }
    ]">
複製程式碼

第一行@dragstart.prevent第一眼看到這個我是懵逼的!這句話表明禁止了div的預設拖動行為,這裡不是很明白,首先如果div要被拖動的話得設定draggable="ture"才行,而且為啥要禁止拖動?我試了下去掉這句話,再拖動這個元件

Element原始碼分析系列7-InputNumber(數字輸入框)
發現當你選中input中的數字時可以拖動數字出去,上圖下面的淺色數字就是拖動出去的樣子,還有個滑鼠禁止的圖案沒能夠截圖,加上draggable="ture"就不能拖動選中的數字了
然後div中class部分的'el-input-number'規定了外層div的基本類,如下

Element原始碼分析系列7-InputNumber(數字輸入框)
可見div被設定為inline-block內聯元素,然後設定了寬度,因為元件寬度是不會隨著內容的變化而變化,所以定死了寬度,接下來3個類分別控制元件是否禁用(禁用邏輯前面幾篇已經分析過了),是否顯示加減按鈕,是否將按鈕放置於右側,見下圖

Element原始碼分析系列7-InputNumber(數字輸入框)
這3個類是否新增都是由使用者傳入對應的prop來實現,上圖一個令我搞了好半天的scss程式碼就是,上圖中右下角的減按鈕

@include when(controls-right) {
@include e(decrease) {
      right: 1px;
      bottom: 1px;
      top: auto;
      left: auto;
      border-right: none;
      border-left: $--border-base;
      border-radius: 0 0 $--border-radius-base 0;
    }
}
複製程式碼

這裡的意思是當controls-right類被加上後,decrease這個類的css變化為上面的內容,也就是將減按鈕從原本的左側放置到右下角,我開始不明白這裡的top:auto,left:auto是幹嘛的,後來控制檯除錯得知,因為decrease類原本的top是1px,left是1px,當controls-right類被加上後,必須得設定top,left為auto,讓瀏覽器自動計算top和left,否則就無法覆蓋原本的top:1px,left:1px。另外一個值得一提的是,這裡的加減按鈕的height是如下設定的

height: auto;
line-height: #{($--input-height - 2) / 2};
複製程式碼

指定height為auto,通過設定line-height值為輸入框高度的一半減去border寬度來撐開高度,如果直接設定height為高度的一半也應該可以吧??然後輸入框內的文字居中就是text-align:center實現

接下來看關鍵的加減按鈕的邏輯實現,html程式碼如下,這個按鈕是span實現的,不是原生button

<span
      class="el-input-number__decrease"
      role="button"
      v-if="controls"
      v-repeat-click="decrease"
      :class="{'is-disabled': minDisabled}"
      @keydown.enter="decrease">
      <i :class="`el-icon-${controlsAtRight ? 'arrow-down' : 'minus'}`"></i>
</span>
複製程式碼

role屬性作用是告訴Accessibility類應用(比如螢幕朗讀程式,為盲人提供的訪問網路的便利程式),這個元素所扮演的角色,主要是供殘疾人使用。使用role可以增強文字的可讀性和語義化,然後v-if的controls是個bool值,是使用者傳入的prop,用來控制是否顯示該按鈕,然後:class控制了該按鈕是否顯示禁用樣式,@keydown.enter又讓我疑惑了,這是在監聽enter鍵按下,Vue官網相關的說明是給input加上這個事件,在input獲得焦點時按下enter會觸發對應的事件,但是為啥要給span也加個@keydown.enter,我試了點選enter沒有任何反應,總之這裡沒搞明白

然後發現沒有這個按鈕沒有@click事件,所有的點選處理邏輯都放在了v-repeat-click="decrease"裡面,這裡除了單擊操作會使數字增加減少外,還有滑鼠一直按著不放會快速增加減少數字,所有的邏輯都通過Vue中的自定義指令(directives)來實現,自定義指令通常用來對底層dom元素進行操作,觸發特定的邏輯。在directives屬性裡進行宣告

directives: {
      repeatClick: RepeatClick
}
複製程式碼

這個key(repeatClick)就對應v-repeat-click,value(RepeatClick)是import進來的方法,程式碼見下面

import { once, on } from 'element-ui/src/utils/dom';
export default {
  bind(el, binding, vnode) {
    let interval = null;
    let startTime;
    const handler = () => vnode.context[binding.expression].apply();
    const clear = () => {
      if (new Date() - startTime < 100) {
        handler();
      }
      clearInterval(interval);
      interval = null;
    };

    on(el, 'mousedown', (e) => {
      if (e.button !== 0) return;
      startTime = new Date();
      once(document, 'mouseup', clear);
      clearInterval(interval);
      interval = setInterval(handler, 100);
    });
  }
};
複製程式碼

這段程式碼就稍微複雜點,首先要熟悉Vue的自定義指令的內容,自定義指令會提供幾個鉤子函式,用來在特定的時機觸發特定的邏輯,見下圖

Element原始碼分析系列7-InputNumber(數字輸入框)
這裡使用了bind鉤子函式,可以理解為初始化呼叫一次,你想想這個指令內肯定是給元素繫結單擊事件,所以只需要在bind內呼叫一次即可,然後bind的三個引數el,binding,vnode分別代表可操作的dom,一個binding物件,提供各種資訊,和Vue編譯生成的虛擬節點 binding物件如下

Element原始碼分析系列7-InputNumber(數字輸入框)
bind這個鉤子函式內的邏輯需要觸發讓輸入框內數字加減的方法,這個方法寫在元件的methods內,那麼如何得到這個方法呢,下面這句就能得到

const handler = () => vnode.context[binding.expression].apply();
複製程式碼

這句話我只能說太高階,得去看原始碼才能寫出來,首先vnode是vue生成的虛擬節點,就是一個js物件而已,裡面屬性很多,那麼context又是啥,翻看vue原始碼得知vnode的結構如下

Element原始碼分析系列7-InputNumber(數字輸入框)
context是一個Component型別的資料結構,這個Component是flow定義的結構,具體可看vue原始碼中的flow內的內容,Component就是元件,所以這個context就是該vnode所在的元件上下文,再來看binding.expression,官網說這就是v-repeat-click="decrease"中的decrease方法,這個方法寫在元件的methods內,那麼context[binding.expression]就是context['decrease']因此就拿到了元件內的decrease方法,類似於在元件中使用this.decrease一樣,然後最後的apply()就很奇怪了,apply的用法是引數的第一個表示要執行的目標物件,如果為null或者undefined則表示在window上呼叫該方法,這裡沒有引數,那就是undefined,所以是在window上執行,這個我也不確定到底說的對不對,我把這句話改為

const handler = () => vnode.context[binding.expression].apply(vnode);
複製程式碼

也沒出現錯誤,這裡也沒搞清楚為啥直接apply()就行,我再把上面的改成下面這種,也就是直接執行函式,也沒報錯,一切正常

const handler = () => vnode.context[binding.expression]()
複製程式碼

回到bind方法的邏輯,發現這裡並沒有任何的click出現,也就是說沒有繫結單擊滑鼠的事件,這裡因為要處理按下去連續觸發decrease方法,所以把單擊和連續按下都糅合到一起了,如下

on(el, 'mousedown', (e) => {
      if (e.button !== 0) return;
      startTime = new Date();
      once(document, 'mouseup', clear);
      clearInterval(interval);
      interval = setInterval(handler, 100);
});
複製程式碼

on這個方法來自於原始碼外層的目錄,因為其他元件也能用到,所以抽離成一個公共方法放到util目錄下。先看on的程式碼

export const on = (function() {
  if (!isServer && document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false);
      }
    };
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler);
      }
    };
  }
})();
複製程式碼

這個方法就是給元素繫結事件,if-else處理了相容性的情況,attachEvent是ie的方法,addEventListener是其他主流瀏覽器的方法。on的第三個引數就是事件處理函式,on中第一句if (e.button !== 0) returne.button是按下了滑鼠的哪個鍵

Element原始碼分析系列7-InputNumber(數字輸入框)
不等於0則是說明按下的不是左鍵,因為一般只處理左鍵的點選事件,注意onclick只響應滑鼠左鍵的按下,而onmousedown則響應3個鍵的按下,所以這裡要區分。

on最後一句interval = setInterval(handler, 100)設定了定時器定時執行handler方法從而每隔0.1s觸發一次數字增加或減少事件,然後我們思考,按下去滑鼠時給dom元素新增了事件:定時執行handler,那麼在滑鼠抬起時肯定要銷燬這個定時器,否則將會無限觸發handler方法,造成數字一直增加或減少,因此once(document, 'mouseup', clear)這句話就是在滑鼠抬起時銷燬定時器,先看clear方法

const clear = () => {
      if (new Date() - startTime < 100) {
        handler();
      }
      clearInterval(interval);
      interval = null;
    };
複製程式碼

裡面就是clearInterval銷燬定時器,前面的if邏輯很關鍵,在按下滑鼠時記錄一個時間,抬起滑鼠時檢測當前時間 - 按下時的時間 < 100毫秒,如果是則觸發一次點選,如果不寫這個if,則無法實現單擊操作,因為如果不寫,由於interval = setInterval(handler, 100),在按下後100毫秒後才會觸發一次點選,則在100毫秒內抬起滑鼠時interval已經被clear了。最後注意下once(document, 'mouseup', clear)once是隻觸發一次的高階函式,程式碼如下

export const once = function(el, event, fn) {
  var listener = function() {
    if (fn) {
      fn.apply(this, arguments);
    }
    off(el, event, listener);
  };
  on(el, event, listener);
};
複製程式碼

這就是觀察者模式裡面的once的寫法,本質上是複用on事件,只不過on的第三個引數加了修改,listener裡會執行fn一次,然後就用off方法移除listener,因此達到了只執行一次的目的。還有個注意的點,once方法的第一個引數是document,這個也很關鍵,你可能以為在加減按鈕上繫結onmousedown就應該在加減按鈕上繫結onmouseup,這樣做就會出bug,考慮一種情況,當你滑鼠在加減按鈕上按下時,然後移動滑鼠到按鈕外,再放開滑鼠,此時會發現數字還在增加,這就是bug,因此要在document這個最外層的dom元素上繫結mouseup,這樣mouseup事件總能被響應,否則亂移動滑鼠就會造成數字一直增加

分析不動了,主要難點已經寫完了,剩下的精度屬性和step其實也不難,總之要搞懂所有的程式碼很難,只能關注部分核心邏輯

相關文章