单选框这个组件看似简单,实则知识点众多,较为复杂,若是写一个html的原生单选框,那确实很简单,可是封装一个完整的单选组件就不那么简单了,接下来咱们先介绍Vue的单选框的一些原理,而后再分析Element的单选框实现css
原生单选框很简单,若是咱们要实现一个男女性别的单选按钮组,代码只需以下几句html
<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的单选框则有所不一样,代码以下vue
v-model
便可达到互斥效果,
v-model
的值是data里面的数据,进行了双向绑定,因而可知并无经过
name
属性来达到互斥,那么时怎么实现的呢?首先先来了解下v-model的本质,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
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的值
$slot.default
进行判断是否存在子元素从而决定是否渲染,注意template本身自己不会被渲染出来,只是起一个占位符的做用
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后就能获取新点击的单选组件的值了???不明白,但愿有大佬能解释下~