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

超級索尼發表於2019-03-04

簡介

級聯選擇器,如下圖,也是一種常用的元件,這個元件會比較複雜一點

Element原始碼分析系列8-Cascader(級聯選擇器)
Element中和該元件相關的檔案有main.vuemenu.vue2個檔案,前者代表輸入框部分,後者代表下方的級聯選擇部分,以及附加的js檔案popper.js以及vue.popper.js,用來處理彈出框邏輯,前面文章介紹過,這4個檔案總程式碼量2000行左右,首先要明確,Element中把彈出框的邏輯分離出去了,放在專門的popper.js中,因為許多元件都要用到該彈出框。該元件官網程式碼點此

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

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

<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就是下圖中輸入框內的文字

Element原始碼分析系列8-Cascader(級聯選擇器)
注意這裡輸入框的文字不是直接作為value放在輸入框內的,而是一個絕對定位的span放在輸入框上,一般我們會直接把選中的文字作為輸入框的value填充,但這裡沒有這麼做,因為後面有個搜尋功能,需要在輸入框內輸入文字

是不是沒有發現下拉選單的html結構?因為下拉選單是掛載在document.body上的,通過popper.js來控制,所以結構被分離出去了

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

先來看最外層span的程式碼

<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"中有控制該變數的程式碼,該方法如下

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特有屬性

Element原始碼分析系列8-Cascader(級聯選擇器)
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才是真正切換的語句

接著看span上的這4句

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

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

Element原始碼分析系列8-Cascader(級聯選擇器)
mouseenter和mouseleave表示滑鼠移入移出span時切換inputHover,注意不是mouseover和mouseout,因為這2者會在子元素上觸發。但是focus和blur就奇怪了,因為這是在普通html元素span上繫結的,一般來說只在input上做,span預設沒有tabindex,因此按tab無法使得其獲得焦點,除非加一個tabindex屬性,但是官網又沒有說明有這個屬性,所以span到底是如何獲得焦點的?仔細檢視span元素的屬性後,如下圖

Element原始碼分析系列8-Cascader(級聯選擇器)
發現它有一個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的可見範圍內,什麼意思呢,見下圖

Element原始碼分析系列8-Cascader(級聯選擇器)
當你選擇了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事件函式 首先回顧一下下圖

Element原始碼分析系列8-Cascader(級聯選擇器)
當我們展開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是啥呢,它就是當前啟用的選項列表,比如如下圖的狀態

Element原始碼分析系列8-Cascader(級聯選擇器)
我們啟用了3個子選單,那麼activeOptions就是下圖的這麼一個二維陣列,儲存了3個子選單的資料

Element原始碼分析系列8-Cascader(級聯選擇器)
回到頭來看this.activeValue.splice(menuIndex, len, item.value)這句話,activeValue就是我們所選擇的啟用項構成的陣列,上圖的activeValue就是['指南','設計原則','一致'],splice是用來從陣列中新增刪除專案

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

則上面的splice從menuIndex處開始刪除,刪除了len個元素,再把item.value新選擇的值加入到陣列中從而更新了所選的專案,注意下一句this.activeOptions.splice(menuIndex + 1, len, item.children)這裡第一個引數是menuIndex+1,是因為要刪除自己的子選單而不是自己,所以是下一個位置,然後將新的子選單加入陣列

總結

這個元件的程式碼有點複雜,還有部分程式碼看不懂,反正慢慢看,第一次看肯定很多地方不明白

相關文章