前端组件二次封装实战:Vue+React基于Element UI/AntD的高效封装策略

在中后台项目开发中,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中通过$attrsinheritAttrs: false实现透传,React中通过剩余参数{...restProps}区分业务与原生Props,两者核心思路一致:让封装后的组件既满足业务需求,又保持原生组件的灵活性。

好的二次封装组件,应该是"对开发者友好"的------调用方无需关心内部实现,只需通过简单的Props配置即可完成业务需求,同时能灵活扩展原生能力,真正做到封装不封死,以上。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

相关推荐
勇气要爆发1 小时前
问:当服务器资源有限,前端项目高并发优化策略
前端·性能优化
桧***攮1 小时前
前端在移动端中的性能优化
前端·性能优化
小小码农一只1 小时前
Spring WebFlux与响应式编程:构建高效的异步Web应用
java·前端·spring·spring webflux
北极糊的狐1 小时前
使用 vue-awesome-swiper 实现轮播图(Vue3实现教程)
前端·javascript·vue.js
W.Y.B.G1 小时前
vue3项目中集成高德地图使用示例
前端·javascript·网络
王兆龙1681 小时前
简易版增删改查
前端·vscode·vue
Jonathan Star1 小时前
`npx prettier --write . --end-of-line lf` 是一条用于**格式化代码**的命令
前端·css3
pan3035074791 小时前
Tailwind CSS 实战
前端·tailwind
_lst_1 小时前
系统环境变量
前端·chrome