Radio 单选按钮
前置知识
本文继续带你看表单组件 radio,如果你没有读过另一篇文章 Input,我建议你先看完那个再来,因为很多东西在那里面分析了。
首先让我们来了解一下 radio 在表单中的作用:
- radio 是单选按钮,当我们点击的时候会有选中和不选中状态
- 在单选按钮组里用于选择多个相互排斥的按钮
原生的 radio 想必大家都很熟悉,平时开发中也会经常用到,先看一下它常用的两个属性
name
单选按钮的名称value
单选按钮需要传给服务器的值
这里重点关注一下value
,它在前端页面上并不会起到什么作用,甚至不会显示,但是最主要的就是可以通过它将单选按钮选择的值传递给服务器,好让后台程序知道用户选择了什么。为什么要讲这个,别急,对后面的理解肯定有帮助。
既然 ElementUI 是基于 Vue 开发的,那么在 Vue 中是如何使用 radio 的呢?
移步官网查看表单输入绑定
从官网中我们可以看到实现 radio 的双向绑定也是使用了v-model
语法糖,它会根据控件类型自动选取正确的方法来更新元素。那么在 radio 组件中就等效于给value
属性和input
事件同时绑定一个响应式数据。当选中单选按钮时v-model
绑定的值通常就是value
的值
<!-- 当选中时,`picked` 为字符串 "a" -->
<input type="radio" v-model="picked" value="a">
基本结构
<template>
<label class="el-radio">
<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>
</template>
从这个结构中我们可以看出整个 radio 组件包裹在 label 标签中,首先简单了解一下 label 标签,它是为 input 元素定义标记的,label 元素不会向用户呈现任何特殊效果,当用户选择该标签时,浏览器就会自动将焦点转到和标签相关的表单控件上,也就是说当我们点击 label 时,其实就是点击了内部的 input 标签。这样做为用户点击按钮提供了便捷,只要在 label 范围内点击都能触发按钮的事件。
里层有两个span
标签,第一个表示的是前面的小圆圈,由于各浏览器对于标签的默认样式不统一,我们又需要保证我们的组件在任何地方运行都能保持一致的效果,通常会把原生 input 样式隐藏起来,通过一个span
或者div
来模拟,源码的具体样式后期再分析。第二个span
表示的是按钮后面的显示文字,默认传递的内容会在插槽中渲染,如果用户没有直接传递内容,那么就需要把用户传递的 label 值显示出来,这就是后面的template
做的事。
radio 属性
接下来重点看一下 input 属性
<input
ref="radio"
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"
/>
这里面会涉及 radio 组件的属性和方法,所以我打算放在一起来分析。
有一些在我的另一篇文章 Input里面分析过,这里就不再赘述,建议传送过去看一下。
- 通过
el-radio__original
类将 input 隐藏同时还能触发事件 :value
是把父组件传递过来的 label 给原生value
属性:name
绑定原生name
这里重点要说的是v-model="model"
,这个怎么理解呢?不急,慢慢解释。
当我们需要在多个单选按钮中里面来控制同一个值时,要使用v-model
双向绑定一个值,这个值是什么?看一个例子
<template>
<el-radio v-model="gender" label="0">男</el-radio>
<el-radio v-model="gender" label="1">女</el-radio>
</template>
data() {
return {
gender: '0'
}
}
使用组件发现v-model
绑定的是父组件的gender
值,但是子组件本身不能够直接使用v-model='value'
来双向绑定数据,因为这个value
来自于父组件,根据 Vue 的单项数据流,子组件一般情况下是不能直接更改父组件传递过来的数据的,所以需要定义一个自己的model
用来绑定 radio 组件的数据,但是这个model
也不是通过data
来写死的,因为它是取决于外界传进来的value
值,同时还要修改这个值。
从源码中可以看到,model
是一个计算属性,当它被当成一个双向数据绑定的值时,就不能是一个函数,而是一个对象,提供get
和set
方法。
model: {
get () {
// 如果在一个单选按钮组里就是按钮组的值
return this.isGroup ? this._radioGroup.value : this.value
},
set (val) {
// 如果是 radio 包裹在按钮组里,那么 model 的改变就需要触发父组件的 input 事件
if (this.isGroup) {
this.dispatch('ElRadioGroup', 'input', [val])
} else {
this.$emit('input', val)
}
// 如果当前的 model 等于组件的 label 就表示当前这个按钮被选中了
this.$refs.radio &&
(this.$refs.radio.checked = this.model === this.label)
}
}
有关
dispatch
方法参考另一篇文章 Input,里面有非常详细的分析emitter.js
文件的详细解释也以及上传到我的 Github 上了,欢迎 star!
当我们需要获取model
值时会调用它的get
方法,get方法里面会判断 radio 是不是在一个单选按钮组里(具体见下文),如果在,那么 radio 本身是没有value
的,value
是通过radio-group
的v-model
传递过来的,现在我们来看一下isGroup
这个计算属性:
// 判断 radio 标签是否在按钮组里
isGroup () {
let parent = this.$parent
while (parent) {
if (parent.$options.componentName !== 'ElRadioGroup') {
parent = parent.$parent
} else {
// 将按钮组添加到当前组件实例的 _radioGroup 属性上
// 并结束循环
this._radioGroup = parent
return true
}
}
return false
}
在这个属性里通过循环遍历父组件,直到找到radio-group
组件把它赋值给_radioGroup
,然后我们就能够在 radio
组件中使用radio-group
组件实例中的数据和方法了,这里为什么不能直接用$parent
呢?是因为有考虑到组件嵌套,如果radio
组件的直接父组件不是radio-group
,那$parent
指向的就不是我们需要的组件,所以这里一直遍历$parent
的$parent
就是为了将parent
指向正确的组件。
当然这里我们还可以使用「依赖注入」的方法去实现深层次的父子通信,在radio-group
中定义需要注入的属性,将radio-group
组件的实例传过去:
provide () {
return {
radioGroup: this
}
}
radio
组件使用inject
来接收,默认是一个空字符串:
inject: {
radioGroup: {
default: ''
}
}
至于methods
里面的方法handleChange
是用来处理 radio 的 change 事件的,但是目前还不明白为什么要使用nextTick()
,希望大佬能分享一下。
handleChange () {
// 这里不太清楚为什么使用 nextTick()
this.$nextTick(() => {
this.$emit('change', this.model)
// 如果存在按钮组
this.isGroup &&
this.dispatch('ElRadioGroup', 'handleChange', this.model)
})
}
RadioGroup 单选按钮组
ElementUI 提供了单选按钮组来包裹一组互斥的按钮,使得我们在使用的时候只需要将v-model
双向绑定在radio-group
上,而在radio
里面只需要传入label
即可。来看一下RadioGroup
组件的封装:
基本结构
<template>
<component
:is="_elTag"
class="el-radio-group"
role="radiogroup"
@keydown="handleKeydown"
>
<slot></slot>
</component>
</template>
可以看出来结构还是很简单的,就是一个component
包裹了一个插槽,使用:is
来决定需要将component
渲染成哪个组件,_elTag
是一个计算属性
_elTag() {
return (this.$vnode.data || {}).tag || 'div';
}
返回的是当前组件的虚拟 DOM 节点的标签,默认是div
。
接着往script
里面看,一开始就定义了一个对象,但是这个对象又不是普通的对象,它是被冻结起来的,这里就要详细说一下Object.freeze()
的作用了。
该方法用于冻结一个对象,被冻结的对象不可以修改,也就是对象身上不能添加或者删除属性,也不能修改「已有属性的可枚举性、可配置性、可写性以及原有的值」。另外它的原型对象也不允许修改,返回的是这个对象本身而不是副本。如果强行修改对象,一般情况下是静默失败的,也就是不会报错,但是在「严格模式」下会抛出TypeError
异常。
值得一提的是它是「浅冻结」,对于被冻结的对象中如果某个属性是一个「引用类型」的值,那么引用类型的值只要地址不发生变化它的属性值是可以更改的,要想实现「深冻结」就需要使用递归循环对象,具体实现参考 MDN Object.freeze()
// 冻结对象
const keyCode = Object.freeze({
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40
});
源码是将键盘上的「上下左右」按键的键盘码作为一个对象冻结起来了,这样能够防止后续代码不小心修改了它的值。那么为什么要使用键盘码呢?
在实际使用过程中,可以发现使用方向键可以切换选中的按钮,这也是为了使用过程中尽量减少手离开键盘吧
// 左右上下按键 可以在 radio 组内切换不同选项
handleKeydown(e) {
// 事件触发的元素
const target = e.target;
// 如果当前的不是 input 元素,那就是 label 标签
const className = target.nodeName === 'INPUT' ? '[type=radio]' : '[role=radio]';
const radios = this.$el.querySelectorAll(className);
const length = radios.length;
// 拿到事件触发元素在所有 radio 里的索引
const index = [].indexOf.call(radios, target);
const roleRadios = this.$el.querySelectorAll('[role=radio]');
switch (e.keyCode) {
case keyCode.LEFT:
case keyCode.UP:
e.stopPropagation();
e.preventDefault();
// 索引为 0 表示当前按钮的第一个触发了键盘事件
if (index === 0) {
// 选中最后一个
roleRadios[length - 1].click();
roleRadios[length - 1].focus();
} else {
// 往前选择
roleRadios[index - 1].click();
roleRadios[index - 1].focus();
}
break;
case keyCode.RIGHT:
case keyCode.DOWN:
// 如果当前是最后一个,那么接下来就选中第一个
if (index === (length - 1)) {
e.stopPropagation();
e.preventDefault();
roleRadios[0].click();
roleRadios[0].focus();
} else {
// 往后选择
roleRadios[index + 1].click();
roleRadios[index + 1].focus();
}
break;
default:
break;
}
}
分析一下具体实现的步骤:
- 在事件被触发的时候判断按下键盘时触发该事件的元素
- 拿到触发事件的元素在整个按钮组里的位置
- 判断按下的是哪一个按键,并触发 label 标签的
click
和focus
事件
最后还有一个监听属性value
// 监听 value 如果发生变化就 form-item 组件触发 change 事件
watch: {
value(value) {
this.dispatch('ElFormItem', 'el.form.change', [this.value]);
}
}
因为我们的组件最终肯定都是会放在 form 表单里面的,监听value
是为了当它的值发生变化时及时通知 form 表单更新数据。
总结与梳理
最后来总结一下 radio 组件的封装:
- radio 组件的功能是在已有选项中选择数据
- 使用了 label 标签包裹
- 提供了
v-model
进行数据双向绑定 - 提供了
radio-group
包裹radio
使得在一组里面只能单一选择 - 提供了方向键切换选中的按钮
虽然提供的功能很简单,通过阅读和分析,我们的编程能力一定会有所提高的,再自己动手封装一个 radio 组件你会更加了解一个组件的封装需要考虑哪些问题。
OK,下一篇再见。
传送门
【2020.3.15】超详细 ElementUI 源码分析 —— Input
【2020.3.16】超详细 ElementUI 源码分析 —— Layout