簡介
單選框這個元件看似簡單,實則知識點眾多,較為複雜,如果寫一個html的原生單選框,那確實很簡單,但是封裝一個完整的單選元件就不那麼簡單了,接下來我們先介紹Vue的單選框的一些原理,然後再分析Element的單選框實現
原生單選 Vs Vue單選
原生單選框很簡單,如果我們要實現一個男女性別的單選按鈕組,程式碼只需如下幾句
<input type="radio" name="sex" value="male" checked>男</input>
<input type="radio" name="sex" value="female">女</input>
複製程式碼
上面的男的單選按鈕新增了checked
屬性,表示被選中,value
屬性表示單選按鈕的值,可以給每個input新增onchange
和onclick
事件來通過點選獲取其值,也可以通過一個按鈕點選後遍歷所有單選的input按鈕,獲取checked
屬性為true
的那一項,然後再獲取其value
注意如何讓一組單選互斥,也就是說同一時刻只能有一個單選被選中,name
屬性就是這個作用, 通過把一些單選按鈕的name
設定為同一個值,就達到了互斥的效果
而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的值
<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水平排列,根據下圖
可以猜到,第一個span代表模擬的圓形按鈕,第二個span代表文字部分,而第一個span裡面又有一個span和input,這個span就是模擬的圓圈,而後面的input才是真正的radio按鈕,不過被隱藏了,那麼是怎麼隱藏的呢?檢視css如下 真正的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的值
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-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
進行了混入,新增了dispatch
和broadcast
方法,那麼為啥不直接在元件的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後就能獲取新點選的單選元件的值了???不明白,希望有大佬能解釋下~