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

簡介

Element的下拉選擇器示意圖以下html

確實作的很漂亮,交互體驗很是好,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裏面不少東西是值得學習的,官網代碼點此vue

下拉選擇器的html結構

仍是先來分析這個下拉選擇器的html結構,簡化後的html代碼以下node

<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內git

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

而後最後的 <transtion>不是組件,是Vue的過渡動畫的標誌,不會渲染出來,裏面包裹着 <el-select-menu>這也是Element封裝的組件,表示彈出的下拉菜單,也是絕對定位, 因此整個下拉組件只有中間的input是相對定位,其餘都是絕對定位,並且要善於複用本身已有的組件,而不是又重頭寫

部分功能源碼分析

若是要寫完全部功能,那至少得一週以上,因此只能寫一部分express

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

下面分析下下拉框主體操做流程以及其中的數據傳遞過程
首先看下下拉框的用法,官網代碼以下element-ui

<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選項都會被放入滾動條組件內
bash

當用戶點擊初始狀態下的下拉框,觸發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而後這句就是在切換下拉菜單的狀態

下拉菜單顯示出來後,點擊某個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上的

是否是很神奇,當初始狀態沒有點擊選擇框時,這個下拉菜單display:none,這時候是絕對定位且包含在 <el-select>內,見下圖

然而當咱們點擊組件時,這個下拉菜單就跑到body上了

爲何要這樣作?官網有說明下拉菜單默認是添加在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就是用來更新彈出框的位置信息,裏面是各類子方法進行對應的位置更新

相關文章
相關標籤/搜索