在中后台项目开发中,Element UI(Vue)和Ant Design(AntD,React)是主流的组件库,但原生组件往往无法直接适配业务场景,比如:统一的表单校验规则、标准化的表格交互、个性化的弹窗样式等。此时,基于组件库的二次封装成为平衡开发效率、代码复用与团队规范的核心手段。我将围绕何时封装 、为何封装 、如何封装,三个核心问题,聚焦Element UI/AntD的二次封装技巧,结合Vue 3和React 18的实战案例,拆解高效且易扩展的封装方法论。
1. 什么时候值得封装一个组件
组件封装不是"为了封装而封装",当满足以下场景时,二次封装的收益远大于成本:
1.1. 重复场景出现时:减少复制粘贴
当同一类UI/交互在2个及以上模块出现(如Element UI的Table+分页、AntD的Form+搜索按钮),且仅参数不同,封装可避免重复代码。
- 示例:多个列表页都用Element UI的Table,且都需要"分页+多选+操作列",封装
BaseTable组件统一逻辑。
1.2. 业务规则需统一时:规避风格混乱
当组件需要遵循统一的业务规则(如按钮权限控制、日期格式渲染、表单校验提示),封装可收口规则。
- 示例:AntD的Button需根据用户角色控制显示/禁用,封装
AuthButton统一处理权限逻辑,所有页面复用。
1.3. 原生组件能力不足时:补齐个性化需求
Element UI/AntD的通用能力无法覆盖业务场景(如Element UI的Dialog需拖拽、AntD的Select需最多显示3个多选标签),二次封装可定制化扩展。
1.4. 逻辑与UI耦合复杂时:降低维护成本
当一个功能包含"数据请求+交互逻辑+样式定制"(如带远程搜索的部门选择器),封装可拆分复杂逻辑,符合单一职责原则。
2. 封装组件的核心目的
降本提效:一次封装,多处复用。后续需求变更(如表格分页样式调整),只需修改封装组件,所有引用处自动生效,无需逐个页面修改。
逻辑内聚:高内聚、低耦合。将业务逻辑(如数据请求、校验规则)封装在组件内部,页面只需关注"传参"和"接收结果",降低代码耦合度。
扩展灵活:适配未来业务变化。预留扩展接口,新增需求(如表格新增导出功能)时,仅需扩展组件内部,不影响外部调用方式。
统一标准:对齐团队开发规范。避免不同开发者对Element UI/AntD的定制方式不一致(如按钮尺寸、表单间距),保证项目风格统一。
3. Element UI/AntD二次封装核心技巧:透传原生Props
二次封装的关键是"不丢失原生组件的能力"------即让封装后的组件能隐式传递原生组件的所有Props、事件和样式,同时新增业务逻辑。以下分Vue(Element Plus)和React(AntD)讲解核心实现方式。
核心概念:透传的本质
- Vue:通过
v-bind="$attrs"透传Props,v-on="$listeners"(Vue 3已合并到$attrs)透传事件,inheritAttrs: false避免属性透传到根元素。 - React:通过扩展运算符
{...props}透传所有Props,通过children透传子元素,区分"业务Props"和"原生Props"。
3.1. Vue 3 + Element Plus 二次封装实战
以封装BaseDialog(基于ElDialog)为例,实现"拖拽+默认样式+透传原生Props":
步骤1:基础封装(透传原生Props)
vue
<template>
<!-- 根元素禁用属性继承,避免$attrs透传到div -->
<div class="base-dialog">
<el-dialog
v-bind="$attrs" <!-- 透传ElDialog的所有原生Props(如title、visible、width) -->
:close-on-click-modal="false" <!-- 业务默认值,可被外部Props覆盖 -->
@close="handleClose" <!-- 内部处理基础事件,也可透传外部事件 -->
class="base-dialog__inner"
>
<!-- 插槽:透传ElDialog的默认插槽 -->
<slot />
<!-- 插槽:自定义底部按钮 -->
<template #footer>
<slot name="footer">
<!-- 默认底部按钮 -->
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm">确认</el-button>
</slot>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElDialog, ElButton, ElMessage } from 'element-plus';
// 引入拖拽指令(可选,扩展功能)
import { vDialogDrag } from '@/directives/dialogDrag';
// 禁用根元素的属性继承,确保$attrs只透传给ElDialog
defineOptions({
inheritAttrs: false
});
// 定义业务Props(与原生Props区分)
const props = defineProps<{
// 业务自定义Props,非ElDialog原生属性
confirmText?: string;
cancelText?: string;
}>();
// 定义事件:透传原生事件 + 自定义业务事件
const emit = defineEmits<{
(e: 'confirm'): void; // 自定义确认事件
(e: 'cancel'): void; // 自定义取消事件
(e: 'close'): void; // 透传ElDialog的close事件
}>();
// 内部处理确认逻辑
const handleConfirm = () => {
emit('confirm');
// 可扩展:统一的确认提示
ElMessage.success('操作成功');
};
// 内部处理取消逻辑
const handleCancel = () => {
emit('cancel');
// 触发ElDialog的关闭(通过透传的visible属性由外部控制)
emit('close');
};
// 透传ElDialog的close事件
const handleClose = () => {
emit('close');
};
</script>
<style scoped>
.base-dialog {
--el-dialog-width: 600px; /* 自定义默认宽度,可被外部覆盖 */
}
.base-dialog__inner :deep(.el-dialog__header) {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
</style>
步骤2:指令扩展(拖拽功能)
ts
// src/directives/dialogDrag.ts
import type { Directive } from 'vue';
export const vDialogDrag: Directive = {
mounted(el) {
const dialogHeaderEl = el.querySelector('.el-dialog__header');
const dragDom = el.querySelector('.el-dialog') as HTMLElement;
if (!dialogHeaderEl || !dragDom) return;
// 设置拖拽元素可拖动
dialogHeaderEl.style.cursor = 'move';
dialogHeaderEl.addEventListener('mousedown', (e) => {
// 鼠标按下,计算当前元素距离可视区的距离
const disX = e.clientX - dialogHeaderEl.offsetLeft;
const disY = e.clientY - dialogHeaderEl.offsetTop;
const dragDomWidth = dragDom.offsetWidth;
const dragDomHeight = dragDom.offsetHeight;
const screenWidth = document.body.clientWidth;
const screenHeight = document.body.clientHeight;
// 最大移动距离
const maxX = screenWidth - dragDomWidth;
const maxY = screenHeight - dragDomHeight;
// 鼠标移动事件
const moveFn = (e: MouseEvent) => {
let left = e.clientX - disX;
let top = e.clientY - disY;
// 边界处理
if (left < 0) left = 0;
if (left > maxX) left = maxX;
if (top < 0) top = 0;
if (top > maxY) top = maxY;
dragDom.style.left = `${left}px`;
dragDom.style.top = `${top}px`;
};
// 鼠标松开事件
const upFn = () => {
document.removeEventListener('mousemove', moveFn);
document.removeEventListener('mouseup', upFn);
};
document.addEventListener('mousemove', moveFn);
document.addEventListener('mouseup', upFn);
});
},
};
步骤3:父组件调用(透传原生Props + 扩展)
vue
<template>
<el-button @click="dialogVisible = true">打开弹窗</el-button>
<!-- 调用封装后的BaseDialog,可透传ElDialog所有原生Props -->
<BaseDialog
v-model="dialogVisible" <!-- 透传ElDialog的visible属性(v-model语法糖) -->
title="自定义弹窗"
width="800px" <!-- 覆盖默认宽度 -->
confirm-text="提交" <!-- 自定义业务Props -->
@confirm="handleConfirm"
@close="handleClose"
>
<div>弹窗内容</div>
<!-- 自定义底部按钮(覆盖默认插槽) -->
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import BaseDialog from './components/BaseDialog.vue';
const dialogVisible = ref(false);
const handleConfirm = () => {
console.log('确认');
dialogVisible.value = false;
};
const handleClose = () => {
console.log('关闭');
};
const handleSubmit = () => {
console.log('自定义提交');
dialogVisible.value = false;
};
</script>
3.2. React 18 + AntD 二次封装实战
以封装BaseTable(基于AntD的Table)为例,实现"分页封装+透传原生Props+统一操作列":
步骤1:基础封装(区分业务Props与原生Props)
tsx
import React, { useState, useEffect } from 'react';
import { Table, Pagination, Space, Button, Typography } from 'antd';
import type { TableProps, PaginationProps } from 'antd';
// 定义业务Props:与AntD Table原生Props区分
interface BaseTableProps<T = any> extends Omit<TableProps<T>, 'pagination'> {
// 业务自定义分页Props
paginationConfig?: PaginationProps;
// 统一操作列配置
actionColumn?: {
width?: number;
fixed?: 'left' | 'right';
// 操作项配置
actions: {
text: string;
onClick: (record: T) => void;
type?: 'primary' | 'default' | 'danger';
}[];
};
}
const BaseTable = <T,>({
columns,
dataSource,
paginationConfig,
actionColumn,
...restProps // 剩余Props:透传AntD Table的原生Props
}: BaseTableProps<T>) => {
// 合并列配置:新增操作列
const mergedColumns = React.useMemo(() => {
const cols = [...(columns || [])];
if (actionColumn) {
cols.push({
title: '操作',
key: 'action',
width: actionColumn.width || 200,
fixed: actionColumn.fixed || 'right',
render: (_, record) => (
<Space size="small">
{actionColumn.actions.map((action, index) => (
<Button
key={index}
type={action.type || 'default'}
onClick={() => action.onClick(record)}
>
{action.text}
</Button>
))}
</Space>
),
});
}
return cols;
}, [columns, actionColumn]);
// 分页状态管理
const [pagination, setPagination] = useState<PaginationProps>({
current: 1,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
...paginationConfig,
});
// 监听数据总数,更新分页
useEffect(() => {
if (paginationConfig?.total !== undefined) {
setPagination(prev => ({ ...prev, total: paginationConfig.total }));
}
}, [paginationConfig?.total]);
// 分页变更回调
const handleTableChange = (
pagination: PaginationProps,
filters: any,
sorter: any
) => {
setPagination(pagination);
// 透传原生onChange事件
restProps.onChange?.(pagination, filters, sorter);
};
return (
<div style={{ background: '#fff', padding: 16, borderRadius: 4 }}>
{/* 透传AntD Table的所有原生Props */}
<Table<T>
columns={mergedColumns}
dataSource={dataSource}
pagination={false} // 禁用原生分页,自定义
onChange={handleTableChange}
bordered // 业务默认值,可被restProps覆盖
{...restProps} // 透传剩余原生Props(如rowKey、loading、scroll)
/>
{/* 自定义分页组件 */}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
{...pagination}
{...paginationConfig}
onChange={(page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize }));
}}
/>
</div>
</div>
);
};
export default BaseTable;
步骤2:父组件调用(透传原生Props + 扩展)
tsx
import React from 'react';
import BaseTable from './components/BaseTable';
import { Button, message } from 'antd';
// 模拟数据
const dataSource = [
{ id: 1, name: '张三', age: 20, status: '启用' },
{ id: 2, name: '李四', age: 22, status: '禁用' },
];
const Page = () => {
// 列配置
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '年龄', dataIndex: 'age', key: 'age' },
{ title: '状态', dataIndex: 'status', key: 'status' },
];
// 操作列配置
const actionColumn = {
width: 200,
fixed: 'right',
actions: [
{
text: '编辑',
type: 'primary',
onClick: (record) => {
message.success(`编辑${record.name}`);
},
},
{
text: '删除',
type: 'danger',
onClick: (record) => {
message.warning(`删除${record.name}`);
},
},
],
};
return (
<div style={{ padding: 20 }}>
<BaseTable
rowKey="id" // 透传AntD Table原生Props
columns={columns}
dataSource={dataSource}
scroll={{ x: 1000 }} // 透传原生Props:横向滚动
loading={false} // 透传原生Props:加载状态
paginationConfig={{
total: 2,
pageSize: 10,
}}
actionColumn={actionColumn}
// 透传原生事件
onRow={(record) => ({
onClick: () => console.log('点击行', record),
})}
/>
</div>
);
};
export default Page;
4. 高效且易扩展的封装原则
下面是一些封装时候的原则,Vue/React通用:
4.1. Props设计
分层透传,不丢失原生能力
- Vue:用
$attrs透传所有原生Props,defineProps仅声明业务自定义Props,inheritAttrs: false避免属性污染; - React:用
Omit剔除业务Props,剩余Props通过{...restProps}透传,区分"业务逻辑Props"和"原生组件Props"。
4.2. 扩展点设计
插槽/Children优先
- Vue:预留具名插槽(如Dialog的footer、Table的action),支持局部替换;
- React:通过
children和自定义插槽对象(如slots)实现扩展,避免硬编码。
4.3. 状态管理
内部隔离,外部可控
- 组件内部维护基础状态(如分页的current/pageSize),外部通过Props覆盖默认值;
- 事件透传:内部处理基础逻辑后,通过emit/回调将结果暴露给外部。
4.4. 样式封装
有默认样式+可覆盖
- Vue:用
scoped+:deep()穿透样式,预留CSS变量(如--el-dialog-width)支持外部定制; - React:用CSS Modules隔离样式,支持传递
className覆盖默认样式。
4.5. 边界处理
需要有兜底与兼容
- 对空数据、空列配置做兜底(如Table无数据时显示"暂无数据");
- 兼容原生组件的所有事件(如Dialog的close、Table的onChange)。
5. 封装的与团队规范
下面是一些封装的"度",与团队规范:
5.1. 避免过度封装
- 不封装"一次性"组件:仅单个页面使用、无复用价值的逻辑无需封装;
- 不滥用透传:核心业务Props显式声明,避免所有属性都透传导致维护困难。
5.2. 组件分层:基础组件 vs 业务组件
| 类型 | 示例 | 特点 |
|---|---|---|
| 基础组件 | BaseDialog、BaseTable | 基于Element UI/AntD封装,全项目复用 |
| 业务组件 | OrderTable、UserForm | 绑定具体业务逻辑,仅业务模块复用 |
5.3. 文档化:标注透传能力
封装组件需注明"支持透传XX原生组件的所有Props/事件",示例:
tsx
/**
* BaseTable 基于AntD Table的二次封装
* @param {BaseTableProps} props - 组件属性
* @param {PaginationProps} props.paginationConfig - 分页配置(业务自定义)
* @param {Object} props.actionColumn - 操作列配置(业务自定义)
* @param {TableProps} ...restProps - 透传AntD Table的所有原生Props(除pagination)
*/
6. 总结
基于Element UI/AntD的二次封装,核心是"保留原生能力+新增业务逻辑"------通过透传Props确保不丢失组件库的原生功能,通过自定义Props和插槽实现业务定制,最终达到"复用、统一、易扩展"的目标。
Vue中通过$attrs和inheritAttrs: false实现透传,React中通过剩余参数{...restProps}区分业务与原生Props,两者核心思路一致:让封装后的组件既满足业务需求,又保持原生组件的灵活性。
好的二次封装组件,应该是"对开发者友好"的------调用方无需关心内部实现,只需通过简单的Props配置即可完成业务需求,同时能灵活扩展原生能力,真正做到封装不封死,以上。
本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~
PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~
往期文章
- HTML的Video从基础使用到高级实战+兼容的完全指南
- 纯前端提取图片颜色插件Color-Thief教学+实战完整指南
- react-konva实战指南:Canvas高性能+易维护的组件化图形开发实现教程
- React无限滚动插件react-infinite-scroll-component的配置+优化+避坑指南
- 前端音频兼容解决:音频神器howler.js从基础到进阶完整使用指南
- 使用React-OAuth进行Google/GitHub登录的教程和案例
- 纯前端人脸识别利器:face-api.js手把手深入解析教学
- 关于React父组件调用子组件方法forwardRef的详解和案例
- React跨组件数据共享useContext详解和案例
- Web图像编辑神器tui.image-editor从基础到进阶的实战指南
- 开发个人微信小程序类目选择/盈利方式/成本控制与服务器接入指南
- 前端图片裁剪Cropper.js核心功能与实战技巧详解
- 编辑器也有邪修?盘点VS Code邪门/有趣的扩展
- js使用IntersectionObserver实现目标元素可见度的交互
- Web前端页面开发阿拉伯语种适配指南
- 让网页拥有App体验?PWA 将网页变为桌面应用的保姆级教程PWA
- 使用nvm管理node.js版本以及更换npm淘宝镜像源
- 手把手教你搭建规范的团队vue项目,包含commitlint,eslint,prettier,husky,commitizen等等