element plus 中的表单控件是怎么封装出来的

762 阅读6分钟

大家好!今天我们来聊一聊如何封装 Vue 中的表单控件,并以 Element Plus 为例,探讨其表单组件的实现方式。相信使用过 Vue 的开发者对 Element Plus 并不陌生,它封装了许多优雅且易用的表单控件,例如 InputCheckboxCheckbox-GroupRadioRadio-Group 等。这些组件不仅功能强大,还能极大地提升开发效率。那么,这些控件是如何封装出来的呢?接下来,我们将从基础到进阶,逐步实现这些组件。

封装UIInput

<template>
  <div class="input-wrap">
    <input v-model="model"/>
  </div>
</template>

<script setup>
const model = defineModel()
</script>

<style lang="scss" scoped>
.input-wrap {
  box-shadow: 0 0 0 1px #dcdfe6 inset;
  border-radius: 4px;
  padding: 1px 11px;

  input {
    border: none;
    outline: none;
    color: #606266;
    width: 100%;
    box-sizing: border-box;
    line-height: 28px;
  }
}
</style>

使用UiInput

<template>
  <div>
    姓名: <UiInput v-model="name" />
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'

import UiInput from '@/components/UiInput/index.vue'

const name = ref('')

watch(name, (newVal) => {
  console.log(newVal)
})
</script>

<style scoped>
</style>

在这个例子中:

  • UiInput 组件中我们主要使用了v-modeldefineModel两个关键知识点。v-model 是vue提供的专门用来做数据双向绑定的。defineModel 是V3.4.0 以后新增的一个语法糖
  • 在使用的时候,我在UiInput 上使用v-model绑定了一个name变量。这个变量是响应式的,因为他使用ref 声明。
  • 我们使用watch 监听name 的变化,变化之后输出新的值。 这时候我在输入框中输入值就能在控制台中看到变化的值。

运行结果: image.png

上面提到defineModel 是3.4.0 以后才有的,如果使用的版本是3.4.0以前的版本改怎么办呢?

3.4.0 版本以前的UiInput 封装。

<template>
  <div class="input-wrap">
    <input @input="emitValue" />
  </div>
</template>

<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const emitValue = ($event) => {
  emit('update:modelValue', $event.target.value)
}
</script>

<style lang="scss" scoped>
.input-wrap {
  box-shadow: 0 0 0 1px #dcdfe6 inset;
  border-radius: 4px;
  padding: 1px 11px;

  input {
    border: none;
    outline: none;
    color: #606266;
    width: 100%;
    box-sizing: border-box;
    line-height: 28px;
  }
}
</style>

可以看到在3.4.0 以前的版本中我们封装UiInput 和3.4.0 以后的区别,主要在于:

  • defineModel 换成了defineProps 中得出来的modelValuedefineEmits中得出来的update:modelValue
  • input 上的v-model 换成了@input 事件

来看看效果:

image.png

UiCheckbox 封装

<template>
  <label class="check-wrap">
    <input type="checkbox" v-model="model" />
    <span class="check-span iconfont"
      :class="{ 
        'icon-checkbox-on': model,
        'icon-checkbox': !model
      }"
    >{{ label }}</span>
  </label>
</template>

<script setup>
const props = defineProps({
  label: {
    type: String,
    default: ''
  }
})

const model = defineModel()
</script>

<style lang="scss" scoped>
.check-wrap {
  position: relative;
  cursor: pointer;
  input {
    width: 0;
    height: 0;
    opacity: 0;
    position: absolute
  }

  .check-span {
    color: #606266;
    &::before{
      margin-right: 6px;
    }

    &.icon-checkbox-on {
      color: #409eff;
    }
  }
}
</style>

使用UiCheckbox

<template>
  <div class="form-wrap">
    <div class="form-item">
      <UiCheckbox label="是否记住密码" v-model="isRememberPwd" />
    </div>
    <p>{{ isRememberPwd }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UiCheckbox from '@/components/UiCheckbox/index.vue'

const isRememberPwd = ref(false)
</script>

<style lang="scss" scoped>
.form-wrap {
  width: 500px;

  .form-item {
    display: flex;
    align-items: center;
  }
}
</style>

在这个例子中:

  • 我们使用props传值的方式来传递label 的值,用于多选框显示的文案内容
  • 我们使用了label 标签包裹了typecheckboxinput标签和一个span标签。选中的时候无论是点击spaninput 都能切换选中状态。
  • 我们将input 标签的样式宽度设置为0,透明度设置为0 , 因为我们不需要checkbox 默认的样式,我只需要他的值。我们使用v-model 绑定了他的值。
  • 我们通过model的值来判断显示选中状态还是非选中状态。这里使用了iconfonts 阿里字体图标字体图标库。如果不会使用iconfonts 的可以百度搜索下,跟着操作即可,很简单的。
  • 使用时我们直接在UiChecbox上使用v-model 绑定值。

来看看运行效果

非选中状态:

image.png

选中状态:

image.png

UiCheckGroup 封装

<template>
  <div class="check-group">
    <slot></slot>
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'

const emit = defineEmits(['update:modelValue'])
const props = defineProps({
  modelValue: {
    type: Array,
    default: () => []
  }
})

const selectdValue = ref([...props.modelValue])
provide('checkGroup', {
  selectdValue,
  toggleOption (value) {
    const index = selectdValue.value.indexOf(value)
    if (index !== -1){
      selectdValue.value.splice(index, 1)
    } else {
      selectdValue.value.push(value)
    }
    emit('update:modelValue', selectdValue.value)
  }
})
</script>

<style lang="scss" scoped>
.check-group {
  :deep(.check-wrap) {
    margin-right: 20px;
  }
}
</style>

修改UiCheckBox 适应UiCheckboxGroup

<template>
  <label class="check-wrap">
    <input type="checkbox" v-model="model" :true-value="checkGroup ? label : true" />
    <span class="check-span iconfont"
      :class="{ 
        'icon-checkbox-on': model,
        'icon-checkbox': !model
      }"
    >{{ label }}</span>
  </label>
</template>

<script setup>
import { inject, watch } from 'vue';
const props = defineProps({
  label: {
    type: String,
    default: ''
  }
})

const model = defineModel()

const checkGroup = inject('checkGroup')
watch(model, () => {
  if (checkGroup) {
    checkGroup.toggleOption(props.label)
  }
})
</script>

<style lang="scss" scoped>
.check-wrap {
  position: relative;
  cursor: pointer;
  input {
    width: 0;
    height: 0;
    opacity: 0;
    position: absolute
  }

  .check-span {
    color: #606266;
    &::before{
      margin-right: 6px;
    }

    &.icon-checkbox-on {
      color: #409eff;
    }
  }
}
</style>

使用UiCheckboxGroup

<template>
  <div class="form-wrap">
    <div class="form-item">
      <UiCheckbox label="是否同意隐私政策" v-model="isArgee" />  {{ isArgee }}
    </div>
    <div class="form-item">
      爱好:
      <UiCheckboxGroup v-model="hobby">
        <UiCheckbox label="打乒乓球" />
        <UiCheckbox label="打篮球" />
        <UiCheckbox label="跑步" />
      </UiCheckboxGroup>
      
    </div>
    <p>{{ hobby }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UiCheckbox from '@/components/UiCheckbox/index.vue'
import UiCheckboxGroup from '@/components/UiCheckboxGroup/index.vue'

const hobby = ref([])

const isArgee = ref(false)
</script>

<style lang="scss" scoped>
.form-wrap {
  width: 500px;

  .form-item {
    display: flex;
    align-items: center;
    margin-top: 10px;
  }
}
</style>

在这个例子中:

  • 我们UiCheckGroup 使用slot占位,使用时将单个UiCheckbox 传过来
  • 使用provide 定义了一个checkGroup 对象,对象里面主要包含了,选中的数据和改变选中数据的方法,提提供给单个的UiCheckbox 组件使用
  • 修改UiCheckbox使得适应UiCheckboxGroup, 使用inject 接收UiCheckboxGroup 传过来的 checkGroup
  • UiCheckbox true-value 属性用来定义选中的值,因为一般一组选中我们都需要一个具体值,而不是只需要falsetrue, 这里做了个判断,只有checkGrou存在时才使用 label的值,不存在时还是使用true 组件作为选中的值。
  • 监听选中状态的变化,判断如果checkGroup 存在时,调用checkGroup.toggleOption 方法更改选中的数组
  • 使用时我们直接将在UiCheckbox 上用v-model 绑定选中的数据,在UiCheckboxGroup之间添加单个的UiCheckbox

查看效果 未选中状态:

image.png

选中状态:

image.png

可以看到我们的单个选择,和一组选择都能正常使用。

UiRadio 封装

<template>
  <label class="radio-wrap">
    <input type="radio" v-model="model" :value="label" />
    <span class="radio-span iconfont"
      :class="{ 
        'icon-radio-on': model,
        'icon-radio': !model
      }"
    ><slot></slot></span>
  </label>
</template>

<script setup>
const props = defineProps({
  label: {
    type: String,
    default: ''
  }
})

const model = defineModel()
</script>

<style lang="scss" scoped>
.radio-wrap {
  position: relative;
  cursor: pointer;
  input {
    width: 0;
    height: 0;
    opacity: 0;
    position: absolute
  }

  .radio-span {
    color: #606266;
    &::before{
      margin-right: 6px;
    }

    &.icon-radio-on {
      color: #409eff;
    }
  }
}
</style>

UiRadio 的使用

<template>
  <div class="form-wrap">
    <div class="form-item">
      <UiRadio label="1" v-model="isArgee">是否同意注册</UiRadio> {{isArgee}}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UiRadio from '@/components/UiRadio/index.vue'

const isArgee = ref('')
</script>

<style lang="scss" scoped>
.form-wrap {
  width: 500px;

  .form-item {
    display: flex;
    align-items: center;
    margin-top: 10px;
  }
}
</style>

在这个例子中:

  • 我们使用slot 占位符定义单选框文本值显示位置
  • :value 定义选中的值显示
  • 其他流程和UiCheckbox 封装流程差不多,不再做多的解释

UiRadioGroup 封装

<template>
  <div class="radio-group">
    <slot></slot>
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'

const emit = defineEmits(['update:modelValue'])
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const selectdValue = ref(props.modelValue)
provide('radioGroup', {
  selectdValue,
  toggleOption (value) {
    selectdValue.value = value
    emit('update:modelValue', selectdValue.value)
  }
})
</script>

<style lang="scss" scoped>
.radio-group {
  :deep(.radio-wrap) {
    margin-right: 20px;
  }
}
</style>

修改UiRadio 适应UiRadioGroup

<template>
  <label class="radio-wrap">
    <input type="radio" v-model="model" :value="label" />
    <span class="radio-span iconfont"
      :class="{ 
        'icon-radio-on': isCheck,
        'icon-radio': !isCheck
      }"
    ><slot></slot></span>
  </label>
</template>

<script setup>
import { watch, inject, computed } from 'vue'
const props = defineProps({
  label: {
    type: String,
    default: ''
  }
})

const model = defineModel()

const radioGroup = inject('radioGroup')
if (model.value === undefined) {
  model.value = radioGroup.selectdValue
}

const isCheck = computed(() => radioGroup ? radioGroup.selectdValue.value === props.label : model.value)
watch(model, () => {
  if (radioGroup) {
    radioGroup.toggleOption(props.label)
  }
})
</script>

<style lang="scss" scoped>
.radio-wrap {
  position: relative;
  cursor: pointer;
  input {
    width: 0;
    height: 0;
    opacity: 0;
    position: absolute
  }

  .radio-span {
    color: #606266;
    &::before{
      margin-right: 6px;
    }

    &.icon-radio-on {
      color: #409eff;
    }
  }
}
</style>

使用UiRadioGroup

<template>
  <div class="form-wrap">
    <div class="form-item">
      <UiRadio label="1" v-model="isArgee">是否同意注册</UiRadio> {{ isArgee }}
    </div>
    <div class="form-item">
      性别:
      <UiRadioGroup v-model="gender">
        <UiRadio label="1"></UiRadio>
        <UiRadio label="2"></UiRadio>
        <UiRadio label="0">未知</UiRadio>
      </UiRadioGroup>
      {{ gender }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UiRadio from '@/components/UiRadio/index.vue'
import UiRadioGroup from '@/components/UiRadioGroup/index.vue'

const isArgee = ref('')

const gender = ref('0')
</script>

<style lang="scss" scoped>
.form-wrap {
  width: 500px;

  .form-item {
    display: flex;
    align-items: center;
    margin-top: 10px;
  }
}
</style>

UiRadioGroup 封装流程和UiCheckboxGroup 封装流程差不多,不做过多解释。不同的地方主要在于,单选框只能选择一个,所以选中的值用不再使用数组,而是用一个String型的值。

查看运行效果:

image.png

总结

通过以上实现,我们完成了 UiInputUiCheckboxUiCheckboxGroupUiRadioUiRadioGroup 的封装。这些组件的设计思路与 Element Plus 类似,遵循了 Vue 的组件化开发原则,充分利用了 v-modelprovide/inject 等特性,使得组件的使用更加直观和便捷。

希望这篇文章能帮助大家更好地理解表单控件的封装逻辑,并在实际开发中灵活运用这些技巧!