select 選擇器是個比較復雜的組件了,通過不同的配置可以有多種用法。有必要單獨學習學習。
整體結構
以下是 select 的 template 結構,已去掉了一部分代碼便于查看整體結構:
<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 是由多個組件組裝成的一個復雜組件~
// 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 的一些功能,后面跟上我對功能實現的理解:
- 單選 —— 點擊
select
彈出下拉框,點擊option
完成賦值。 - 禁用 ——
select
和option
都有disabled
選項用于禁用。 - 清空 —— 如果
select
中有內容,鼠標懸浮在input
上顯示刪除圖標,點擊執行刪除操作。 - 多選(平鋪展示和數字顯示數量兩種方式) —— 參數 model 變為數組,點擊下拉菜單中的選項添加或刪除數組中的值。
- 自定義模板 —— option 中定義了
slot
插槽,默認加了span
顯示內容。可以修改el-option
標簽中內容來自定義模板。 - 分組 —— 使用 option-group 組件來實現分組效果。
- 搜索 —— 通過正則匹配搜索項,不符合搜索項的控制 v-show 隱藏
- 創建條目 —— 在
select
中添加額外option
(一般option
都是通過slot
插槽傳遞的),如允許創建條目,則顯示這條option
,option
的內容顯示為查詢內容。
從幾個問題去看源碼邏輯
如何實現基本單選功能?
分析下基本功能:點擊 input,顯示下拉菜單;鼠標選中一項 option,隱藏下拉菜單;input 中顯示選中的結果。
所以這里看下顯示內容的 input 都有些什么事件:
@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
方法:
toggleMenu() {
if (!this.selectDisabled) {
this.visible = !this.visible;
if (this.visible) {
(this.$refs.input || this.$refs.reference).focus();
}
}
},
其實就是控制下拉菜單的顯示和隱藏。如果顯示的時候定焦在 input
和 reference
上,它們其實就是單選和多選的 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-model
是 selectedLabel
,而 select 是通過獲取下拉菜單的顯示與隱藏事件來執行結果顯示部分的功能的。最終 selectedLabel
獲得到了選中的 option 的 label
內容。
這樣,從 點擊-單選-顯示 的流程就實現了。還是很簡單的。
如何實現多選,多選選中后 option 右側的勾以及 input 中的 tag 如何顯示?
關于多選,在剛才講單選的時候提及了一些了。所以有些代碼就不貼出浪費篇幅了。具體邏輯如下:
先點擊 input 執行 selectOption
方法顯示下拉菜單,然后點擊下拉菜單中的 option,執行 handleOptionSelect
方法將 option 的值都傳給 value
數組。此時 value
數組改變,觸發 watch 中的 value
變化監聽方法。
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();
}
},
其中,remoteMethod
和 filterMethod
方法是自定義的遠程查詢和本地過濾方法。如果沒有自定義的這兩個方法,則會觸發廣播給 option
和 option-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-menu
的 mounted
方法中接收使用:
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 設置 remote
和 remote-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 組件,學以致用。
打個廣告
上海鏈家-鏈家上海研發中心需求大量前端、后端、測試,需要內推請將簡歷發送至 dingxiaojie001@ke.com。