1. 为什么我们需要二次封装ElementPlus组件?
大家好,我是有十年大前端开发经验的老码农。在带团队做中后台项目时,我们几乎离不开ElementPlus这样的优秀UI库。但不知道你有没有遇到过这样的场景:产品经理说,“这个输入框在所有表单里都要有相同的校验规则和样式”,或者“所有表格的操作列按钮都要统一成这个样式”。这时候,如果每个页面都去写一遍,代码就会变得又臭又长,后期维护简直是噩梦。
我刚开始带项目时也踩过这个坑。当时一个管理系统里有三十多个表单,每个表单的输入框都要支持清除按钮、字数统计和自定义前缀图标。我傻乎乎地在每个页面复制粘贴,结果后来要调整样式,我改了整整两天。从那以后我就下定决心,必须把通用组件封装好。
二次封装的核心目的很简单:在保留原组件所有能力的基础上,增加业务定制功能,同时保持使用体验的一致性。就像给汽车加装导航和倒车影像,方向盘、刹车、油门这些基本操作都不变,但开起来更顺手了。
举个例子,我们封装的CInput组件,在外层用起来几乎和el-input一模一样:
<!-- 父组件使用 -->
<template>
<c-input
v-model="username"
placeholder="请输入用户名"
clearable
show-word-limit
maxlength="20"
@blur="validateUsername"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</c-input>
</template>
你看,clearable、show-word-limit这些ElementPlus原有的属性直接就能用,@blur事件监听也正常生效,前缀插槽#prefix的写法完全没变。但我们在封装时,可以偷偷给所有输入框加上统一的边框样式、默认的校验逻辑,甚至自动防抖处理。
这种封装不是“重新造轮子”,而是“给轮子加个更舒适的轮胎”。接下来,我就带你一步步实现这种无缝透传的封装技巧。
2. 数据绑定的两种现代化方案
封装组件第一关就是解决数据绑定。Vue3的数据流是单向的,父组件传数据给子组件用props,子组件想修改父组件的数据得靠emit事件。这听起来有点绕,我画个简单的关系图帮你理解:
父组件数据 → (通过props传递) → 子组件接收
子组件修改 → (通过emit事件) → 父组件监听并更新
2.1 传统方案:computed的getter/setter
这是Vue3早期最常用的方法,虽然有点啰嗦,但特别适合理解数据流的工作原理。我拿我们封装的输入框组件举例:
<!-- 子组件 CInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'
// 定义props接收父组件传来的value
const props = defineProps<{
value: string
}>()
// 定义emit事件,用于通知父组件更新
const emit = defineEmits<{
'update:value': [value: string]
}>()
// 关键在这里:用computed创建一个中间变量
const inputValue = computed({
// getter:当需要读取值时,返回props.value
get: () => props.value,
// setter:当值被修改时,触发emit事件
set: (newValue: string) => {
emit('update:value', newValue)
}
})
</script>
<template>
<!-- 把计算属性绑定到el-input的v-model上 -->
<el-input v-model="inputValue" />
</template>
在父组件里,你可以用v-model:value来绑定:
<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
import CInput from './CInput.vue'
const username = ref('张三')
</script>
<template>
<c-input v-model:value="username" />
</template>
这个方案的好处是思路清晰,你一眼就能看出数据是怎么流动的。我在教新人时总是从这个方法开始,因为它把Vue的响应式原理展现得很明白。但缺点也很明显——代码量多了点,每个双向绑定的属性都要写一遍getter和setter。
2.2 新方案:Vue 3.4+的defineModel宏
如果你用的是Vue 3.4或更高版本(我强烈建议升级,性能提升很明显),那就有更优雅的解决方案了。Vue团队听到了开发者的呼声,推出了defineModel()这个宏。我第一次用的时候直呼“真香”,代码量直接减半。
<!-- 子组件 CInput.vue -->
<script setup lang="ts">
// 一行代码搞定双向绑定!
const model = defineModel<string>()
</script>
<template>
<el-input v-model="model" />
</template>
父组件用法变得更简洁:
<!-- 父组件 -->
<template>
<!-- 注意这里变成了标准的v-model -->
<c-input v-model="username" />
</template>
defineModel背后做了很多工作:自动声明对应的prop,自动定义update:modelValue事件,还支持类型定义和默认值。比如你可以这样写:
const model = defineModel<string>({ default: '默认值' })
在实际项目中,我建议这样选择:如果是新项目,直接用defineModel,省时省力;如果是老项目升级,可以逐步替换,两者混用也没问题。我上个月重构一个大型项目,把一百多个组件的computed方案换成了defineModel,代码行数减少了约30%,而且可读性大大提升。
3. 属性与事件监听的全自动透传
解决了数据绑定,接下来要处理属性和事件。ElementPlus的组件有很多配置属性,比如el-input就有clearable、disabled、placeholder、maxlength等等。我们不可能把所有属性都定义成props——那太累了,而且ElementPlus升级新增属性时,我们的封装组件还得跟着改。
3.1 认识Vue3的“透传Attributes”
Vue3有个很棒的特性叫“透传Attributes”,指的是那些传递给组件但没有被声明为props或emits的属性和事件监听器。举个例子:
<!-- 父组件 -->
<c-input
class="custom-input"
style="width: 100%"
placeholder="请输入"
clearable
@input="handleInput"
@blur="handleBlur"
/>
这里的class、style、placeholder、clearable属性,以及@input、@blur事件监听器,如果子组件没有显式声明,就会自动“透传”到子组件的根元素上。
3.2 实现属性透传:v-bind="$attrs"
在我们的封装组件里,只需要用v-bind="$attrs"就能一键接收所有透传属性:
<!-- 子组件 CInput.vue -->
<template>
<el-input
v-model="inputValue"
v-bind="$attrs" <!-- 关键代码 -->
/>
</template>
这样,父组件传过来的所有属性都会原封不动地应用到el-input上。你可以打开浏览器开发者工具,看看渲染出来的DOM结构,会发现clearable、placeholder这些属性确实挂在了el-input元素上。
3.3 事件监听器的透传
事件监听器也是$attrs的一部分,所以上面的写法已经包含了事件透传。但这里有个重要细节:事件监听器会合并,不会覆盖。
什么意思呢?看这个例子:
<!-- 子组件 -->
<template>
<el-input
v-model="inputValue"
v-bind="$attrs"
@input="handleLocalInput" <!-- 子组件自己也监听input事件 -->
/>
</template>
<script setup>
const handleLocalInput = (value) => {
console.log('子组件内部处理:', value)
}
</script>
如果父组件也传了@input监听器:
<!-- 父组件 -->
<c-input @input="handleParentInput" />
那么两个监听器都会触发!先触发子组件内部的handleLocalInput,再触发父组件的handleParentInput。这个特性很有用,比如我们可以在封装组件里先做数据清洗,然后再交给父组件处理。
3.4 固定某些业务属性
有时候我们想给所有实例固定一些属性。比如公司设计规范要求所有输入框都有圆角,我们可以这样写:
<template&


3942

被折叠的 条评论
为什么被折叠?



