Element源碼分析系列8-Cascader(級聯選擇器)

簡介

級聯選擇器,以下圖,也是一種經常使用的組件,這個組件會比較複雜一點html

Element中和該組件相關的文件有 main.vuemenu.vue2個文件,前者表明輸入框部分,後者表明下方的級聯選擇部分,以及附加的js文件popper.js以及vue.popper.js,用來處理彈出框邏輯,前面文章介紹過,這4個文件總代碼量2000行左右,首先要明確,Element中把彈出框的邏輯分離出去了,放在專門的popper.js中,由於許多組件都要用到該彈出框。該組件官網代碼 點此

級聯選擇器輸入框的html結構

先來看main.vue中的html結構,main.vue表明輸入框部分,簡化後的html結構以下vue

<span class="el-cascader">
    <el-input>
        <template slot="suffix">
            <i v-if></i>
            <i v-else></i>
        </template>
    </el-input>
    <span class="el-cascader__label">
        ...
    </span>
</span>
複製代碼

結構很簡單,最外層一個span包裹全部元素,該span相對定位且inline-block,裏面是一個<el-input>輸入框組件,該輸入框用來搜索目標內容(內容就是級聯選擇器的data),而後<el-input>裏面一個template做爲插槽存放了2個i標籤,注意這裏的slot="suffix"這是將這2個i標籤做爲具名插槽的內容插入<el-input>中的對應位置,帶表了下箭頭和清空輸入框的按鈕。而後是一個span,這個span就是下圖中輸入框內的文字react

注意這裏輸入框的文字不是直接做爲value放在輸入框內的,而是一個絕對定位的span放在輸入框上,通常咱們會直接把選中的文字做爲輸入框的value填充,但這裏沒有這麼作,由於後面有個搜索功能,須要在輸入框內輸入文字

是否是沒有發現下拉菜單的html結構?由於下拉菜單是掛載在document.body上的,經過popper.js來控制,因此結構被分離出去了git

級聯選擇器輸入框的代碼分析

先來看最外層span的代碼github

<span
    class="el-cascader"
    :class="[ { 'is-opened': menuVisible, 'is-disabled': cascaderDisabled }, cascaderSize ? 'el-cascader--' + cascaderSize : '' ]"
    @click="handleClick"
    @mouseenter="inputHover = true"
    @focus="inputHover = true"
    @mouseleave="inputHover = false"
    @blur="inputHover = false"
    ref="reference"
    v-clickoutside="handleClickoutside"
    @keydown="handleKeydown"
  >
複製代碼

做爲組件最外層的span,其功能主要就是點擊以後會彈出/隱藏下拉框,前面class部分就是控制該輸入框是否禁用的樣式,is-opened這個類很奇怪,源碼裏沒有,並且審查元素也發現該類是空,menuVisible是組件內data中的變量,控制是否顯示下拉菜單,天然能夠想到,下面的@click="handleClick"中有控制該變量的代碼,該方法以下ajax

handleClick() {
      if (this.cascaderDisabled) return;
      this.$refs.input.focus();
      if (this.filterable) {
        this.menuVisible = true;
        return;
      }
      this.menuVisible = !this.menuVisible;
    },
複製代碼

首先判斷組件是否禁用,若是禁用則直接返回,第二句this.$refs.input.focus()是獲取到該組件內的<el-input>並讓其得到焦點,focus是原生用法,注意這裏默認狀態下組件內的輸入框是readonly只讀的,只有在開啓了搜索狀態下才能得到焦點,而開啓搜索由filterable這個prop控制,用戶傳入,<el-input>:readonly="readonly"這句話就是控制只讀的,readonly是個計算屬性,以下正則表達式

readonly() {
      const isIE = !this.$isServer && !isNaN(Number(document.documentMode));
      return !this.filterable || (!isIE && !this.menuVisible);
    }
複製代碼

這裏首先判斷是否是ie瀏覽器,首先判斷是否是服務端渲染,若是是則直接返回false,而後這句話!isNaN(Number(document.documentMode)就能夠很輕鬆的判斷是不是ie,以前我記得通常是用navigator.userAgent.indexOf("MSIE")>0來判斷的,documentMode是一個ie特有屬性 chrome

ie返回一個數字,其餘瀏覽器返回undefined,則Numer(undefined)就是NaN,那爲啥不直接用 document.documentMode!==undefined來判斷呢這裏不明白,難道是怕undefined不是真正的undefined?由於undefined能夠被修改。繼續看return邏輯,若是是開啓搜索狀態(filterable爲true,那麼通常狀況下輸入框readonly應該爲false,表示能夠寫入),注意這裏還要繼續判斷 (!isIE && !this.menuVisible),若是瀏覽器是ie,那麼輸入框可寫,問題來了,爲啥要判斷ie呢?這裏有點迷糊,我試了下ie和chrome,沒看出啥問題來

繼續回到handleClick中, if (this.filterable)這句話說明若是開啓了搜索狀態,則點擊輸入框後直接返回,不切換下拉菜單狀態,這是合理的,由於搜索狀態下須要讓下拉菜單一直顯示方便你查看,最後一句this.menuVisible = !this.menuVisible纔是真正切換的語句npm

接着看span上的這4句api

@mouseenter="inputHover = true"
@focus="inputHover = true"
@mouseleave="inputHover = false"
@blur="inputHover = false"
複製代碼

這是控制是否顯示輸入框的叉按鈕,用於清空輸入框,以下圖

mouseenter和mouseleave表示鼠標移入移出span時切換inputHover,注意不是mouseover和mouseout,由於這2者會在子元素上觸發。可是focus和blur就奇怪了,由於這是在普通html元素span上綁定的,通常來講只在input上作,span默認沒有tabindex,所以按tab沒法使得其得到焦點,除非加一個tabindex屬性,可是官網又沒有說明有這個屬性,因此span究竟是如何得到焦點的?仔細查看span元素的屬性後,以下圖

發現它有一個tabindex,可是爲-1,-1的意思就是經過tab鍵沒法訪問到,這裏我有2點不明白,一是這個tabindex屬性是如何加上去的,二是span的 @keydown="handleKeydown"這一句,經過打印發現當組件內的input得到焦點時,這個span上的keydown會被觸發。

@keydown="handleKeydown"最後一句這裏也很奇怪,給span綁定了一個keydown方法,只有在span得到焦點時按鍵才觸發該方法,仔細觀察後發現原來是span裏面的input得到焦點觸發focus方法, 而後冒泡到父span上觸發父span的focus,這時候按鍵就可以觸發父span的keydown

再來看<el-input>的代碼

<el-input
      ref="input"
      :readonly="readonly"
      :placeholder="currentLabels.length ? undefined : placeholder"
      v-model="inputValue"
      @input="debouncedInputChange"
      @focus="handleFocus"
      @blur="handleBlur"
      @compositionstart.native="handleComposition"
      @compositionend.native="handleComposition"
      :validate-event="false"
      :size="size"
      :disabled="cascaderDisabled"
      :class="{ 'is-focus': menuVisible }"
    >
複製代碼

首先要明確這個輸入框起到的做用僅僅承載是搜索功能時用戶輸入的文字,v-model="inputValue"這句話指定了輸入框綁定的值,當用戶鍵入字符時,該值被更新,inputValue是組件內的data中的屬性,@input="debouncedInputChange"這句話聲明瞭input事件綁定的函數,從名字看來這裏用到了防抖,簡而言之,這裏的防抖就是用戶輸入文字時停頓了多久才觸發debouncedInputChange,由於搜索功能會調用ajax,所以是異步的,須要控制向服務器的請求頻率,若是不設置,則輸入一個字符觸發一次,明顯過高頻,來看一下debouncedInputChange

this.debouncedInputChange = debounce(this.debounce, value => {
      const before = this.beforeFilter(value);
      if (before && before.then) {
        this.menu.options = [{
          __IS__FLAT__OPTIONS: true,
          label: this.t('el.cascader.loading'),
          value: '',
          disabled: true
        }];
        before
          .then(() => {
            this.$nextTick(() => {
              this.handleInputChange(value);
            });
          });
      } else if (before !== false) {
        this.$nextTick(() => {
          this.handleInputChange(value);
        });
      }
    });
  },
複製代碼

這裏的debounce是一個高階函數,一個完整的防抖函數實現,具體可參考npm,第一個參數是防抖時間,第二個參數就是指定的回調函數,返回一個新的函數做爲input事件綁定的函數。這個回調函數的參數是value,就是輸入框新輸入的值,該函數內第一句const before = this.beforeFilter(value)的beforeFilter是一個函數

beforeFilter: {
      type: Function,
      default: () => (() => {})
    },
複製代碼

這個函數是一個函數,before是其返回值,該函數是由用戶自定義傳入的,目的是做爲搜索功能篩選以前的鉤子,參數爲輸入的值,若返回 false 或者返回 Promise 且被 reject,則中止篩選。

接着if (before && before.then)若是該函數的返回值爲true且擁有then方法,說明是個promise,首先修改menu.options爲加載狀態, 而後在then裏面執行this.handleInputChange(value)進行真正的操做 ,else if那一段說明不是promise且返回值爲true,則直接執行handleInputChange方法,這裏爲啥要用nextTick,暫時還不明白

<el-input>後面的@compositionstart.native="handleComposition"監聽了一個原生的事件,注意這是在<el-input>組件上給根元素監聽的原生事件而不是給原生html元素監聽事件,那麼必須用native修飾符

而後注意到mounted方法裏有這麼一句話

mounted() {
    this.flatOptions = this.flattenOptions(this.options);
  }
複製代碼

這就是在進行經典的數組展平操做,this.options是用戶傳入的數據數組,用來渲染下拉菜單,而數組的每一個值都是一個對象,有value,label,children,而children就是嵌套的子數組,至關於二級菜單以及多級菜單,那麼爲啥要展平呢?緣由是搜索功能須要遍歷全部數據項,所以展平的數組更容易遍歷,下面是代碼

flattenOptions(options, ancestor = []) {
      let flatOptions = [];
      options.forEach((option) => {
        const optionsStack = ancestor.concat(option);
        if (!option[this.childrenKey]) {
          flatOptions.push(optionsStack);
        } else {
          if (this.changeOnSelect) {
            flatOptions.push(optionsStack);
          }
          flatOptions = flatOptions.concat(this.flattenOptions(option[this.childrenKey], optionsStack));
        }
      });
      return flatOptions;
    },
複製代碼

原理就是遞歸操做,判斷有沒有children項存在,若是有,則遞歸調用本身,並concat到flatOptions 並返回,不然直接push,這裏該方法的第二個參數是用來保存多級菜單的,而後到搜索的代碼裏看下,核心搜索邏輯以下

let filteredFlatOptions = flatOptions.filter(optionsStack => {
        return optionsStack.some(option => new RegExp(escapeRegexpString(value), 'i')
          .test(option[this.labelKey]));
      });
複製代碼

這就是對展開的數組進行filter操做,用正則表達式進行匹配,value就是用戶輸入的要查詢的值,這裏optionStack是數組,若是裏面任何一項知足,都返回true表示找到,經過some高階函數最終得到filteredFlatOptions搜索的結果

級聯選擇器下拉菜單分析

經過查看main.vue的的代碼發現html部分並無下拉菜單這個結構,其實下拉菜單是掛載在body上的,那天然會問,輸入框部分和下拉菜單部分是如何聯繫在一塊兒的?查看源碼發現一個initMenu的方法,該方法在第一次showMenu時會被調用,代碼以下

initMenu() {
      this.menu = new Vue(ElCascaderMenu).$mount();
      this.menu.options = this.options;
      this.menu.props = this.props;
      this.menu.expandTrigger = this.expandTrigger;
      this.menu.changeOnSelect = this.changeOnSelect;
      this.menu.popperClass = this.popperClass;
      this.menu.hoverThreshold = this.hoverThreshold;
      this.popperElm = this.menu.$el;
      this.menu.$refs.menus[0].setAttribute('id', `cascader-menu-${this.id}`);
      this.menu.$on('pick', this.handlePick);
      this.menu.$on('activeItemChange', this.handleActiveItemChange);
      this.menu.$on('menuLeave', this.doDestroy);
      this.menu.$on('closeInside', this.handleClickoutside);
    },
複製代碼

注意第一句話this.menu = new Vue(ElCascaderMenu).$mount()這代表把ElCascaderMenu做爲選項對象,而後new了一個Vue的實例出來,這個實例就是下拉菜單實例,ElCascaderMenu就是菜單組件,而$mount()沒有傳遞參數,表示在文檔以外渲染,可是沒有掛載到dom,具體的掛載操做在vue-popper.js中進行,這裏用this.menu保存了下拉菜單的實例,所以對於用戶操做下拉菜單,都能經過this.menu進行事件的處理,所以聯繫在一塊兒了,再看this.popperElm = this.menu.$el一句話,這一句也很重要,它將下拉菜單的根dom元素賦值給了popperElm,popperElm又是哪裏來的呢?是這樣來的

const popperMixin = {
  props: {
    placement: {
      type: String,
      default: 'bottom-start'
    },
    appendToBody: Popper.props.appendToBody,
    arrowOffset: Popper.props.arrowOffset,
    offset: Popper.props.offset,
    boundariesPadding: Popper.props.boundariesPadding,
    popperOptions: Popper.props.popperOptions
  },
  methods: Popper.methods,
  data: Popper.data,
  beforeDestroy: Popper.beforeDestroy
};
複製代碼

經過popperMixin將vue-popper.js裏面的方法,data等混入輸入框這個部分,這樣作的目的是可以在這個組件裏操做popper組件的相關內容。initMenu中最後幾句就是在監聽下拉菜單用$emit觸發的各類事件

到如今爲止仍是沒有看到下拉菜單是如何掛載到body上的,initMenu裏沒有,咱們繼續看,當點擊輸入框時彈出下拉菜單,觸發showMenu,進入showMenu

showMenu() {
      if (!this.menu) {
        this.initMenu();
      }
      ...
      this.$nextTick(_ => {
        this.updatePopper();
        this.menu.inputWidth = this.$refs.input.$el.offsetWidth - 2;
      });
    },
複製代碼

能夠看到裏面的this.updatePopper就是進行更新下拉菜單操做,注意這裏必定要有nextTick,由於initMenu裏修改了data,此時要獲取更新後的dom,updatePopper是經過popperMixin混入到輸入框部分的,它位於vue-popper.js中

updatePopper() {
      const popperJS = this.popperJS;
      if (popperJS) {
        popperJS.update();
        if (popperJS._popper) {
          popperJS._popper.style.zIndex = PopupManager.nextZIndex();
        }
      } else {
        this.createPopper();
      }
    },
複製代碼

這裏的popperJS是個成熟的popper插件,代碼2000多行,有興趣的能夠去了解,這裏首先判斷popperJS是否存在,第一次操做時確定不存在,進入this.createPopper()進行初始化操做,繼續看this.createPopper()

createPopper() {
    ...
    const popper = this.popperElm = this.popperElm || this.popper || this.$refs.popper;
    ...
    if (this.appendToBody) document.body.appendChild(this.popperElm);
}
複製代碼

這裏先經過this.popperElm獲取到下拉菜單的根dom元素,就是從以前分析的那裏獲得,而後判斷是否要掛載到body上,若是是舊直接appendCHild,所以這裏就完成了下拉菜單的掛載,具體的位置更新操做也在這個popperJS裏,比較麻煩。

下面來看下拉菜單的html結構

return (
        <transition name="el-zoom-in-top" on-before-enter={this.handleMenuEnter} on-after-leave={this.handleMenuLeave}>
          <div
            v-show={visible}
            class={[
              'el-cascader-menus el-popper',
              popperClass
            ]}
            ref="wrapper"
          >
            <div x-arrow class="popper__arrow"></div>
            {menus}
          </div>
        </transition>
      );
複製代碼

這個return表示下拉菜單是經過render渲染函數生成的,相似於react的jsx形式,最外層一個transition聲明瞭組件的動畫效果,這個動畫效果就是從transform: scaleY(1)到transform: scaleY(0)以及反過來的縮放過程,而後看2個鉤子函數,this.handleMenuEnter在下拉菜單插入dom時觸發,那這裏面作了什麼呢?

handleMenuEnter() {
        this.$nextTick(() => this.$refs.menus.forEach(menu => this.scrollMenu(menu)));
      }
複製代碼

這裏包了一層nextTick,由於要保證dom插入完畢才能調用,不然可能會報錯。而後看scrollMenu

scrollMenu(menu) {
        scrollIntoView(menu, menu.getElementsByClassName('is-active')[0]);
      },
複製代碼

顧名思義,裏面所作的就是將第二個參數的dom元素移入到第一個參數所在的dom的可見範圍內,什麼意思呢,見下圖

當你選擇了rate評分這個項時,再收起菜單,再點擊輸入框展開菜單,則rate評分項自動進入視野,這就是scrollIntoView的功能

export default function scrollIntoView(container, selected) {
  if (!selected) {
    container.scrollTop = 0;
    return;
  }
  const offsetParents = [];
  let pointer = selected.offsetParent;
  while (pointer && container !== pointer && container.contains(pointer)) {
    offsetParents.push(pointer);
    pointer = pointer.offsetParent;
  }
  const top = selected.offsetTop + offsetParents.reduce((prev, curr) => (prev + curr.offsetTop), 0);
  const bottom = top + selected.offsetHeight;
  const viewRectTop = container.scrollTop;
  const viewRectBottom = viewRectTop + container.clientHeight;

  if (top < viewRectTop) {
    container.scrollTop = top;
  } else if (bottom > viewRectBottom) {
    container.scrollTop = bottom - container.clientHeight;
  }
}
複製代碼

上述代碼的核心思想就是不斷累加selected元素的offsetTop值,while循環裏面就是經過pointer.offsetParent來獲取到本身的偏移父級元素,offsetParent就是離本身最近的一個position不爲static的祖先元素,而後將其保存爲數組,再經過 offsetParents.reduce一句依次累加offsetTop值,最終獲得selected元素底部距離container元素頂部的距離。最後再更新container的scrollTop來移動滾動條讓元素恰好進入視野,scrollIntoView實際上是h5的新特性,一個新的api,讓元素可以移入頁面視野範圍內,可是不適用於容器內的元素滾動,並且兼容性不是很好。

而後繼續看html結構部分v-show={visible}經過visible控制下拉菜單的顯示隱藏,visible是在main.vue中更新的,也就是在用戶點擊輸入框時的showMenu裏更新,而後是class部分'el-cascader-menus'這個類裏面聲明瞭一些基本樣式,而後el-popper類讓這個下拉菜單距離輸入框有個margin。<div x-arrow class="popper__arrow"></div>則表明下拉菜單的三角形小箭頭,這個寫法就是經典的3個border透明,一個border有顏色從而造成三角形。

而後div內只有一個{menu},這纔是下拉菜單內的ul列表們,這個ul列表是經過下面的方法生成的

const menus = this._l(activeOptions, (menu, menuIndex) => {
    ...
    const items = this._l(menu, item => {
        ...
         return (
            <li>...</li>
            )
    }
    return (<ul>{items}</ul)
}
複製代碼

這個方法裏面很是長,上面是簡化後的邏輯,可見就是先生成每一個ul裏面的li列表,再生成ul列表,那麼問題來了this._l方法究竟是啥?在本文件和相關文件內是搜不到的,最後發現竟然是Vue源碼裏面的東西。在Vue源碼裏搜索到該方法是renderList的別名,renderList以下

export function renderList (
  val: any,
  render: (
    val: any,
    keyOrIndex: string | number,
    index?: number
  ) => VNode
): ?Array<VNode> {
  let ret: ?Array<VNode>, i, l, keys, key
  if (Array.isArray(val) || typeof val === 'string')
    ret = new Array(val.length)
    for (i = 0, l = val.length; i < l; i++) {
      ret[i] = render(val[i], i)
    }
    
  ...
  
  return ret
}
複製代碼

這兒是flow格式的代碼,用於類型控制,renderList是個高階函數,第二個參數要傳入一個rander方法,而後裏面if的邏輯是若是參數val是數組,就ret[i] = render(val[i], i)依次執行數組的每一項並將返回結果保存在ret數組中在,最後返回。這個函數就是對傳入的val參數的每一項進行處理,而後返回處理後的新數組。因此上面的this._l(activeOptions, (menu, menuIndex)處理後就返回了一個由<li>組成的數組,而後插入到html中進行渲染

const menus = this._l(activeOptions, (menu, menuIndex) => {}這個函數的第二個參數裏超級複雜,裏面處理了li的各類鼠標事件和鍵盤事件,具體邏輯就不寫了,根本寫不完。 最後說一下點擊下拉菜單的某項時的click事件函數 首先回顧一下下圖

當咱們展開3級菜單時點擊了rate評分,那麼咱們須要獲得從一級菜單開始直到末級菜單的這麼一個路徑文本 組件/form/rate評分 這裏菜單裏的每個li都綁定了點擊事件,以下

activeItem(item, menuIndex) {
        const len = this.activeOptions.length;
        this.activeValue.splice(menuIndex, len, item.value);
        this.activeOptions.splice(menuIndex + 1, len, item.children);
        if (this.changeOnSelect) {
          this.$emit('pick', this.activeValue.slice(), false);
        } else {
          this.$emit('activeItemChange', this.activeValue);
        }
      },
複製代碼

第一個參數是li本身,第二個參數是menu的index,這個值就是前面const menus = this._l(activeOptions, (menu, menuIndex) => {}裏面傳過來的值,表明了第幾級菜單。而後先獲取到activeOptions的長度,activeOptions是啥呢,它就是當前激活的選項列表,好比以下圖的狀態

咱們激活了3個子菜單,那麼activeOptions就是下圖的這麼一個二維數組,存儲了3個子菜單的數據

回到頭來看 this.activeValue.splice(menuIndex, len, item.value)這句話,activeValue就是咱們所選擇的激活項構成的數組,上圖的activeValue就是['指南','設計原則','一致'],splice是用來從數組中添加刪除項目

則上面的splice從menuIndex處開始刪除,刪除了len個元素,再把item.value新選擇的值加入到數組中從而更新了所選的項目,注意下一句this.activeOptions.splice(menuIndex + 1, len, item.children)這裏第一個參數是menuIndex+1,是由於要刪除本身的子菜單而不是本身,因此是下一個位置,而後將新的子菜單加入數組

總結

這個組件的代碼有點複雜,還有部分代碼看不懂,反正慢慢看,第一次看確定不少地方不明白

相關文章
相關標籤/搜索