Element原始碼分析系列7-Select(下拉選擇框)

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

簡介

Element的下拉選擇器示意圖如下

Element原始碼分析系列7-Select(下拉選擇框)
確實做的很漂亮,互動體驗非常好,html有原生的選擇器<select>,但是太醜了,而且各瀏覽器樣式不統一,因此要做一個漂亮且實用的下拉選擇器必須自己模擬全部方法和結構,Element的下拉選擇器程式碼量非常大,僅select.vue一個檔案就快1000行,而且裡面是由Element的其他元件組合而成,算上其他元件的話,又得加上1000行,最後是這個選擇器引用了非常多的util以及第三方js,再加上這些至少得再加2000行,所以只能分析部分核心原理,下面是下拉選擇器的import

import Emitter from 'element-ui/src/mixins/emitter';
import Focus from 'element-ui/src/mixins/focus';
import Locale from 'element-ui/src/mixins/locale';
import ElInput from 'element-ui/packages/input';
import ElSelectMenu from './select-dropdown.vue';
import ElOption from './option.vue';
import ElTag from 'element-ui/packages/tag';
import ElScrollbar from 'element-ui/packages/scrollbar';
import debounce from 'throttle-debounce/debounce';
import Clickoutside from 'element-ui/src/utils/clickoutside';
import { addClass, removeClass, hasClass } from 'element-ui/src/utils/dom';
import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
import { t } from 'element-ui/src/locale';
import scrollIntoView from 'element-ui/src/utils/scroll-into-view';
import { getValueByPath } from 'element-ui/src/utils/util';
import { valueEquals } from 'element-ui/src/utils/util';
import NavigationMixin from './navigation-mixin';
import { isKorean } from 'element-ui/src/utils/shared';
複製程式碼

不過這些import裡面很多東西是值得學習的,官網程式碼點此

下拉選擇器的html結構

還是先來分析這個下拉選擇器的html結構,簡化後的html程式碼如下

<template>
    <div class="el-select" >
        <div class="el-select__tags"
        </div>
        
        <el-input></el-input>
        
        <transition>
            <el-select-menu>
            <el-select-menu>
        </transtion>
    </div>
</template>
複製程式碼

最外層一個div包裹所有子元素(相對定位),裡面第一個div是展示下拉選擇器的tag的包裹div,如下圖,這個div絕對定位,然後通過top:50%;transform:translateY(-50%)垂直居中於最外層的div內

Element原始碼分析系列7-Select(下拉選擇框)

然後第二個<el-input>是Element封裝的輸入元件,前面文章介紹過,這個輸入框寬度和最外層的div一樣,如下圖,右側的箭頭按鈕是放在其padding位置上

Element原始碼分析系列7-Select(下拉選擇框)
然後最後的<transtion>不是元件,是Vue的過渡動畫的標誌,不會渲染出來,裡面包裹著<el-select-menu>這也是Element封裝的元件,表示彈出的下拉選單,也是絕對定位,所以整個下拉元件只有中間的input是相對定位,其他都是絕對定位,而且要善於複用自己已有的元件,而不是又重頭寫

部分功能原始碼分析

如果要寫完所有功能,那至少得一週以上,所以只能寫一部分

下拉框主體操作流程邏輯梳理

下面分析下下拉框主體操作流程以及其中的資料傳遞過程
首先看下下拉框的用法,官網程式碼如下

<el-select v-model="value" placeholder="請選擇">
    <el-option
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value">
    </el-option>
</el-select>
複製程式碼

資料部分如下

<script>
  export default {
    data() {
      return {
        options: [{
          value: '選項1',
          label: '黃金糕'
        }, {
          value: '選項2',
          label: '雙皮奶'
        }]
        value: ''
      }
    }
  }
</script>
複製程式碼

可見最外層的<el-select>有一個v-model,這個是元件的v-model用法,具體參考官網,value初始為空,當選擇了下拉選單的某一項後,value變成那一項的值。<el-select>標籤內是用v-for迴圈出所有的options,<el-option>也是Element封裝的元件,可以明確上面肯定繫結了click事件,options由label和value組成,分別代表該下拉項的顯示文字和實際的值,而data中的options也提供了對應的key。

這裡注意下<el-option>是作為slot插槽被插入到<el-select>中的,因此在<el-select>需要有<slot>來承載內容,如果元件沒有包含一個 元素,則任何傳入它的內容都會被拋棄。檢視html程式碼,發現slot的位置如下

<el-select-menu
        <el-scrollbar>
          <el-option>
          </el-option>
          
          <slot></slot>
          
        </el-scrollbar>
        <p
         ...
        </p>
</el-select-menu>
複製程式碼

slot被包含在<el-scrollbar>這個滾動條元件內,這個元件的實現很考驗基本功,略複雜,程式碼點此,因此所有的option選項都會被放入滾動條元件內

當使用者點選初始狀態下的下拉框,觸發toggleMenu顯示出下拉選單,toggleMenu如下

toggleMenu() {
    if (!this.selectDisabled) {
      if (this.menuVisibleOnFocus) {
        this.menuVisibleOnFocus = false;
      } else {
        this.visible = !this.visible;
      }
      if (this.visible) {
        (this.$refs.input || this.$refs.reference).focus();
      }
    }
},
複製程式碼

由程式碼可知首先判斷是否禁用,如果是在禁用狀態下則不觸發事件,接著判斷this.menuVisibleOnFocus,這又是幹嘛的呢,仔細檢視原始碼得知,當時多選狀態下時,也就是下圖中可以多個tag並排,這時元件裡面的另一個輸入框(下圖游標處)會渲染出來,然後該輸入框會聚焦,此時下拉選單不需要隱藏(方便你檢視已有的條目),所以這裡進行了if判斷。this.visible = !this.visible然後這句就是在切換下拉選單的狀態

Element原始碼分析系列7-Select(下拉選擇框)

下拉選單顯示出來後,點選某個option,會關閉下拉選單且將這個值傳遞給父元件,先來看option元件的內容

<template>
  <li
    @mouseenter="hoverItem"
    @click.stop="selectOptionClick"
    class="el-select-dropdown__item"
    v-show="visible"
    :class="{
      'selected': itemSelected,
      'is-disabled': disabled || groupDisabled || limitReached,
      'hover': hover
    }">
    <slot>
      <span>{{ currentLabel }}</span>
    </slot>
  </li>
</template>
複製程式碼

很簡單,由li元素封裝而成,@mouseenter="hoverItem"這句話說明了當你滑鼠hover在某項上時觸發 hoverItem事件,這裡你可能會問,為啥要在滑鼠hover時做這件事?其實這裡有這個操作:當你滑鼠懸浮在某個option上時,按下enter鍵也能達到選中項的目的,當然單擊也行,所以在mouseenter時就要更新被hover的option,來看hoverItem的內容

hoverItem() {
    if (!this.disabled && !this.groupDisabled) {
      this.select.hoverIndex = this.select.options.indexOf(this);
    }
},
複製程式碼

???黑人問號!這是在幹嘛?僅僅是一條賦值語句,不慌,先看this.select是啥,搜尋後發現select在如下位置

inject: ['select'],
複製程式碼

它既不是一個prop也不是data,是依賴注入,依賴注入的核心思想是讓後代元件能夠訪問到祖先元件的內容,因為如果是父子元件則通過$parent就可以訪問父元件,但是爺爺元件呢?所以有了依賴注入,依賴注入的使用很簡單,在祖先元件內宣告如下provide屬性,value是祖先元件的方法或者屬性

provide: function () {
  return {
    xxMethod: this.xxMethod
  }
}
複製程式碼

然後在後代元件內宣告如下

inject: ['xxMethod']
複製程式碼

則在後代元件中可以使用xxMethod,回過頭來看option元件的依賴注入select,它的位置在祖先元件(不是父元件)<el-select>中,也就是在本文的下拉選擇器元件中,如下

 provide() {
      return {
        'select': this
      };
    },
複製程式碼

它返回了this,this就是指這個下拉選擇器元件的例項,因此就能通過this.select.hoverIndex下拉選擇器上的hoverIndex屬性,那麼繼續來分析this.select.hoverIndex = this.select.options.indexOf(this),這句話的意思是按下回車後,將滑鼠懸浮所在的option在options裡的序號賦值給hoverIndex,意思就是找到被懸浮的那個option在陣列中的序號,然後其餘的邏輯就在<el-select>裡處理了。前面說滑鼠hover時按下enter也能夠選中,這是怎麼實現的呢?可以猜到肯定在input上繫結了keydown.enter事件,原始碼裡input上有這麼一句

@keydown.native.enter.prevent="selectOption"
複製程式碼

這裡這麼多修飾符鬧哪樣?native修飾符是必須的,官網說在元件用v-on只能監聽自定義事件,要監聽原生的事件必須用native修飾,prevent是防止觸發預設enter事件,比如按下enter提交了表單之類的,肯定不行。然後看selectOption方法

 selectOption() {
        if (!this.visible) {
          this.toggleMenu();
        } else {
          if (this.options[this.hoverIndex]) {
            this.handleOptionSelect(this.options[this.hoverIndex]);
          }
        }
      },
複製程式碼

這裡就用到了hoverIndex來更新選中的項,接下來看handleOptionSelect是如何更新所選的項的,這個方法傳入了option例項

 handleOptionSelect(option, byClick) {
        if (this.multiple) {
          const value = this.value.slice();
          const optionIndex = this.getValueIndex(value, option.value);
          if (optionIndex > -1) {
            value.splice(optionIndex, 1);
          } else if (this.multipleLimit <= 0 || value.length < this.multipleLimit) {
            value.push(option.value);
          }
          this.$emit('input', value);
          this.emitChange(value);
          ...
        } else {
          this.$emit('input', option.value);
          this.emitChange(option.value);
          this.visible = false;
        }
        ...
      },
複製程式碼

這裡只保留核心邏輯,可以看出首先要判斷是否是多選狀態,因為多選狀態下<el-select v-model="value">v-model的value是個陣列,單選狀態下是一個單獨的值,如果是多選,首先獲得value的副本,這裡有必要搞清楚value是啥,其實value就是這個元件的一個prop,就是v-model語法糖拆分開來的產物,也就是上面的v-model中的value,也就是使用者傳入的data中的資料項,所以這個value變化了就會導致使用者的傳入的value變化。接著上面通過indexOf在value陣列中查詢是否存在option選項,如果存在則splice去除掉,不存在則push進來,讓後通過emit觸發父元件的input事件改變value,同時觸發父元件的change通知使用者我的值改變啦!如果是單選狀態,那就能簡單了,直接emit即可。

當直接滑鼠點選某個option時,觸發@click.stop="selectOptionClick"中的selectOptionClick

selectOptionClick() {
        if (this.disabled !== true && this.groupDisabled !== true) {
          this.dispatch('ElSelect', 'handleOptionClick', [this, true]);
        }
      },
複製程式碼

這個方法裡面用了通用的dispatch方法在<el-select>上觸發handleOptionClick事件,傳入當前option例項,這個dispatch其實就是完成了子元件向祖先元件傳遞事件的邏輯,在<el-select>肯定有一個on方法接收該事件,如下

this.$on('handleOptionClick', this.handleOptionSelect)
複製程式碼

可以看出這個handleOptionSelect和上面說的是一個方法,因此點選某一個option和按enter最終都會觸發這個方法從而更新value

綜上所述,這就是一個完整的流程邏輯描述

點選Select框外收起下拉選單

檢視最外層的div程式碼

<div
    class="el-select"
    :class="[selectSize ? 'el-select--' + selectSize : '']"
    @click.stop="toggleMenu"
    v-clickoutside="handleClose">
複製程式碼

這裡@click繫結了點選事件來切換選單的隱藏和顯示,下面的v-clickoutside="handleClose"是重點,這是個Vue的指令,handleClose裡面的邏輯就是this.visible = false設定選單的visible為false從而隱藏下拉選單,當滑鼠點選範圍在下拉元件外時,觸發這個handleClose,這是個很常見的需求,不過這裡的實現卻不是很簡單,核心思想就是給document繫結mouseup事件,然後在這個事件裡判斷點選的target是否包含在目標元件內. 這個指令對應的物件通過import Clickoutside from 'element-ui/src/utils/clickoutside'引入,因為很多元件都要用這個方法,所以給單獨抽離出去放在util目錄下,程式碼點此 進入該方法的bind方法內看到如下2句

!Vue.prototype.$isServer && on(document, 'mousedown', e => (startClick = e));
!Vue.prototype.$isServer && on(document, 'mouseup', e => {
  nodeList.forEach(node => node[ctx].documentHandler(e, startClick));
});
複製程式碼

這就給document繫結了滑鼠按下抬起事件(服務端渲染無效),按下時記錄一個按下的dom元素,抬起時遍歷所有有該指令的dom,然後執行documentHandler進行判斷,該方法如下

function createDocumentHandler(el, binding, vnode) {
  return function(mouseup = {}, mousedown = {}) {
    if (!vnode ||
      !vnode.context ||
      !mouseup.target ||
      !mousedown.target ||
      el.contains(mouseup.target) ||
      el.contains(mousedown.target) ||
      el === mouseup.target ||
      (vnode.context.popperElm &&
      (vnode.context.popperElm.contains(mouseup.target) ||
      vnode.context.popperElm.contains(mousedown.target)))) return;

    if (binding.expression &&
      el[ctx].methodName &&
      vnode.context[el[ctx].methodName]) {
      vnode.context[el[ctx].methodName]();
    } else {
      el[ctx].bindingFn && el[ctx].bindingFn();
    }
  };
}
複製程式碼

注意這個是由createDocumentHandler生成一個documentHandler,裡面的第一個if中的el.contains(mouseup.target),el.contains(mousedown.target)就通過原生的contains方法判斷點選處是否被el這個dom元素包含,如果是則return,如果不包含,也就是點選在下拉選單外,則執行vnode.context[el[ctx].methodName]()呼叫v-clickoutside="handleClose"中的handleClose方法隱藏下拉選單,el[ctx].methodName是在指令的bind方法裡初始化的,如下

bind(el, binding, vnode) {
    nodeList.push(el);
    const id = seed++;
    el[ctx] = {
      id,
      documentHandler: createDocumentHandler(el, binding, vnode),
      methodName: binding.expression,
      bindingFn: binding.value
    };
  },
複製程式碼

將expression賦值給methodName,ctx又是啥?ctx在最上面const ctx = '@@clickoutsideContext'這句話我覺得是給el這個dom加了個屬性,這個屬性名字2個@開頭,表示很特殊,不容易被覆蓋,然後這個屬性的值是一個物件,裡面儲存了很多資訊,這裡的邏輯大體是,在指令第一次被繫結到dom元素時,給dom元素加上要執行的方法等屬性,然後給document繫結mouseup事件,後來當使用者點選時取出對應的元素的dom進行判斷,如果判斷為true再取出該dom上之前繫結的方法進行執行

下拉選單的定位

你可能覺得這個下拉選單是絕對定位於輸入框,那就錯了,其實這個下拉框是新增在document.body上的

Element原始碼分析系列7-Select(下拉選擇框)
是不是很神奇,當初始狀態沒有點選選擇框時,這個下拉選單display:none,這時候是絕對定位且包含在<el-select>內,見下圖

Element原始碼分析系列7-Select(下拉選擇框)
然而當我們點選元件時,這個下拉選單就跑到body上了

Element原始碼分析系列7-Select(下拉選擇框)
為什麼要這樣做?官網有說明下拉選單預設是新增在body上的,不過可以修改。這是因為element用了一個第三方js:popper.js,這個是用來專門處理彈出框的js,1000多行,然後Element又寫了個vue-popper.vue來進一步控制,這個檔案裡有如下程式碼

 createPopper() {
      ...
      if (!popper || !reference) return;
      if (this.visibleArrow) this.appendArrow(popper);
      
      if (this.appendToBody) document.body.appendChild(this.popperElm);
      
      if (this.popperJS && this.popperJS.destroy) {
        this.popperJS.destroy();
      }
      ...
      this.popperJS = new PopperJS(reference, popper, options);
      this.popperJS.onCreate(_ => {
        this.$emit('created', this);
        this.resetTransformOrigin();
        this.$nextTick(this.updatePopper);
      });
    
    },

複製程式碼

creatPopper就是初始化時進行的邏輯,裡面if (this.appendToBody) document.body.appendChild(this.popperElm)這句話就是關鍵,通過appendChild將彈出的下拉選單移動到body上,注意appendChild如果引數是已存在的元素則會移動它。然後你會發現滑鼠滾輪滾動時下拉選單也會隨著一起移動,注意下拉選單是在body上的,那麼這裡的移動邏輯就是在popperJS裡實現的,有點複雜,首先裡面得有個addEventListener監聽scroll事件,一查果然有

Popper.prototype._setupEventListeners = function() {
        // NOTE: 1 DOM access here
        this.state.updateBound = this.update.bind(this);
        root.addEventListener('resize', this.state.updateBound);
        // if the boundariesElement is window we don't need to listen for the scroll event
        if (this._options.boundariesElement !== 'window') {
            var target = getScrollParent(this._reference);
            // here it could be both `body` or `documentElement` thanks to Firefox, we then check both
            if (target === root.document.body || target === root.document.documentElement) {
                target = root;
            }
            target.addEventListener('scroll', this.state.updateBound);
            this.state.scrollTarget = target;
        }
    };
複製程式碼

上面的這句話target.addEventListener('scroll', this.state.updateBound);就是繫結了事件監聽,繼續看updateBound,發現它是通過update方法繫結到this,update如下

 /**
     * Updates the position of the popper, computing the new offsets and applying the new style
     * @method
     * @memberof Popper
     */
    Popper.prototype.update = function() {
        var data = { instance: this, styles: {} };

        // store placement inside the data object, modifiers will be able to edit `placement` if needed
        // and refer to _originalPlacement to know the original value
        data.placement = this._options.placement;
        data._originalPlacement = this._options.placement;

        // compute the popper and reference offsets and put them inside data.offsets
        data.offsets = this._getOffsets(this._popper, this._reference, data.placement);

        // get boundaries
        data.boundaries = this._getBoundaries(data, this._options.boundariesPadding, this._options.boundariesElement);

        data = this.runModifiers(data, this._options.modifiers);

        if (typeof this.state.updateCallback === 'function') {
            this.state.updateCallback(data);
        }
    };
複製程式碼

顧名思義,update就是用來更新彈出框的位置資訊,裡面是各種子方法進行對應的位置更新

相關文章