二次组件封装
2025/10/8大约 4 分钟
二次组件封装
什么是二次封装
二次封装(Wrapper Component)是指基于现有 UI 组件库(如 Element Plus、Ant Design、Naive UI 等)进行功能扩展和定制化开发的组件。通过封装,我们可以:
- 统一项目中的组件使用规范
- 简化重复的配置代码
- 添加业务相关的默认行为
- 提供更好的类型提示和开发体验
封装要点
二次组件封装需要关注以下五个核心要素:
| 要素 | 说明 |
|---|---|
| 属性(Props) | 透传或扩展原组件的属性 |
| 事件(Events) | 透传或自定义事件 |
| 插槽(Slots) | 透传或自定义插槽内容 |
| 方法(Expose) | 暴露原组件的方法供父组件调用 |
| 类型提示(Types) | 完整的 TypeScript 类型支持 |
基础封装示例
基于 Element Plus 的 Input 组件进行二次封装:
<script lang="ts">
import { h } from "vue";
import { ElInput, InputInstance } from "element-plus";
interface Props {
modelValue?: string;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: "",
placeholder: "请输入",
disabled: false,
clearable: true,
});
const emit = defineEmits<{
"update:modelValue": [value: string];
change: [value: string];
focus: [event: FocusEvent];
blur: [event: FocusEvent];
}>();
const inputRef = ref<InputInstance | null>(null);
defineExpose<{
focus: () => void;
blur: () => void;
select: () => void;
}>({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
select: () => inputRef.value?.select(),
});
const handleInput = (value: string) => {
emit("update:modelValue", value);
};
const handleChange = (value: string) => {
emit("change", value);
};
</script>
<template>
<div class="custom-input-wrapper">
<ElInput
ref="inputRef"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
@input="handleInput"
@change="handleChange"
@focus="(e) => emit('focus', e)"
@blur="(e) => emit('blur', e)"
/>
</div>
</template>透传 $attrs 的方式
方式一:使用 h 函数 + $attrs
<script lang="ts">
import { h } from "vue";
import { ElInput, InputInstance } from "element-plus";
const inputRef = ref<InputInstance | null>(null);
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
});
</script>
<template>
<div>
<component :is="h(ElInput, { ...$attrs, ref: inputRef }, $slots)" />
</div>
</template>方式二:使用 v-bind="$attrs"
<script lang="ts">
import { ElInput } from "element-plus";
</script>
<template>
<ElInput v-bind="$attrs">
<template v-for="(_, name) in $slots" #[name]>
<slot :name="name" />
</template>
</ElInput>
</template>方式三:使用 inheritAttrs: false
<script lang="ts">
import { ElInput } from "element-plus";
defineOptions({
inheritAttrs: false,
});
</script>
<template>
<ElInput v-bind="$attrs">
<template v-for="(_, name) in $slots" #[name]>
<slot :name="name" />
</template>
</ElInput>
</template>完整封装示例
封装一个支持自定义前缀图标和输入验证的输入框组件:
<script lang="ts">
import { ElInput, FormItemInstance } from "element-plus";
import type { InputInstance } from "element-plus";
interface Props {
modelValue?: string;
label?: string;
placeholder?: string;
prefixIcon?: string;
rules?: Record<string, any>[];
disabled?: boolean;
maxlength?: number | string;
showWordLimit?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: "",
placeholder: "请输入",
disabled: false,
showWordLimit: false,
});
const emit = defineEmits<{
"update:modelValue": [value: string];
change: [value: string];
focus: [event: FocusEvent];
blur: [event: FocusEvent];
}>();
const inputRef = ref<InputInstance | null>(null);
const formItemRef = ref<FormItemInstance | null>(null);
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
select: () => inputRef.value?.select(),
clear: () => inputRef.value?.clear(),
validate: () => formItemRef.value?.validate(),
resetField: () => formItemRef.value?.resetField(),
});
const handleInput = (value: string) => {
emit("update:modelValue", value);
};
</script>
<template>
<el-form-item
v-if="label"
ref="formItemRef"
:label="label"
:prop="label"
:rules="rules"
>
<div class="input-wrapper">
<span v-if="prefixIcon" class="prefix-icon">
<el-icon><component :is="prefixIcon" /></el-icon>
</span>
<ElInput
ref="inputRef"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="maxlength"
:show-word-limit="showWordLimit"
:class="{ 'has-prefix': prefixIcon }"
@input="handleInput"
@change="(val) => emit('change', val)"
@focus="(e) => emit('focus', e)"
@blur="(e) => emit('blur', e)"
>
<template v-for="(_, name) in $slots" #[name]>
<slot :name="name" />
</template>
</ElInput>
</div>
</el-form-item>
<ElInput
v-else
ref="inputRef"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="maxlength"
:show-word-limit="showWordLimit"
@input="handleInput"
@change="(val) => emit('change', val)"
@focus="(e) => emit('focus', e)"
@blur="(e) => emit('blur', e)"
>
<template v-for="(_, name) in $slots" #[name]>
<slot :name="name" />
</template>
</ElInput>
</template>
<style scoped>
.input-wrapper {
display: flex;
align-items: center;
}
.prefix-icon {
display: flex;
align-items: center;
margin-right: 8px;
}
.has-prefix :deep(.el-input__wrapper) {
padding-left: 8px;
}
</style>类型提示最佳实践
继承原组件类型
import type { InputInstance, InputProps } from "element-plus";
type CustomInputProps = InputProps;
type CustomInputInstance = InputInstance;组合多个类型
interface CustomProps {
customProp?: string;
}
type CombinedProps = Partial<CustomProps> & InputProps;
type CombinedInstance = InputInstance & {
customMethod: () => void;
};使用泛型封装
interface WrapperConfig<T> {
component: T;
defaultProps?: Partial<T>;
}
function createWrapper<T>(config: WrapperConfig<T>) {
return defineComponent({
setup(_, { attrs, slots }) {
return () =>
h(
config.component,
{
...config.defaultProps,
...attrs,
},
slots,
);
},
});
}
const CustomInput = createWrapper({
component: ElInput,
defaultProps: {
placeholder: "请输入",
},
});最佳实践
1. 保持 API 一致性
尽量保持与原组件相同的 API,使用 withDefaults 设置合理的默认值:
const props = withDefaults(defineProps<Props>(), {
placeholder: "请输入",
clearable: true,
size: "default",
});2. 合理透传 $attrs
defineOptions({
inheritAttrs: false,
});3. 正确暴露方法
const inputRef = ref<InputInstance | null>(null);
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
clear: () => inputRef.value?.clear(),
select: () => inputRef.value?.select(),
});4. 事件透传
const emit = defineEmits<{
'update:modelValue': [value: string];
change: [value: string];
focus: [event: FocusEvent];
blur: [event: FocusEvent];
}>();
<ElInput
@input="(val) => emit('update:modelValue', val)"
@change="(val) => emit('change', val)"
@focus="(e) => emit('focus', e)"
@blur="(e) => emit('blur', e)"
/>5. 插槽透传
<template v-for="(_, name) in $slots" #[name]>
<slot :name="name" />
</template>总结
二次组件封装的核心要点:
- 属性透传:使用
$attrs或显式透传,保持组件扩展性 - 事件透传:使用
defineEmits透传事件,必要时添加业务事件 - 插槽透传:使用
v-for+$slots透传所有插槽 - 方法暴露:使用
defineExpose暴露原组件方法 - 类型完整:使用 TypeScript 提供完整的类型提示
通过合理的二次封装,可以显著提升开发效率和代码可维护性。