Element源码分析系列4-Radio(单选框)

6,278 阅读6分钟

简介

单选框这个组件看似简单,实则知识点众多,较为复杂,如果写一个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添加onchangeonclick事件来通过点击获取其值,也可以通过一个按钮点击后遍历所有单选的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-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后就能获取新点击的单选组件的值了???不明白,希望有大佬能解释下~