超详细 ElementUI 源码分析 —— Radio

3,946 阅读5分钟

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是一个计算属性,当它被当成一个双向数据绑定的值时,就不能是一个函数,而是一个对象,提供getset方法。

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-groupv-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 标签的clickfocus事件

最后还有一个监听属性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