简介
单选框这个组件看似简单,实则知识点众多,较为复杂,如果写一个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后就能获取新点击的单选组件的值了???不明白,希望有大佬能解释下~