在源码里手撕ElementPlus的Radio组件是如何取消选中的

655 阅读3分钟

在工作中有时候遇到表单中的一个单选框,我们一般会用到ElementPlus el-radio组件,但该radio组件默认是没有取消选中功能的,如果表单中这一项是必填的还比较合理。但如果是非必填的话,用户使用时还是会有不好体验的。今天我就来实现下这个可取消选中的功能,并从ElementPlus、vue3源码中了解为什么是这样,做到知其所以然。

基础版

const hobby = ref<null|number>(null)
const hobbyHandle = (label:number) => {
    hobby.value && hobby.value === label ? (hobby.value = null) : (hobby.value = label)
}
<el-radio v-model="hobby" :label="1" @click.prevent="hobbyHandle(1)">踢足球</el-radio>
<el-radio v-model="hobby" :label="2" @click.prevent="hobbyHandle(2)">打篮球</el-radio>

上面的代码是实现了取消选中的,利用prevent修饰符点击时不触发默认事件并使用数据双向绑定根据情况手动改变hobby状态继而改变radio元素的状态。但因为使用了prevent所以会有两个缺陷,如下:

  • 不会触发el-radio标签上的change事件。
  • 上层使用了el-radio-group标签包裹上面的代码,并在el-radio-group上监听了change事件,也不会触发。

接下来我们去ElementPlus和vue3源码里一探究竟,并实现。

源码探索

在ElementPlus/packages/components/radio/src/radio.vue里radio的元素是这个input。

<input
  ref="radioRef"
  v-model="modelValue"
  :class="ns.e('original')"
  :value="label"
  :name="name || radioGroup?.name"
  :disabled="disabled"
  type="radio"
  @focus="focus = true"
  @blur="focus = false"
  @change="handleChange"
/>

其中主要关注v-model绑定的modelValue,是从useRadio的hook函数里返回的。

export const useRadio = (
  props: { label: RadioProps['label']; modelValue?: RadioProps['modelValue'] },
  emit?: SetupContext<RadioEmits>['emit']
) => {
  const radioRef = ref<HTMLInputElement>()
  const radioGroup = inject(radioGroupKey, undefined)
  const isGroup = computed(() => !!radioGroup)
  const modelValue = computed<RadioProps['modelValue']>({
    get() {
      return isGroup.value ? radioGroup!.modelValue : props.modelValue!
    },
    set(val) {
      if (isGroup.value) {
        radioGroup!.changeEvent(val)
      } else {
        emit && emit(UPDATE_MODEL_EVENT, val)
      }
      radioRef.value!.checked = props.modelValue === props.label
    },
  })

  
  return {
    radioRef,
    isGroup,
    radioGroup,
    modelValue,
  }
}

原来modelValue是一个计算属性,读取的时候会先判断是否包含在el-radio-group标签下,如果是就取radioGroup(radioGroup是使用inject注入的,在radio-group.vue内应该有个对应的provide提供)里的modelValue属性值,否则取el-radio的props.modelValue。更改的时候也会先判断,如果包含在el-radio-group标签下会执行radioGroup.changeEvent方法,否则会执行emit传递事件到el-radio标签上的change事件并执行。

const changeEvent = (value: RadioGroupProps['modelValue']) => {
  emit(UPDATE_MODEL_EVENT, value)
  nextTick(() => emit('change', value))
}
provide(
  radioGroupKey,
  reactive({
    ...toRefs(props),
    changeEvent,
    name,
  })
)

在ElementPlus/packages/components/radio/src/radio-group.vue看到changeEvent,其执行的作用就是emit传递事件到el-radio-group标签上的change事件和其双向绑定的默认事件并执行。

根据以上源码,我们解决的思路就有了,首先封装一个自己的radio组件,并通过判断是否存在radioGroupKey的inject,如果存在则判定为在radio-group内部并手动调用其changeEvent方法,如果不在则手动发送radio的change事件。

但我还有个模糊的点想弄清楚,就是el-radio其实就是type=radio的input元素,那它又是怎么和v-model配合完成选中和不选中操作的呢?抱着这个疑问,我们得去看看vue源码里v-model的实现。

const getModelAssigner = (vnode: VNode): AssignerFn => {
  const fn = vnode.props!['onUpdate:modelValue']
  return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
const vModelRadio: ModelDirective<HTMLInputElement> = {
  created(el, { value }, vnode) {
    el.checked = looseEqual(value, vnode.props!.value)
    el._assign = getModelAssigner(vnode)
    addEventListener(el, 'change', () => {
      el._assign(getValue(el))
    })
  },
  beforeUpdate(el, { value, oldValue }, vnode) {
    el._assign = getModelAssigner(vnode)
    if (value !== oldValue) {
      el.checked = looseEqual(value, vnode.props!.value)
    }
  }
}

以上是v-model指令处理type=radio的input元素时的调用。looseEqual函数其实就是判定两个参数值是否相等,在created钩子里初始化第一件事就是判定v-model绑定的值和vnode的真实值是否相等,如果相等input元素的checked属性则为true,即当前的radio被选中,然后给input元素添加change事件,当操作radio dom操作状态有变化时,change事件执行更改v-model上指定的状态。同时在beforeUpdate钩子内当v-model上指定的状态变化时,如果和原先值不同就改变input元素的checked值。这就是数据双向绑定,原来radio选中状态是通过checked属性来改变的,所以ElementPlus源码里有这么一行代码!

radioRef.value!.checked = props.modelValue === props.label

CancelableRadio组件实现

<script setup lang="ts">
import { radioGroupKey } from 'element-plus'
// props
const { label, disabled, modelValue } = defineProps<{
  modelValue: string | number | boolean | null
  label: string | number | boolean
  disabled?: boolean
}>()
// emit
const emit = defineEmits<{
  (e: 'update:modelValue', val: string | number | boolean | null): void
  (e: 'change', val: string | number | boolean | null): void
}>()

const radioVal = computed({
  get() {
    return modelValue
  },
  set(val) {
    emit('update:modelValue', val)
  }
})
// 拿到radio-group注入的对象
const radioGroup = inject(radioGroupKey)
// 点击处理选中/取消选中逻辑
const handleClick = () => {
  // 在radio-group内
  if (radioGroup) {
    if (!disabled && !radioGroup?.disabled) {
      // 调用changeEvent事件,触发radio-grou的change事件
      radioGroup?.changeEvent(
        radioGroup?.modelValue == label
          ? ((typeof label == 'string' ? '' : null) as string | number | boolean)
          : label
      )
    }
  } else {
    // 不在radio-group内
    if (!disabled) {
      radioVal.value && radioVal.value === label
        ? (radioVal.value = null)
        : (radioVal.value = label)
      // 发送change事件
      nextTick(() => {
        emit('change', radioVal.value)
      })
    }
  }
}
</script>
<template>
  <el-radio
    :label="label"
    v-model="radioVal"
    :disabled="disabled"
    @click.prevent="handleClick"
    v-bind="$attrs"
  >
    <slot />
  </el-radio>
</template>

使用组件,如下

const hobby = ref<null | number>(null)
const groupHandle = (v: number) => {
  console.log('group:', v)
}

<el-radio-group v-model="hobby" @change="groupHandle">
    <CancelableRadio :label="1">踢足球</CancelableRadio>
    <CancelableRadio :label="2">打篮球</CancelableRadio>
</el-radio-group>