Element原始碼分析系列4-Radio(單選框)

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

簡介

單選框這個元件看似簡單,實則知識點眾多,較為複雜,如果寫一個html的原生單選框,那確實很簡單,但是封裝一個完整的單選元件就不那麼簡單了,接下來我們先介紹Vue的單選框的一些原理,然後再分析Element的單選框實現

Element原始碼分析系列4-Radio(單選框)

原生單選 Vs Vue單選

原生單選框很簡單,如果我們要實現一個男女性別的單選按鈕組,程式碼只需如下幾句

<input type="radio" name="sex" value="male" checked>男</input>
<input type="radio" name="sex" value="female">女</input>
複製程式碼

上面的男的單選按鈕新增了checked屬性,表示被選中,value屬性表示單選按鈕的值,可以給每個input新增onchangeonclick事件來通過點選獲取其值,也可以通過一個按鈕點選後遍歷所有單選的input按鈕,獲取checked屬性為true的那一項,然後再獲取其value
注意如何讓一組單選互斥,也就是說同一時刻只能有一個單選被選中,name屬性就是這個作用, 通過把一些單選按鈕的name設定為同一個值,就達到了互斥的效果

而Vue的單選框則有所不同,程式碼如下

Element原始碼分析系列4-Radio(單選框)
它只需要一個v-model即可達到互斥效果,v-model的值是data裡面的資料,進行了雙向繫結,由此可見並沒有通過name屬性來達到互斥,那麼時怎麼實現的呢?首先先來了解下v-model的本質,v-model本質上是語法糖

Element原始碼分析系列4-Radio(單選框)
官網說的很清楚,這就相當於進行了一個雙向繫結,對input輸入框的input事件進行監聽,當鍵盤敲下時就實時改變searchText的值,同時修改searchText的值,輸入框的value也跟著變化。那麼底層是怎麼處理互斥的呢?通過檢視v-model相關原始碼

function genRadioModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
) {
  const number = modifiers && modifiers.number
  let valueBinding = getBindingAttr(el, 'value') || 'null'
  valueBinding = number ? `_n(${valueBinding})` : valueBinding
  addProp(el, 'checked', `_q(${value},${valueBinding})`)
  addHandler(el, 'change', genAssignmentCode(value, valueBinding), null, true)
}
複製程式碼

上述程式碼是處理單選框model的程式碼,genRadioModel引數中的value就是input的value的值,而valueBinding的值就是v-model中的v-bind:value的值

 <input type="radio" id="jack" value="Jack" v-model="name">
複製程式碼

如果示例如上,那麼addProp這個方法就會把checked屬性的值_q('Jack',name)放入屬性列表,這裡_q是looseEqual方法的簡寫,表示寬鬆比較(如果是物件,則通過JSON.stringify轉成字串比較,否則直接String()轉換比較)2個值是否相同,這樣這裡的邏輯就明確了,如果單選框的value的值和v-model的值相同,那麼就加上一個checked屬性,表示該單選被選中,自然而然其他單選框value的值和v-model的值不同,所以就不是選中狀態,沒有checked屬性,所以達到了互斥效果

原始碼分析

整個單選元件的原始碼不算太長,但是裡面知識點很多,先上原始碼,官網程式碼點此

<template>
  <label
    class="el-radio"
    :class="[
      border && radioSize ? 'el-radio--' + radioSize : '',
      { 'is-disabled': isDisabled },
      { 'is-focus': focus },
      { 'is-bordered': border },
      { 'is-checked': model === label }
    ]"
    role="radio"
    :aria-checked="model === label"
    :aria-disabled="isDisabled"
    :tabindex="tabIndex"
    @keydown.space.stop.prevent="model = isDisabled ? model : label"
  >
    <span class="el-radio__input"
      :class="{
        'is-disabled': isDisabled,
        'is-checked': model === label
      }"
    >
      <span class="el-radio__inner"></span>
      <input
        class="el-radio__original"
        :value="label"
        type="radio"
        aria-hidden="true"
        v-model="model"
        @focus="focus = true"
        @blur="focus = false"
        @change="handleChange"
        :name="name"
        :disabled="isDisabled"
        tabindex="-1"
      >
    </span>
    <span class="el-radio__label" @keydown.stop>
      <slot></slot>
      <template v-if="!$slots.default">{{label}}</template>
    </span>
  </label>
</template>
<script>
  import Emitter from 'element-ui/src/mixins/emitter';
  export default {
    name: 'ElRadio',
    mixins: [Emitter],
    inject: {
      elForm: {
        default: ''
      },
      elFormItem: {
        default: ''
      }
    },
    componentName: 'ElRadio',
    props: {
      value: {},
      label: {},
      disabled: Boolean,
      name: String,
      border: Boolean,
      size: String
    },
    data() {
      return {
        focus: false
      };
    },
    computed: {
      isGroup() {
        let parent = this.$parent;
        while (parent) {
          if (parent.$options.componentName !== 'ElRadioGroup') {
            parent = parent.$parent;
          } else {
            this._radioGroup = parent;
            return true;
          }
        }
        return false;
      },
      model: {
        get() {
          return this.isGroup ? this._radioGroup.value : this.value;
        },
        set(val) {
          if (this.isGroup) {
            this.dispatch('ElRadioGroup', 'input', [val]);
          } else {
            this.$emit('input', val);
          }
        }
      },
      _elFormItemSize() {
        return (this.elFormItem || {}).elFormItemSize;
      },
      radioSize() {
        const temRadioSize = this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
        return this.isGroup
          ? this._radioGroup.radioGroupSize || temRadioSize
          : temRadioSize;
      },
      isDisabled() {
        return this.isGroup
          ? this._radioGroup.disabled || this.disabled || (this.elForm || {}).disabled
          : this.disabled || (this.elForm || {}).disabled;
      },
      tabIndex() {
        return !this.isDisabled ? (this.isGroup ? (this.model === this.label ? 0 : -1) : 0) : -1;
      }
    },
    methods: {
      handleChange() {
        this.$nextTick(() => {
          this.$emit('change', this.model);
          this.isGroup && this.dispatch('ElRadioGroup', 'handleChange', this.model);
        });
      }
    }
  };
</script>
複製程式碼

首先分析template部分,分析一個元件首先得搞清楚元件的html結構,上面的程式碼結構簡化後如下

<label ...>
    <span class='el-radio__input'>
        <span class='el-radio__inner'></span>
        <input type='radio' .../>
    </span>
    <span class='el-radio__label'>
        <slot></slot>
        <template v-if="!$slots.default">{{label}}</template>
    </span>
</label>
複製程式碼

由此可見,整個元件是一個外層label套2個span,我們知道原生的radio標籤很醜,樣式在各個瀏覽器不統一,所以必須自己實現所有radio按鈕的樣式,一般做法是隱藏真正的input,自己用div或者span模擬input標籤,這裡的label放在最外層的作用是擴大滑鼠點選範圍,無論是點選在文字還是input上都能夠觸發響應,當然如下通過for屬性繫結input的id屬性也可以實現

<input id='t' type='radio'>
<label for='t'>點此</label>
複製程式碼

前者被稱為隱式連結,後者是顯示連結,很明顯前者不需要id,肯定前者好,label裡面2個內聯的span水平排列,根據下圖

Element原始碼分析系列4-Radio(單選框)
可以猜到,第一個span代表模擬的圓形按鈕,第二個span代表文字部分,而第一個span裡面又有一個span和input,這個span就是模擬的圓圈,而後面的input才是真正的radio按鈕,不過被隱藏了,那麼是怎麼隱藏的呢?檢視css如下

Element原始碼分析系列4-Radio(單選框)
真正的input透明度為0,且是絕對定位脫離文件流,因此不佔空間且我們看不到,注意不是display:none或者visibility:hidden,如果是none或者hidden的話則無法觸發滑鼠點選了,只有opacity:0才能達到目的,這是個需要注意的地方

接下來看label中的第二個span,這個span就是我們填充的文字

<span class='el-radio__label'>
        <slot></slot>
        <template v-if="!$slots.default">{{label}}</template>
</span>
複製程式碼

這個span裡做了處理,slot預設渲染我們在<el-radio></el-radio>間的文字,注意template,如果我們什麼都不填,比如我們這麼寫

<el-radio label='1'></el-radio>
複製程式碼

最終文字就渲染成其label的值

Element原始碼分析系列4-Radio(單選框)
template通過$slot.default進行判斷是否存在子元素從而決定是否渲染,注意template自己本身不會被渲染出來,只是起一個佔位符的作用

label標籤分析

label標籤有一大堆屬性,我們依次來看

 <label
    class="el-radio"
    :class="[
      border && radioSize ? 'el-radio--' + radioSize : '',
      { 'is-disabled': isDisabled },
      { 'is-focus': focus },
      { 'is-bordered': border },
      { 'is-checked': model === label }
    ]"
    role="radio"
    :aria-checked="model === label"
    :aria-disabled="isDisabled"
    :tabindex="tabIndex"
    @keydown.space.stop.prevent="model = isDisabled ? model : label"
  >
複製程式碼

首先第一句class="el-radio"表明了label的基礎類class,裡面有什麼呢?

@include b(radio) {
  color: $--radio-color;
  font-weight: $--radio-font-weight;
  line-height: 1;
  position: relative;
  cursor: pointer;
  display: inline-block;
  white-space: nowrap;
  outline: none;
  font-size: $--font-size-base;
複製程式碼

無非就是規定了一些很基礎的css樣式,滑鼠樣式,不換行,無輪廓,字型大小顏色等 然後第二句:class表明了動態繫結的類,其中有是否禁用,是否獲得焦點,是否有邊框,是否選中等。首先看是否禁用類is-disabled,部分scss程式碼如下

 .el-radio__inner {
    background-color: $--radio-disabled-input-fill;
    border-color: $--radio-disabled-input-border-color;
    cursor: not-allowed;

    &::after {
      cursor: not-allowed;
      background-color: $--radio-disabled-icon-color;
}
複製程式碼

可見禁用類就是修改了背景色和邊框色以及滑鼠樣式變為禁止符號,當然這只是樣式上的禁止,功能上的禁止是如何實現的呢?功能上的禁用是通過設定input的disabled屬性來實現,下面原始碼中的真正的input的:disabled="isDisabled"一句話就實現了單選按鈕禁止點選

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

isDisabled是計算屬性,程式碼如下

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

這裡首先通過isGroup來判斷自己是否是在單選組裡,單選組也是一個Element元件,程式碼如下,通過將一系列單選按鈕放在一起形成一個框組來進行操作,這裡只需設定一個v-model在最外層即可

 <el-radio-group v-model="radio2">
    <el-radio :label="3">備選項</el-radio>
    <el-radio :label="6">備選項</el-radio>
    <el-radio :label="9">備選項</el-radio>
  </el-radio-group>
複製程式碼

那麼isGroup是啥呢,看程式碼,它是一個計算屬性,首先獲取當前元件的父級元件,然後檢查其元件名是否是ElRadioGroup即單選框組,如果不是就繼續檢查父級的父級,這裡的知識在前面文章介紹過。這個方法會找到距離自己最近的父級ElRadioGroup元件

isGroup() {
        let parent = this.$parent;
        while (parent) {
          if (parent.$options.componentName !== 'ElRadioGroup') {
            parent = parent.$parent;
          } else {
            this._radioGroup = parent;
            return true;
          }
        }
        return false;
      },
複製程式碼

回過頭來看禁用的邏輯,當自己是被包含在單選框組元件內時,則禁用與否就等於單選框組的禁用與否,這很正常,畢竟整個框組都禁用了,自己也就被禁用了,如果只是單獨的單選框元件,則禁用就是自己的disabled這個prop

禁用邏輯結束,然後是{ 'is-focus': focus },這句話代表label標籤是否獲得is-focus類,通過focus控制,而focus在上面input的@foucus@blur中進行處理,也就是input是否獲得焦點,接下來的is-bordered通過使用者傳入的border屬性進行控制是否單選框有邊框,後面的is-checked類代表了當前單選按鈕被選中的樣式,通過model===label來控制,model是個計算屬性

model: {
        get() {
          return this.isGroup ? this._radioGroup.value : this.value;
        },
        set(val) {
          if (this.isGroup) {
            this.dispatch('ElRadioGroup', 'input', [val]);
          } else {
            this.$emit('input', val);
          }
        }
      },
複製程式碼

上面定義了getter和setter,getter首先判斷自己是否是在單選框組元件內,如果是舊返回單選框組的value,否則就是自己的value,而label則是使用者傳入的一個屬性,代表單選元件自己代表的值,這裡的一個難點是this.value到底是啥,檢視原始碼得知this.value是一個prop,但是官網上單選元件根本沒有這個value供使用者定義,這其實是在元件上使用v-model的做法,官網介紹如下

Element原始碼分析系列4-Radio(單選框)
可見v-model是個語法糖而已,轉換後就有了v-bind:value這個prop,因此在單選元件內得宣告一個叫value的prop,這樣就可以取到使用者定義的v-model的值,從而加以利用,而set方法裡面則必須通過this.$emit('input', val)觸發父元件上的oninput事件傳遞出新值,dispatch後面我們再討論


然後是這幾句
role="radio"
:aria-checked="model === label"
:aria-disabled="isDisabled"
複製程式碼

這幾句都是用來為不方便的人士提供的功能,比如螢幕閱讀器,role的作用是描述一個非標準的tag的實際作用。比如用div做button,那麼設定div 的 role="button",輔助工具就可以認出這實際上是個button。 aria的意思是Accessible Rich Internet Application,aria-*的作用就是描述這個tag在視覺化的情境中的具體資訊。比如:

<div role="checkbox" aria-checked="checked"></div>
複製程式碼

輔助工具就會知道,這個div實際上是個checkbox的角色,為選中狀態,然後是

:tabindex="tabIndex"
@keydown.space.stop.prevent="model = isDisabled ? model : label"
複製程式碼

其中tabindex規定了按下tab鍵該元素獲取焦點的順序,同樣是個計算屬性

tabIndex() {
    return !this.isDisabled ? (this.isGroup ? (this.model === this.label ? 0 : -1) : 0) : -1;
}
複製程式碼

如果為禁用狀態,tabindex為-1,則無法使用tab鍵使該元素獲取焦點,如果不是禁用狀態下,如果該單選按鈕是在單選框組元件內且是選中狀態則可以通過tab鍵獲取焦點,否則無法通過tab鍵獲取焦點, 當 tabindex > 0 的元素都切換之後,才會切換到 tabindex = 0 的元素,並且按出現的先後次序進行切換,這裡的邏輯就是tab只能訪問到選中狀態下的單選按鈕

後面這句@keydown.space.stop.prevent="model = isDisabled ? model : label"不清楚是幹啥的,我去掉了也可以正常使用元件,這裡說明按下空格鍵會改變model的值???

混入選項

注意js部分的mixin:[Emitter],首先介紹混入,混入 (mixins) 是一種分發 Vue 元件中可複用功能的非常靈活的方式。混入物件可以包含任意元件選項。當元件使用混入物件時,所有混入物件的選項將被混入該元件本身的選項。這裡將Emitter混入進了該元件,也就是說所有該元件都擁有Emitter中的方法,混入是一個陣列,我們進入emitter.js中看看混入了啥?

export default {
  methods: {
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.componentName;

      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;

        if (parent) {
          name = parent.$options.componentName;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};
複製程式碼

很明顯,這裡將methods進行了混入,新增了dispatchbroadcast方法,那麼為啥不直接在元件的methods裡寫這2個方法呢?原因在於這樣做會增大程式碼量,由於很多地方都會用到的公用方法,用混入的方法可以減少程式碼量,實現程式碼重用,比如有10個元件都要用這2個方法,那麼用混入每個元件就只寫一行程式碼,簡單很多。

混入的methods將會和元件原本的methods合併,如果衝突,則保留元件的methods裡的方法,然後我們來研究dispatch方法,該方法實現了向最近的特定父級元件傳送事件的邏輯,第一個引數是父級元件的名稱,第二個是事件名稱,第三個引數是事件引數,是一個陣列或者單獨的值,邏輯也很簡單:不斷地取到自己的父元件,判斷是否是目標元件,如果不是繼續去其父元件判斷,如果是則在父元件上呼叫$emit觸發事件,注意這裡的

parent.$emit.apply(parent, [eventName].concat(params));
複製程式碼

不能寫成

parent.$emit(eventName,...params)
複製程式碼

必須用apply定$emit的呼叫目標物件,因為是在父元件上觸發該事件而不是在dispatch裡,這裡你可能會說parent.$emit不就是在父元件上呼叫麼?其實不是,parent.$emit僅僅是拿到了emit這個方法而已,並沒有說明在哪裡呼叫! 這裡要特別注意

然後我們看看到底哪裡使用了dispatch方法,答案就是單選元件的methods裡

 methods: {
      handleChange() {
        this.$nextTick(() => {
          this.$emit('change', this.model);
          this.isGroup && this.dispatch('ElRadioGroup', 'handleChange', this.model);
        });
      }
    }
複製程式碼

這裡的handleChange是在單選元件內的input上繫結的,在單選按鈕失去焦點時觸發

<input @change="handleChange" .../>
複製程式碼

當點選不同的單選按鈕時會觸發該按鈕的原生onchange事件,這裡又向父級丟擲了一個change事件,這是因為單選元件需要一個@change來說明繫結值變化時觸發的事件,同時將this.model的值傳遞出去讓使用者拿到該值,如下程式碼

<el-radio v-model="v" label='1' @change="radioChange"></el-radio>
複製程式碼

然後如果該單選元件是在單選組元件內,則會像單選組元件傳送一個handleChange事件告訴父元件:我的值變化啦!否則怎麼通知父元件自己的值!

最後是這個$nextTick,這個就很微妙了,試著把nextTick去掉,發現單選元件點選新的元件後,列印出來的值是舊元件的值,這就有問題了,$nextTick的作用是將回撥延遲到下次 DOM 更新迴圈之後執行,但是這裡為啥加了nextTick後就能獲取新點選的單選元件的值了???不明白,希望有大佬能解釋下~

相關文章