Element原始碼分析系列6-Checkbox(核取方塊)

超級索尼發表於2018-08-21

簡介

核取方塊的邏輯比單選框更為複雜,程式碼量也更多,這裡只介紹其與單選框不同的邏輯,其餘的分析參考單選框

Element原始碼分析系列6-Checkbox(核取方塊)
先上程式碼,官網程式碼點此

<template>
  <label
    class="el-checkbox"
    :class="[
      border && checkboxSize ? 'el-checkbox--' + checkboxSize : '',
      { 'is-disabled': isDisabled },
      { 'is-bordered': border },
      { 'is-checked': isChecked }
    ]"
    role="checkbox"
    :aria-checked="indeterminate ? 'mixed': isChecked"
    :aria-disabled="isDisabled"
    :id="id"
  >
    <span class="el-checkbox__input"
      :class="{
        'is-disabled': isDisabled,
        'is-checked': isChecked,
        'is-indeterminate': indeterminate,
        'is-focus': focus
      }"
       aria-checked="mixed"
    >
      <span class="el-checkbox__inner"></span>
      <input
        v-if="trueLabel || falseLabel"
        class="el-checkbox__original"
        type="checkbox"
        aria-hidden="true"
        :name="name"
        :disabled="isDisabled"
        :true-value="trueLabel"
        :false-value="falseLabel"
        v-model="model"
        @change="handleChange"
        @focus="focus = true"
        @blur="focus = false">
      <input
        v-else
        class="el-checkbox__original"
        type="checkbox"
        aria-hidden="true"
        :disabled="isDisabled"
        :value="label"
        :name="name"
        v-model="model"
        @change="handleChange"
        @focus="focus = true"
        @blur="focus = false">
    </span>
    <span class="el-checkbox__label" v-if="$slots.default || label">
      <slot></slot>
      <template v-if="!$slots.default">{{label}}</template>
    </span>
  </label>
</template>
<script>
  import Emitter from 'element-ui/src/mixins/emitter';
  export default {
    name: 'ElCheckbox',
    mixins: [Emitter],
    inject: {
      elForm: {
        default: ''
      },
      elFormItem: {
        default: ''
      }
    },
    componentName: 'ElCheckbox',
    data() {
      return {
        selfModel: false,
        focus: false,
        isLimitExceeded: false
      };
    },
    computed: {
      model: {
        get() {
          return this.isGroup
            ? this.store : this.value !== undefined
              ? this.value : this.selfModel;
        },
        set(val) {
          if (this.isGroup) {
            this.isLimitExceeded = false;
            (this._checkboxGroup.min !== undefined &&
              val.length < this._checkboxGroup.min &&
              (this.isLimitExceeded = true));
            (this._checkboxGroup.max !== undefined &&
              val.length > this._checkboxGroup.max &&
              (this.isLimitExceeded = true));
            this.isLimitExceeded === false &&
            this.dispatch('ElCheckboxGroup', 'input', [val]);
          } else {
            this.$emit('input', val);
            this.selfModel = val;
          }
        }
      },
      isChecked() {
        if ({}.toString.call(this.model) === '[object Boolean]') {
          return this.model;
        } else if (Array.isArray(this.model)) {
          return this.model.indexOf(this.label) > -1;
        } else if (this.model !== null && this.model !== undefined) {
          return this.model === this.trueLabel;
        }
      },
      isGroup() {
        let parent = this.$parent;
        while (parent) {
          if (parent.$options.componentName !== 'ElCheckboxGroup') {
            parent = parent.$parent;
          } else {
            this._checkboxGroup = parent;
            return true;
          }
        }
        return false;
      },
      store() {
        return this._checkboxGroup ? this._checkboxGroup.value : this.value;
      },
      isDisabled() {
        return this.isGroup
          ? this._checkboxGroup.disabled || this.disabled || (this.elForm || {}).disabled
          : this.disabled || (this.elForm || {}).disabled;
      },
      _elFormItemSize() {
        return (this.elFormItem || {}).elFormItemSize;
      },
      checkboxSize() {
        const temCheckboxSize = this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
        return this.isGroup
          ? this._checkboxGroup.checkboxGroupSize || temCheckboxSize
          : temCheckboxSize;
      }
    },
    props: {
      value: {},
      label: {},
      indeterminate: Boolean,
      disabled: Boolean,
      checked: Boolean,
      name: String,
      trueLabel: [String, Number],
      falseLabel: [String, Number],
      id: String, /* 當indeterminate為真時,為controls提供相關連的checkbox的id,表明元素間的控制關係*/
      controls: String, /* 當indeterminate為真時,為controls提供相關連的checkbox的id,表明元素間的控制關係*/
      border: Boolean,
      size: String
    },
    methods: {
      addToStore() {
        if (
          Array.isArray(this.model) &&
          this.model.indexOf(this.label) === -1
        ) {
          this.model.push(this.label);
        } else {
          this.model = this.trueLabel || true;
        }
      },
      handleChange(ev) {
        if (this.isLimitExceeded) return;
        let value;
        if (ev.target.checked) {
          value = this.trueLabel === undefined ? true : this.trueLabel;
        } else {
          value = this.falseLabel === undefined ? false : this.falseLabel;
        }
        this.$emit('change', value, ev);
        this.$nextTick(() => {
          if (this.isGroup) {
            this.dispatch('ElCheckboxGroup', 'change', [this._checkboxGroup.value]);
          }
        });
      }
    },
    created() {
      this.checked && this.addToStore();
    },
    mounted() { // 為indeterminate元素 新增aria-controls 屬性
      if (this.indeterminate) {
        this.$el.setAttribute('aria-controls', this.controls);
      }
    },
    watch: {
      value(value) {
        this.dispatch('ElFormItem', 'el.form.change', value);
      }
    }
  };
</script>
複製程式碼

是不是看的一臉懵逼,最好是開啟官網,對照checkbox用法一項項來分析其原理

核取方塊整體html結構

同單選框類似,核取方塊的示意圖如下,無非就是左右2部分組成,外層套一個label,並隱藏原生的<input type='checkbox'>

Element原始碼分析系列6-Checkbox(核取方塊)
簡化的html結構如下所示

<label ...>
    <span class='el-checkbox__input'>
        <span class='el-checkbox__inner'></span>
        <input type='checkbox' .../>
    </span>
    <span class='el-checkbox__label'>
        <slot></slot>
        <template v-if="!$slots.default">{{label}}</template>
    </span>
</label>
複製程式碼

這裡具體參考上一篇單選按鈕的文章,重點說下上圖的藍色方框內的勾是怎麼實現的,也就是選中狀態,開始我以為是一個類似Icon的東西,然而並不是,檢視css程式碼如下

&::after {
      box-sizing: content-box;
      content: "";
      border: 1px solid $--checkbox-checked-icon-color;
      border-left: 0;
      border-top: 0;
      height: 7px;
      left: 4px;
      position: absolute;
      top: 1px;
      transform: rotate(45deg) scaleY(0);
      width: 3px;
      transition: transform .15s ease-in .05s;
      transform-origin: center;
}
複製程式碼

很明顯,這是el-checkbox__inner類的after偽元素,裡面是一個只有右下border的長方形經過旋轉45度後的圖形,也就是一個勾的形狀,所以這個勾只是純粹的css實現而已,好處是簡化了html結構,並且還用了transition來新增點選後勾變大的動畫效果,這裡是通過過渡transformscaleY的值來實現,未選中時scaleY為0,選中時為1,就實現了勾放大的效果
因此要善用偽元素,會簡化很多不必要的程式碼

Vue中的核取方塊原理

首先來看Vue中的核取方塊是怎麼實現的,瞭解這個有助於理解Element的實現,官網介紹如下

Element原始碼分析系列6-Checkbox(核取方塊)
上圖中單個核取方塊使用bool值,多個核取方塊使用陣列即可,這裡其實Vue在幕後做了許多工作,找到Vue中相關原始碼如下

function genCheckboxModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
) {
  const number = modifiers && modifiers.number
  const valueBinding = getBindingAttr(el, 'value') || 'null'
  const trueValueBinding = getBindingAttr(el, 'true-value') || 'true'
  const falseValueBinding = getBindingAttr(el, 'false-value') || 'false'
  addProp(el, 'checked',
    `Array.isArray(${value})` +
    `?_i(${value},${valueBinding})>-1` + (
      trueValueBinding === 'true'
        ? `:(${value})`
        : `:_q(${value},${trueValueBinding})`
    )
  )
  addHandler(el, 'change',
    `var $$a=${value},` +
        '$$el=$event.target,' +
        `$$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});` +
    'if(Array.isArray($$a)){' +
      `var $$v=${number ? '_n(' + valueBinding + ')' : valueBinding},` +
          '$$i=_i($$a,$$v);' +
      `if($$el.checked){$$i<0&&(${genAssignmentCode(value, '$$a.concat([$$v])')})}` +
      `else{$$i>-1&&(${genAssignmentCode(value, '$$a.slice(0,$$i).concat($$a.slice($$i+1))')})}` +
    `}else{${genAssignmentCode(value, '$$c')}}`,
    null, true
  )
}
複製程式碼

這就是處理checkbox的v-model的程式碼,我們只需要知道這段程式碼大概在做啥就行,細節不用太清楚,程式碼中首先獲取v:bind繫結的value的值,也就是下面示例程式碼中的Jack(注意程式碼中其實處理了不是v:bind的情況,具體看原始碼),然後genCheckboxModel這個函式的引數中的value就是v-model的值,也就是下面的checkedNames,接下來addProp方法的邏輯:如果checkedNames是陣列,則通過indexOf查詢Jack是否在checkedNames中,如果在則給input新增checked屬性代表被選中,其次如果checkedNames不是陣列,則直接比較2者是否相等來決定是否給input新增checked屬性

<input type="checkbox" id="jack" value="Jack" v-model="checkedNames">

由上面的分析可見,核取方塊的選中的checked屬性是Vue幕後新增的,通過值的比較來決定是否新增該屬性
然後來看addHandler方法,這個方法給核取方塊新增了change事件,原生核取方塊點選後它的checked屬性會改變(true或false),但是Vue中的checkedNames的值會跟著變化,這裡就是addHandler所做的工作了,該方法裡面總體邏輯就是首先判斷checkedNames是否是陣列,如果是且該核取方塊被選中,則將該核取方塊的值加入checkedNames陣列中,如果該核取方塊沒有被選中,則從陣列中去掉它(注意這裡沒有用splice,而是2個slice後concat合併成一個陣列,splice會改變原始陣列,這樣就不會)

所以onchange這個事件也是Vue幕後處理的,因此checkedNames陣列就能夠隨著我們點選不同的核取方塊而同步變化

checkbox原始碼分析

接下來我們按官網羅列的功能依次分析

禁用功能

禁用功能最簡單,使用起來如下程式碼,只需新增disabled屬性即可

<el-checkbox v-model="checked1" disabled>備選項1</el-checkbox>
複製程式碼

原始碼裡對應的:disabled屬性

 <input
    v-else
    class="el-checkbox__original"
    type="checkbox"
    aria-hidden="true"
    :disabled="isDisabled"
    :value="label"
    :name="name"
    v-model="model"
    @change="handleChange"
    @focus="focus = true"
    @blur="focus = false"
>
複製程式碼

,這裡isDisabled是個計算屬性,因為要考慮到核取方塊組元件的存在,核取方塊組元件<el-checkbox-group>也有disabled屬性,且核取方塊組元件是核取方塊元件的父級元件

isDisabled() {
        return this.isGroup
          ? this._checkboxGroup.disabled || this.disabled || (this.elForm || {}).disabled
          : this.disabled || (this.elForm || {}).disabled;
      },
複製程式碼

這裡首先判斷自己是不是被包含在核取方塊組元件內,如果是的話那麼禁用屬性就是父級的核取方塊組元件的禁用屬性,否則就是自己的屬性,關於如何判斷是否被包含在核取方塊組元件內,前面系列文章已經介紹過了

多選框組元件

這裡翻看Vue官網,示例程式碼說明僅僅需要把多個核取方塊的input的v-model設定為同一個陣列就能達到核取方塊組的目的

 <input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
  <label for="jack">Jack</label>
  <input type="checkbox" id="john" value="John" v-model="checkedNames">
  <label for="john">John</label>
  <input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
  <label for="mike">Mike</label>
複製程式碼

但是檢視Element的程式碼,還單獨抽象出了一個<el-checkbox-group>元件,這樣做的好處在於不用給每個核取方塊元件設定v-model,只需給<el-checkbox-group>設定v-model即可,且這個抽象出的元件還能新增其他很多自定義的屬性,相當於新增一個父元件統一控制所有子核取方塊的某些行為

<el-checkbox-group>的程式碼很簡單,html部分如下

<template>
  <div class="el-checkbox-group" role="group" aria-label="checkbox-group">
    <slot></slot>
  </div>
</template>
複製程式碼

就是一個div裡面放置了一個插槽,插槽的內容就是使用者放進去的<el-checkbox>元件,多選框框組元件的props如下

 props: {
      value: {},
      disabled: Boolean,
      min: Number,
      max: Number,
      size: String,
      fill: String,
      textColor: String
    },
複製程式碼

其中value是元件上v-model的用法,具體參考官網和前面文章的說明,這裡的min,max屬性控制了核取方塊框組最多和最少能選擇的核取方塊數量,這是怎麼實現的呢?
首先檢視原始碼注意到,這裡的邏輯並沒有放在<el-checkbox-group>裡實現,而是放在<el-checkbox>裡實現,因為你實際點選的是<el-checkbox>的input,所以需要在核取方塊元件內實現邏輯,相關程式碼如下

 model: {
        get() {
          return this.isGroup
            ? this.store : this.value !== undefined
              ? this.value : this.selfModel;
        },
        set(val) {
          if (this.isGroup) {
            this.isLimitExceeded = false;
            (this._checkboxGroup.min !== undefined &&
              val.length < this._checkboxGroup.min &&
              (this.isLimitExceeded = true));
            (this._checkboxGroup.max !== undefined &&
              val.length > this._checkboxGroup.max &&
              (this.isLimitExceeded = true));
            this.isLimitExceeded === false &&
            this.dispatch('ElCheckboxGroup', 'input', [val]);
          } else {
            this.$emit('input', val);
            this.selfModel = val;
          }
        }
      },

複製程式碼

這個model是計算屬性,在input裡面的v-model="model"處使用,代表核取方塊元件v-model的值,計算屬性的get,set用法參考官網,先看get,首先判斷是否被包含在核取方塊組元件內,如果是的話,model的值就等於this.store,這個store也是個計算屬性,如下

store() {
        return this._checkboxGroup ? this._checkboxGroup.value : this.value;
      },
複製程式碼

它也要判斷是否被包含在核取方塊組元件內,如果是則返回核取方塊組元件的value,這個value就是下面示例程式碼中的checkList

<el-checkbox-group v-model="checkList">
複製程式碼

因此這裡就把使用者傳遞進去的checkList這個陣列給傳遞到了子<el-checkbox>內,而this._checkboxGroup是在isGroup這個計算屬性中賦值的,它就是自己外層的<el-checkbox-group>元件

再來看set方法,set是給model賦值時觸發的方法,會在使用者點選核取方塊時觸發核取方塊的onchange事件,在這個事件裡面賦值,從而觸發set方法,set方法裡面用一個isLimitExceeded變數來判斷是否超出max和min的限制,如果min屬性存在,且val陣列的長度小於min,則說明已經越界,此時設定isLimitExceeded為true,max同理,val的值是在Vue原始碼裡處理的,這裡不用深究,後面當isLimitExceeded為false時也就是未越界時才用dispatch通知父元件自己更新後的val的值

一個疑惑是在<el-checkbox>內的input上繫結了@change="handleChange",程式碼如下

handleChange(ev) {
        if (this.isLimitExceeded) return;
        let value;
        if (ev.target.checked) {
          value = this.trueLabel === undefined ? true : this.trueLabel;
        } else {
          value = this.falseLabel === undefined ? false : this.falseLabel;
        }
        this.$emit('change', value, ev);
        this.$nextTick(() => {
          if (this.isGroup) {
            this.dispatch('ElCheckboxGroup', 'change', [this._checkboxGroup.value]);
          }
        });
      }
複製程式碼

上面的handleChange同樣向父checkGroup元件dispatch了value,那麼這個dispatch和上面的dispatch的區別在哪裡呢?仔細分析後發現這裡的dispatch僅僅是通知<el-checkbox-group>自己的值變化了,在<el-checkbox-group>上可以用@change來獲取變化後的值(使用者可以拿到該值進行進一步處理),而前面的dispatch則更新了<el-checkbox-group>的v-model屬性的值,這2個dispatch的作用是不同的,請仔細理解
然後handleChange裡this.$emit('change', value, ev)表示將value和ev原生事件物件傳遞給<el-checkbox>的onchange事件,因為使用者可能需要這個介面來獲取更新後的資料

最後再來看看當選中核取方塊時,css樣式變化的邏輯

<span class="el-checkbox__input"
      :class="{
        'is-disabled': isDisabled,
        'is-checked': isChecked,
        'is-indeterminate': indeterminate,
        'is-focus': focus
      }"
       aria-checked="mixed"
    >
複製程式碼

這個span代表模擬的核取方塊按鈕,其中is-checked類代表選中時的樣式類,這個類由isChecked控制,這是個計算屬性,程式碼如下

isChecked() {
        if ({}.toString.call(this.model) === '[object Boolean]') {
          return this.model;
        } else if (Array.isArray(this.model)) {
          return this.model.indexOf(this.label) > -1;
        } else if (this.model !== null && this.model !== undefined) {
          return this.model === this.trueLabel;
        }
      },
複製程式碼

第一步判斷this.model是不是bool型別,注意這裡的判斷方法,Object.prototype.toString.call來判斷才是最可靠的,當model是bool時說明這個值就控制這個核取方塊他自己,如果這個model是陣列,則判斷label在不在該陣列中,如果在則表示選中了該核取方塊,從而isChecked為true,label是使用者定義在核取方塊上的屬性,代表該核取方塊的值,具體看官網

主要內容差不多這麼多,其實還有很多細節沒寫完,具體可以參考原始碼啦

相關文章