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

简介

单选框这个组件看似简单,实则知识点众多,较为复杂,若是写一个html的原生单选框,那确实很简单,可是封装一个完整的单选组件就不那么简单了,接下来咱们先介绍Vue的单选框的一些原理,而后再分析Element的单选框实现css

原生单选 Vs Vue单选

原生单选框很简单,若是咱们要实现一个男女性别的单选按钮组,代码只需以下几句html

<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的单选框则有所不一样,代码以下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的值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

能够猜到,第一个span表明模拟的圆形按钮,第二个span表明文字部分,而第一个span里面又有一个span和input,这个span就是模拟的圆圈,然后面的input才是真正的radio按钮,不过被隐藏了,那么是怎么隐藏的呢?查看css以下

真正的input透明度为0,且是绝对定位脱离文档流,所以不占空间且咱们看不到,注意不是 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的值

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后就能获取新点击的单选组件的值了???不明白,但愿有大佬能解释下~

相关文章
相关标签/搜索