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

栏目: 编程工具 · 发布时间: 8年前

内容简介:单选框这个组件看似简单,实则知识点众多,较为复杂,如果写一个html的原生单选框,那确实很简单,但是封装一个完整的单选组件就不那么简单了,接下来我们先介绍Vue的单选框的一些原理,然后再分析Element的单选框实现原生单选框很简单,如果我们要实现一个男女性别的单选按钮组,代码只需如下几句上面的男的单选按钮添加了

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

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

原生单选 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的单选框则有所不同,代码如下

Element源码分析系列4-Radio(单选框)
它只需要一个 v-model 即可达到互斥效果, v-model 的值是data里面的数据,进行了双向绑定,由此可见并没有通过 name

属性来达到互斥,那么时怎么实现的呢?首先先来了解下v-model的本质,v-model本质上是语法糖

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

官网说的很清楚,这就相当于进行了一个双向绑定,对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水平排列,根据下图

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

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

Element源码分析系列4-Radio(单选框)
真正的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的值

Element源码分析系列4-Radio(单选框)
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 的做法,官网介绍如下

Element源码分析系列4-Radio(单选框)
可见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后就能获取新点击的单选组件的值了???不明白,希望有大佬能解释下~


以上所述就是小编给大家介绍的《Element源码分析系列4-Radio(单选框)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。

Java Concurrency in Practice

Java Concurrency in Practice

Brian Goetz、Tim Peierls、Joshua Bloch、Joseph Bowbeer、David Holmes、Doug Lea / Addison-Wesley Professional / 2006-5-19 / USD 59.99

This book covers: Basic concepts of concurrency and thread safety Techniques for building and composing thread-safe classes Using the concurrency building blocks in java.util.concurrent Pe......一起来看看 《Java Concurrency in Practice》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

MD5 加密
MD5 加密

MD5 加密工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具