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

簡介

單選框這個組件看似簡單,實則知識點衆多,較爲複雜,若是寫一個html的原生單選框,那確實很簡單,可是封裝一個完整的單選組件就不那麼簡單了,接下來咱們先介紹Vue的單選框的一些原理,而後再分析Element的單選框實現css

原生單選 Vs Vue單選

原生單選框很簡單,若是咱們要實現一個男女性別的單選按鈕組,代碼只需以下幾句html

<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的單選框則有所不一樣,代碼以下vue

它只須要一個 v-model便可達到互斥效果, v-model的值是data裏面的數據,進行了雙向綁定,因而可知並無經過 name屬性來達到互斥,那麼時怎麼實現的呢?首先先來了解下v-model的本質,v-model本質上是語法糖

官網說的很清楚,這就至關於進行了一個雙向綁定,對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的值git

<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屬性,因此達到了互斥效果github

源碼分析

整個單選組件的源碼不算太長,可是裏面知識點不少,先上源碼,官網代碼點此element-ui

<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水平排列,根據下圖bash

能夠猜到,第一個span表明模擬的圓形按鈕,第二個span表明文字部分,而第一個span裏面又有一個span和input,這個span就是模擬的圓圈,然後面的input纔是真正的radio按鈕,不過被隱藏了,那麼是怎麼隱藏的呢?查看css以下

真正的input透明度爲0,且是絕對定位脫離文檔流,所以不佔空間且咱們看不到,注意不是 display:none或者 visibility:hidden,若是是none或者hidden的話則沒法觸發鼠標點擊了, 只有opacity:0才能達到目的,這是個須要注意的地方

接下來看label中的第二個span,這個span就是咱們填充的文本app

<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的值

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的作法,官網介紹以下

可見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後就能獲取新點擊的單選組件的值了???不明白,但願有大佬能解釋下~

相關文章
相關標籤/搜索