element 源碼學習三 —— select 源碼學習

select 選擇器是個比較複雜的組件了,經過不一樣的配置能夠有多種用法。有必要單獨學習學習。

總體結構

如下是 select 的 template 結構,已去掉了一部分代碼便於查看總體結構:html

<template>
  <div>
    <!-- 多選 -->
    <div
      v-if="multiple"
      ref="tags">
      <!-- collapse tags 多選時是否將選中值按文字的形式展現 -->
      <span v-if="collapseTags && selected.length">
        <el-tag
          type="info"
          disable-transitions>
          <span class="el-select__tags-text">{{ selected[0].currentLabel }}</span>
        </el-tag>
        <el-tag
          v-if="selected.length > 1"
          type="info"
          disable-transitions>
          <span class="el-select__tags-text">+ {{ selected.length - 1 }}</span>
        </el-tag>
      </span>
      <!-- 多選,多個 el-tag 組成 -->
      <transition-group @after-leave="resetInputHeight" v-if="!collapseTags">
        <el-tag
          v-for="item in selected"
          :key="getValueKey(item)"
          type="info"
          disable-transitions>
          <span class="el-select__tags-text">{{ item.currentLabel }}</span>
        </el-tag>
      </transition-group>
      <!-- 可輸入文本的查詢框 -->
      <input
        v-model="query"
        v-if="filterable"
        ref="input">
    </div>
    <!-- 顯示結果框 read-only -->
    <el-input
      ref="reference"
      v-model="selectedLabel">
      <!-- 用戶顯示清空和向下箭頭 -->
      <i slot="suffix"></i>
    </el-input>
    <!-- 下拉菜單 -->
    <transition>
      <el-select-menu
        ref="popper"
        v-show="visible && emptyText !== false">
        <el-scrollbar
          tag="ul"
          wrap-class="el-select-dropdown__wrap"
          view-class="el-select-dropdown__list"
          ref="scrollbar"
          v-show="options.length > 0 && !loading">
          <!-- 默認項(建立條目) -->
          <el-option
            :value="query"
            created
            v-if="showNewOption">
          </el-option>
          <!-- 插槽,用於放 option 和 option-group -->
          <slot></slot>
        </el-scrollbar>
        <!-- loading 加載中文本 -->
        <p
          v-if="emptyText &&
            (!allowCreate || loading || (allowCreate && options.length === 0 ))">
          {{ emptyText }}
        </p>
      </el-select-menu>
    </transition>
  </div>
</template>

具體都寫在註釋中了~從上面內容中能夠看到,select 考慮了不少狀況,如單選、多選、搜索、下拉框、圖標等等。而且使用 slot 插槽來獲取開發者傳遞的 option 和 option-group 組件。
能夠發如今 select 中使用了多個外部組件,也就是說 el-select 是由多個組件組裝成的一個複雜組件~vue

// components
  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';

select 要實現的功能

參照官方文檔的內容羅列出 select 的一些功能,後面跟上我對功能實現的理解:element-ui

  • 單選 —— 點擊 select 彈出下拉框,點擊 option 完成賦值。
  • 禁用 —— selectoption 都有 disabled 選項用於禁用。
  • 清空 —— 若是 select 中有內容,鼠標懸浮在 input 上顯示刪除圖標,點擊執行刪除操做。
  • 多選(平鋪展現和數字顯示數量兩種方式) —— 參數 model 變爲數組,點擊下拉菜單中的選項添加或刪除數組中的值。
  • 自定義模板 —— option 中定義了 slot 插槽,默認加了 span 顯示內容。能夠修改 el-option 標籤中內容來自定義模板。
  • 分組 —— 使用 option-group 組件來實現分組效果。
  • 搜索 —— 經過正則匹配搜索項,不符合搜索項的控制 v-show 隱藏
  • 建立條目 —— 在 select 中添加額外 option(通常 option 都是經過 slot 插槽傳遞的),如容許建立條目,則顯示這條 option ,option 的內容顯示爲查詢內容。

從幾個問題去看源碼邏輯

如何實現基本單選功能?

分析下基本功能:點擊 input,顯示下拉菜單;鼠標選中一項 option,隱藏下拉菜單;input 中顯示選中的結果。
因此這裏看下顯示內容的 input 都有些什麼事件:api

@focus="handleFocus" // 處理 焦點
      @blur="handleBlur" // 處理 焦點 離開
      @keyup.native="debouncedOnInputChange"
      @keydown.native.down.stop.prevent="navigateOptions('next')" // 向下按鍵,移動到下一個 option
      @keydown.native.up.stop.prevent="navigateOptions('prev')" // 向上按鍵,移動到上一個 option
      @keydown.native.enter.prevent="selectOption" // 回車按鍵,選中option
      @keydown.native.esc.stop.prevent="visible = false"  // esc按鍵,隱藏下拉框
      @keydown.native.tab="visible = false" // tab按鍵,跳轉到下一個文本框,隱藏下拉框
      @paste.native="debouncedOnInputChange" // 
      @mouseenter.native="inputHovering = true" // mouse enter 事件
      @mouseleave.native="inputHovering = false" // mouse leave 事件

從上面的這些事件中能夠知道:選中方法爲 selectOption(從英文字面意思都能知道~);顯示下拉框經過 visible 屬性控制;以及其餘按鍵的一些功能。這裏主要主要看看 selectOption 方法。數組

selectOption() {
        if (!this.visible) {
          this.toggleMenu();
        } else {
          if (this.options[this.hoverIndex]) {
            this.handleOptionSelect(this.options[this.hoverIndex]);
          }
        }
      },

邏輯就是,若是下拉框未顯示則執行 toggleMenu 方法觸發下拉框,若是已顯示下拉框則處理選擇 option 的過程。看看這個 toggleMenu 方法:ide

toggleMenu() {
        if (!this.selectDisabled) {
          this.visible = !this.visible;
          if (this.visible) {
            (this.$refs.input || this.$refs.reference).focus();
          }
        }
      },

其實就是控制下拉菜單的顯示和隱藏。若是顯示的時候定焦在 inputreference 上,它們其實就是單選和多選的 input 框(多選 input 定義了 ref="input" 單選 input 定義了 ref="reference")。
至此,下拉菜單的顯示與隱藏解決了。而後咱們去找 option 點擊事件:學習

// 處理選項選中事件
      handleOptionSelect(option) {
        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);
          if (option.created) {
            this.query = '';
            this.handleQueryChange('');
            this.inputLength = 20;
          }
          // 查詢
          if (this.filterable) this.$refs.input.focus();
        } else {
          // 單選
          this.$emit('input', option.value);
          this.emitChange(option.value);
          this.visible = false;
        }
        // 渲染完成後
        this.$nextTick(() => {
          this.scrollToOption(option);
          this.setSoftFocus();
        });
      },

處理選中事件考慮了單選和多選兩種狀況。
若是是多選,檢索選中 option 是否在 value 數組中,有則移除、無則添加到 value 數組中。而後 $emit 觸發 input 事件,執行 emitChange 方法。若是 option 的 created 爲 true,則清空查詢內容。
若是是單選,$emit 觸發 input 事件將選中值傳遞給父組件,執行 emitChange 方法,最後隱藏下拉菜單。
最後使用 $nextTick 方法處理下界面。
到這裏,選中 option 後下拉菜單消失問題解決,只剩下顯示結果到 input 中了。這個顯示結果的過程是經過對 visible 屬性的監聽來完成的(一開始覺得在 emitChange 結果發現那只是觸發改變事件的)。動畫

visible(val) {
        // 在下拉菜單隱藏時
        if (!val) {
          // 處理圖標
          this.handleIconHide();
          // 廣播下拉菜單銷燬事件
          this.broadcast('ElSelectDropdown', 'destroyPopper');
          // 取消焦點
          if (this.$refs.input) {
            this.$refs.input.blur();
          }
          // 重置過程
          this.query = '';
          this.previousQuery = null;
          this.selectedLabel = '';
          this.inputLength = 20;
          this.resetHoverIndex();
          this.$nextTick(() => {
            if (this.$refs.input &&
              this.$refs.input.value === '' &&
              this.selected.length === 0) {
              this.currentPlaceholder = this.cachedPlaceHolder;
            }
          });
          // 若是不是多選,進行賦值如今 input 中
          if (!this.multiple) {
            // selected 爲當前選中的 option
            if (this.selected) {
              if (this.filterable && this.allowCreate &&
                this.createdSelected && this.createdOption) {
                this.selectedLabel = this.createdLabel;
              } else {
                this.selectedLabel = this.selected.currentLabel;
              }
              // 查詢結果
              if (this.filterable) this.query = this.selectedLabel;
            }
          }
        } else {
          // 下拉菜單顯示
          // 處理圖片顯示
          this.handleIconShow();
          // 廣播下拉菜單更新事件
          this.broadcast('ElSelectDropdown', 'updatePopper');
          // 處理查詢事件
          if (this.filterable) {
            this.query = this.remote ? '' : this.selectedLabel;
            this.handleQueryChange(this.query);
            if (this.multiple) {
              this.$refs.input.focus();
            } else {
              if (!this.remote) {
                this.broadcast('ElOption', 'queryChange', '');
                this.broadcast('ElOptionGroup', 'queryChange');
              }
              this.broadcast('ElInput', 'inputSelect');
            }
          }
        }
        // 觸發 visible-change 事件
        this.$emit('visible-change', val);
      },

從 template 中可知,顯示結果的 input 綁定的 v-modelselectedLabel,而 select 是經過獲取下拉菜單的顯示與隱藏事件來執行結果顯示部分的功能的。最終 selectedLabel 得到到了選中的 option 的 label 內容。
這樣,從 點擊-單選-顯示 的流程就實現了。仍是很簡單的。ui

如何實現多選,多選選中後 option 右側的勾以及 input 中的 tag 如何顯示?

關於多選,在剛纔講單選的時候說起了一些了。因此有些代碼就不貼出浪費篇幅了。具體邏輯以下:
先點擊 input 執行 selectOption 方法顯示下拉菜單,而後點擊下拉菜單中的 option,執行 handleOptionSelect 方法將 option 的值都傳給 value 數組。此時 value 數組改變,觸發 watch 中的 value 變化監聽方法。this

value(val) {
        // 多選
        if (this.multiple) {
          this.resetInputHeight();
          if (val.length > 0 || (this.$refs.input && this.query !== '')) {
            this.currentPlaceholder = '';
          } else {
            this.currentPlaceholder = this.cachedPlaceHolder;
          }
          if (this.filterable && !this.reserveKeyword) {
            this.query = '';
            this.handleQueryChange(this.query);
          }
        }
        this.setSelected();
        // 非多選查詢
        if (this.filterable && !this.multiple) {
          this.inputLength = 20;
        }
      },

以上代碼關鍵是執行了 setSelected 方法:

// 設置選擇項
      setSelected() {
        // 單選
        if (!this.multiple) {
          let option = this.getOption(this.value);
          // created 是指建立出來的 option,這裏指 allow-create 建立的 option 項
          if (option.created) {
            this.createdLabel = option.currentLabel;
            this.createdSelected = true;
          } else {
            this.createdSelected = false;
          }
          this.selectedLabel = option.currentLabel;
          this.selected = option;
          if (this.filterable) this.query = this.selectedLabel;
          return;
        }
        // 遍歷獲取 option
        let result = [];
        if (Array.isArray(this.value)) {
          this.value.forEach(value => {
            result.push(this.getOption(value));
          });
        }
        // 賦值
        this.selected = result;
        this.$nextTick(() => {
          // 重置 input 高度
          this.resetInputHeight();
        });
      },

能夠看到若是是多選,那麼將 value 數組遍歷,獲取相應的 option 值,傳給 selected。而多選界面其實就是對於這個 selected 的 v-for 遍歷顯示。顯示的標籤使用的是 element 的另一個組件 el-tag

<el-tag
          v-for="item in selected"
          :key="getValueKey(item)">
          <span class="el-select__tags-text">{{ item.currentLabel }}</span>
        </el-tag>

這裏順便提一句: option 的 created 參數用於標識是 select 組件中建立的那個用於建立條目的 option。而從 slot 插槽傳入的 option 是不用傳 created 參數的。

如何實現搜索功能?

從 template 中可知,select 有兩個 input,一個用於顯示結果,一個則用於查詢搜索。咱們來看下搜索內容的 input 文本框如何實現搜索功能:
在 input 中有 @input="e => handleQueryChange(e.target.value)"這麼一段代碼。因此,handleQueryChange 方法就是關鍵所在了。

// 處理查詢改變
      handleQueryChange(val) {
        if (this.previousQuery === val) return;
        if (
          this.previousQuery === null &&
          (typeof this.filterMethod === 'function' || typeof this.remoteMethod === 'function')
        ) {
          this.previousQuery = val;
          return;
        }
        this.previousQuery = val;
        this.$nextTick(() => {
          if (this.visible) this.broadcast('ElSelectDropdown', 'updatePopper');
        });
        this.hoverIndex = -1;
        if (this.multiple && this.filterable) {
          const length = this.$refs.input.value.length * 15 + 20;
          this.inputLength = this.collapseTags ? Math.min(50, length) : length;
          this.managePlaceholder();
          this.resetInputHeight();
        }
        if (this.remote && typeof this.remoteMethod === 'function') {
          this.hoverIndex = -1;
          this.remoteMethod(val);
        } else if (typeof this.filterMethod === 'function') {
          this.filterMethod(val);
          this.broadcast('ElOptionGroup', 'queryChange');
        } else {
          this.filteredOptionsCount = this.optionsCount;
          this.broadcast('ElOption', 'queryChange', val);
          this.broadcast('ElOptionGroup', 'queryChange');
        }
        if (this.defaultFirstOption && (this.filterable || this.remote) && this.filteredOptionsCount) {
          this.checkDefaultFirstOption();
        }
      },

其中,remoteMethodfilterMethod 方法是自定義的遠程查詢和本地過濾方法。若是沒有自定義的這兩個方法,則會觸發廣播給 optionoption-group 組件 queryChange 方法。

// option.vue
      queryChange(query) {
        let parsedQuery = String(query).replace(/(\^|\(|\)|\[|\]|\$|\*|\+|\.|\?|\\|\{|\}|\|)/g, '\\$1');
        // 匹配字符決定是否顯示當前option
        this.visible = new RegExp(parsedQuery, 'i').test(this.currentLabel) || this.created;
        if (!this.visible) {
          this.select.filteredOptionsCount--;
        }
      }

option 中經過正則匹配決定是否隱藏當前 option 組件,而 option-group 經過獲取子組件,判斷若是有子組件是可見的則顯示,不然隱藏。

// option-group.vue
      queryChange() {
        this.visible = this.$children &&
          Array.isArray(this.$children) &&
          this.$children.some(option => option.visible === true);
      }

因此,其實 option 和 option-group 在搜索的時候只是隱藏掉了不匹配的內容而已。

下拉菜單的顯示和隱藏效果是如何實現的?下拉菜單本質是什麼東西?

下拉菜單是經過 transition 來實現過渡動畫的。
下拉菜單 el-select-menu 本質上就是一個 div 容器而已。

<div
    class="el-select-dropdown el-popper"
    :class="[{ 'is-multiple': $parent.multiple }, popperClass]"
    :style="{ minWidth: minWidth }">
    <slot></slot>
  </div>

另外,在代碼中常常出現的通知下拉菜單顯示和隱藏的廣播在 el-select-menumounted 方法中接收使用:

mounted() {
      this.referenceElm = this.$parent.$refs.reference.$el;
      this.$parent.popperElm = this.popperElm = this.$el;
      this.$on('updatePopper', () => {
        if (this.$parent.visible) this.updatePopper();
      });
      this.$on('destroyPopper', this.destroyPopper);
    }

建立條目如何實現?

上文中提到過,就是在 select 中默認藏了一條 option,當建立條目時顯示這個 option 並顯示建立內容。點擊這個 option 就能夠把建立的內容添加到顯示結果的 input 上了。

如何展現遠程數據?

經過爲 select 設置 remoteremote-method 屬性來獲取遠程數據。remote-method 方法最終將數據賦值給 option 的 v-model 綁定數組數據將結果顯示出來便可。

清空按鈕顯示和點擊事件呢?

在顯示結果的 input 文本框中有一個 <i> 標籤,用於顯示圖標。

<!-- 用戶顯示清空和向下箭頭 -->
      <i slot="suffix"
       :class="['el-select__caret', 'el-input__icon', 'el-icon-' + iconClass]"
       @click="handleIconClick"
      ></i>

最終 input 右側顯示什麼圖標由 iconClass 決定,其中 circle-close 就是圓形查查,即清空按鈕~

iconClass() {
        let criteria = this.clearable &&
          !this.selectDisabled &&
          this.inputHovering &&
          !this.multiple &&
          this.value !== undefined &&
          this.value !== '';
        return criteria ? 'circle-close is-show-close' : (this.remote && this.filterable ? '' : 'arrow-up');
      },

handleIconClick 方法:

// 處理圖標點擊事件(刪除按鈕)
      handleIconClick(event) {
        if (this.iconClass.indexOf('circle-close') > -1) {
          this.deleteSelected(event);
        }
      },
      // 刪除選中
      deleteSelected(event) {
        event.stopPropagation();
        this.$emit('input', '');
        this.emitChange('');
        this.visible = false;
        this.$emit('clear');
      },

最終,清空只是將文本清空掉而且關閉下拉菜單。其實當再次打開 select 的時候,option 仍是選中在以前選中的那個位置,即 HoverIndex 沒有變爲 -1,不知道算不算 bug。

option 的自定義模板是如何實現的?

很簡單,使用了 slot 插槽。而且在 slot 中定義了默認顯示方式。

<slot>
      <span>{{ currentLabel }}</span>
    </slot>

最後

第一次嘗試用問題取代主題來寫博客,這樣看着中心是否是更明確一些?
最後,說下看完 select 組件的感覺:

  • element 經過自定義的廣播方法進行父子組件間的通訊。(好像之前Vue也有這個功能,後來棄用了。)
  • 再複雜的組件都是由一個個基礎的組件拼起來的。
  • select 功能仍是挺複雜的,加上子組件 1000+ 行代碼了。本文只是講了基本功能的實現,值得深刻學習。
  • 學習了高手寫組件的方式和寫法~以後在本身寫組件的時候能夠參考。
  • 方法、參數命名很是規範,一眼就能看懂具體用法。
  • 知道了 Array.some() 方法~

好吧,說好了一天寫出來,結果斷斷續續花了三天才完成。有點高估本身能力啦~說下以後的Vue實驗室博客計劃:計劃再找兩個複雜的 element 組件來學習,最後寫一篇總結博客。而後試着本身去建立幾個 UI 組件,學以至用。

相關文章
相關標籤/搜索