Vue3 组件库二次封装实战 | 基于 Element Plus 封装企业级 UI 组件库

几乎所有 Vue3 中后台项目都会用 Element Plus,但直接使用第三方组件会导致业务耦合、风格难以统一、定制成本高。本文手把手教你从 0 搭建一套企业级二次封装组件库,统一项目风格、提升开发效率。


一、为什么要二次封装?

1.1 直接使用 Element Plus 的痛点

痛点 场景 后果
重复配置 每个 ElTable 都要写 stripe border size="small" 代码冗余,改风格要全局替换
业务耦合 ElMessage.success('保存成功') 散落在各处 统一修改提示文案成本高
风格不统一 A 页面用默认主题,B 页面自定义颜色 产品体验割裂
功能缺失 ElDialog 没有拖拽、全屏、自适应高度 每个项目重复造轮子
文档缺失 团队不知道哪些组件可用、怎么用 重复封装,维护成本高

1.2 二次封装的核心价值

markdown 复制代码
Element Plus(基础组件)
    ↓  二次封装
企业组件库(业务适配 + 风格统一 + 功能增强)
    ↓  业务使用
   项目开发(只关心业务逻辑)

核心原则:

  • 不重写,只增强(保留 Element Plus 原有 API)
  • 统一入口 ,业务不直接引用 element-plus
  • 可配置,主题 / 行为可通过配置覆盖

二、项目初始化

2.1 创建组件库项目

bash 复制代码
# 创建 Vue3 组件库项目
npm create vite@latest my-ui -- --template vue-ts
cd my-ui

# 安装 Element Plus
npm install element-plus @element-plus/icons-vue

# 安装开发依赖
npm install -D vite vue-tsc typescript
npm install -D vite-plugin-dts
npm install -D unplugin-vue-components unplugin-auto-import

2.2 项目结构

python 复制代码
my-ui/
├── src/
│   ├── components/          # 二次封装的组件
│   │   ├── MButton/
│   │   │   └── index.vue
│   │   ├── MTable/
│   │   │   └── index.vue
│   │   ├── MDialog/
│   │   │   └── index.vue
│   │   ├── MForm/
│   │   │   └── index.vue
│   │   └── index.ts         # 统一导出
│   ├── theme/               # 主题定制
│   │   ├── vars.scss
│   │   └── index.scss
│   └── types/               # 类型定义
│       └── global.d.ts
├── docs/                    # VitePress 文档
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md

三、组件二次封装实战

3.1 封装原则

markdown 复制代码
1. 透传(Pass-through):保留原组件所有 Props / Events / Slots
2. 增强(Enhance):添加业务常用功能
3. 约束(Constrain):禁用或改默认行为,统一风格
4. 类型(Type-safe):用 TypeScript 保证类型安全

3.2 MButton --- 统一按钮风格

目标: 统一默认尺寸、禁用某些类型、添加权限控制

vue 复制代码
<!-- src/components/MButton/index.vue -->
<template>
  <el-button
    v-bind="mergedProps"
    :loading="innerLoading"
    @click="handleClick"
  >
    <template v-for="(_, name) in $slots" :key="name" v-slot:[name]>
      <slot :name="name" />
    </template>
  </el-button>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ButtonProps } from 'element-plus'

export interface MButtonProps extends Partial<ButtonProps> {
  /** 是否有权限显示(权限控制) */
  auth?: string
  /** 是否显示加载态(支持异步) */
  loading?: boolean | Promise<any>
}

const props = withDefaults(defineProps<MButtonProps>(), {
  type: 'primary',
  size: 'small',
  auth: undefined,
  loading: false
})

const emit = defineEmits<{
  click: [e: MouseEvent]
}>()

const innerLoading = ref(false)

// 合并默认 Props,透传剩余属性
const mergedProps = computed(() => {
  const { auth, loading, ...rest } = props as any
  return {
    ...rest,
    // 默认小型按钮
    size: props.size || 'small',
    // 禁用 text 类型(业务不推荐)
    type: props.type === 'text' ? 'primary' : props.type
  }
})

const handleClick = async (e: MouseEvent) => {
  // 如果 loading 是 Promise,自动管理加载态
  if (props.loading && typeof props.loading === 'object') {
    innerLoading.value = true
    try {
      await props.loading
    } finally {
      innerLoading.value = false
    }
  }
  emit('click', e)
}

// 权限检查(示例,实际对接权限系统)
const hasAuth = computed(() => {
  if (!props.auth) return true
  // 这里对接你的权限系统
  return true
})

// 没有权限时不渲染
if (!hasAuth.value) {
  // 在父组件中通过 v-if 控制,这里不渲染
}
</script>

使用方式:

vue 复制代码
<template>
  <MButton type="primary" :loading="save()" @click="handleSave">
    保存
  </MButton>

  <MButton :auth="'user:delete'" type="danger" @click="handleDelete">
    删除
  </MButton>
</template>

<script setup lang="ts">
import { MButton } from 'my-ui'
import 'my-ui/dist/theme/index.css'

const save = async () => {
  // 返回 Promise,按钮自动 loading
  return fetch('/api/save', { method: 'POST' })
}
</script>

3.3 MTable --- 增强表格(分页 / 空态 / 自适应高度)

目标: 内置分页、空状态、自适应高度、列配置

vue 复制代码
<!-- src/components/MTable/index.vue -->
<template>
  <div class="m-table">
    <el-table
      ref="tableRef"
      v-bind="$attrs"
      :data="displayData"
      :height="tableHeight"
      stripe
      border
      size="small"
    >
      <!-- 空状态插槽 -->
      <template #empty>
        <el-empty description="暂无数据" />
      </template>

      <!-- 透传默认插槽(列定义) -->
      <slot />

      <!-- 操作列(可选) -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        fixed="right"
      >
        <template #default="scope">
          <slot name="action" v-bind="scope" />
        </template>
      </el-table-column>
    </el-table>

    <!-- 内置分页 -->
    <div v-if="pagination" class="m-table__pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="[10, 20, 50, 100]"
        :total="total"
        layout="total, sizes, prev, pager, next, jumper"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

export interface MTableProps {
  data?: any[]
  total?: number
  pagination?: boolean
  actionWidth?: number
  autoHeight?: boolean       // 自动撑满容器高度
}

const props = withDefaults(defineProps<MTableProps>(), {
  data: () => [],
  total: 0,
  pagination: true,
  actionWidth: 180,
  autoHeight: false
})

const emit = defineEmits<{
  'page-change': [page: number, pageSize: number]
  'size-change': [pageSize: number]
}>()

const tableRef = ref()
const currentPage = ref(1)
const pageSize = ref(10)
const tableHeight = ref<number | undefined>(undefined)

// 自动高度(撑满父容器)
const updateHeight = () => {
  if (!props.autoHeight) return
  const parent = tableRef.value?.$el?.parentElement
  if (parent) {
    const rect = parent.getBoundingClientRect()
    // 减去分页器高度和其他元素高度
    tableHeight.value = rect.height - 52
  }
}

onMounted(() => {
  updateHeight()
  window.addEventListener('resize', updateHeight)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateHeight)
})

const displayData = computed(() => {
  if (!props.pagination) return props.data
  // 前端分页(小数据量场景)
  const start = (currentPage.value - 1) * pageSize.value
  return props.data.slice(start, start + pageSize.value)
})

const handleSizeChange = (val: number) => {
  pageSize.value = val
  currentPage.value = 1
  emit('size-change', val)
}

const handleCurrentChange = (val: number) => {
  emit('page-change', val, pageSize.value)
}
</script>

<style scoped>
.m-table {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.m-table__pagination {
  display: flex;
  justify-content: flex-end;
  margin-top: 16px;
  padding: 8px 0;
}
</style>

使用方式:

vue 复制代码
<template>
  <MTable
    :data="tableData"
    :total="total"
    :auto-height="true"
    @page-change="handlePageChange"
  >
    <el-table-column prop="name" label="名称" />
    <el-table-column prop="status" label="状态">
      <template #default="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'danger'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
    </el-table-column>

    <template #action="{ row }">
      <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
      <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
    </template>
  </MTable>
</template>

3.4 MDialog --- 增强弹窗(拖拽 / 全屏 / 自适应)

vue 复制代码
<!-- src/components/MDialog/index.vue -->
<template>
  <el-dialog
    v-model="visible"
    v-bind="mergedProps"
    :fullscreen="isFullscreen"
    draggable
    @close="handleClose"
  >
    <!-- 标题栏增强:全屏按钮 -->
    <template #header="{ close }">
      <div class="m-dialog__header">
        <span class="m-dialog__title">{{ title }}</span>
        <div class="m-dialog__header-buttons">
          <el-button text @click="isFullscreen = !isFullscreen">
            <el-icon>
              <FullScreen v-if="!isFullscreen" />
              <Close v-else />
            </el-icon>
          </el-button>
          <el-button text @click="close">
            <el-icon><Close /></el-icon>
          </el-button>
        </div>
      </div>
    </template>

    <!-- 弹窗内容 -->
    <slot />

    <!-- 底部按钮(可选) -->
    <template v-if="showFooter" #footer>
      <slot name="footer">
        <el-button @click="visible = false">取消</el-button>
        <el-button type="primary" :loading="confirmLoading" @click="handleConfirm">
          确定
        </el-button>
      </slot>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { FullScreen, Close } from '@element-plus/icons-vue'
import type { DialogProps } from 'element-plus'

export interface MDialogProps extends Partial<DialogProps> {
  title?: string
  showFooter?: boolean
  confirmLoading?: boolean
}

const props = withDefaults(defineProps<MDialogProps>(), {
  title: '',
  showFooter: true,
  confirmLoading: false,
  width: '600px'
})

const emit = defineEmits<{
  confirm: []
  close: []
}>()

const visible = defineModel<boolean>('modelValue', { default: false })
const isFullscreen = ref(false)

const mergedProps = computed(() => {
  const { title, showFooter, confirmLoading, ...rest } = props as any
  return {
    ...rest,
    destroyOnClose: true,    // 默认关闭时销毁内容
    alignCenter: true,        // 默认居中
    draggable: true           // 默认可拖拽
  }
})

const handleConfirm = () => {
  emit('confirm')
}

const handleClose = () => {
  emit('close')
  visible.value = false
}
</script>

<style scoped>
.m-dialog__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}
.m-dialog__title {
  font-weight: 600;
  font-size: 16px;
}
.m-dialog__header-buttons {
  display: flex;
  gap: 4px;
}
</style>

四、统一导出与自动导入

4.1 统一导出

typescript 复制代码
// src/components/index.ts
export { default as MButton } from './MButton/index.vue'
export { default as MTable } from './MTable/index.vue'
export { default as MDialog } from './MDialog/index.vue'
export { default as MForm } from './MForm/index.vue'

// 类型导出
export type { MButtonProps } from './MButton/index.vue'
export type { MTableProps } from './MTable/index.vue'
export type { MDialogProps } from './MDialog/index.vue'
export type { MFormProps } from './MForm/index.vue'

4.2 全局注册插件

typescript 复制代码
// src/index.ts
import type { App } from 'vue'

// 组件
import MButton from './components/MButton/index.vue'
import MTable from './components/MTable/index.vue'
import MDialog from './components/MDialog/index.vue'
import MForm from './components/MForm/index.vue'

// 样式
import './theme/index.scss'

export default {
  install(app: App) {
    app.component('MButton', MButton)
    app.component('MTable', MTable)
    app.component('MDialog', MDialog)
    app.component('MForm', MForm)
  }
}

// 按需导出
export { MButton, MTable, MDialog, MForm }

4.3 Vite 配置(库模式构建)

typescript 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'

export default defineConfig({
  plugins: [
    vue(),
    dts({
      include: ['src/**/*.vue', 'src/**/*.ts'],
      staticImport: true
    })
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyUI',
      fileName: 'my-ui'
    },
    rollupOptions: {
      // 外部化 Vue 和 Element Plus
      external: ['vue', 'element-plus'],
      output: {
        globals: {
          vue: 'Vue',
          'element-plus': 'ElementPlus'
        }
      }
    }
  }
})

4.4 package.json 配置

json 复制代码
{
  "name": "my-ui",
  "version": "1.0.0",
  "main": "dist/my-ui.umd.cjs",
  "module": "dist/my-ui.js",
  "types": "dist/index.d.ts",
  "files": ["dist", "src"],
  "peerDependencies": {
    "vue": "^3.4.0",
    "element-plus": "^2.9.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "prepublishOnly": "npm run build"
  }
}

五、主题定制

5.1 SCSS 变量覆盖

scss 复制代码
// src/theme/vars.scss
@use 'sass:map';

// Element Plus 变量覆盖
$--color-primary: #409eff !default;
$--color-success: #67c23a !default;
$--color-warning: #e6a23c !default;
$--color-danger: #f56c6c !default;
$--color-info: #909399 !default;

// 企业品牌色
$--brand-color: #1890ff;
$--brand-light: #e6f7ff;

// 圆角
$--border-radius-base: 6px;
$--border-radius-small: 4px;

// 字体
$--font-size-base: 14px;
$--font-size-small: 13px;

// 导出给 JS 使用
:export {
  brandColor: $--brand-color;
  brandLight: $--brand-light;
}
scss 复制代码
// src/theme/index.scss
@use './vars.scss';

// 引入 Element Plus 基础样式
@use 'element-plus/theme-chalk/src/index' as *;

// 覆盖 Element Plus 样式
.el-button--primary {
  --el-button-bg-color: #{vars.$--brand-color};
  --el-button-border-color: #{vars.$--brand-color};
}

.el-table {
  --el-table-header-bg-color: #fafafa;
  --el-table-row-hover-bg-color: #{vars.$--brand-light};
}

5.2 Vite 配置注入 SCSS 变量

typescript 复制代码
// vite.config.ts(业务项目侧)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  css: {
    preprocessorOptions: {
      scss: {
        // 自动注入主题变量,所有组件可直接使用
        additionalData: `@use "${resolve(__dirname, 'node_modules/my-ui/src/theme/vars.scss')}" as *;`
      }
    }
  }
})

六、组件库文档(VitePress)

6.1 初始化 VitePress

bash 复制代码
cd my-ui
mkdir docs && cd docs
npm install -D vitepress

6.2 文档结构

arduino 复制代码
docs/
├── .vitepress/
│   ├── config.ts
│   └── theme/
│       └── index.ts
├── guide/
│   ├── getting-started.md
│   └── theme.md
├── components/
│   ├── mbutton.md
│   ├── mtable.md
│   ├── mdialog.md
│   └── mform.md
└── index.md

6.3 组件文档示例(MButton)

markdown 复制代码
# MButton 按钮

二次封装的按钮组件,统一项目按钮风格,支持权限控制和异步加载。

## 基础用法

```vue
<template>
  <MButton type="primary" @click="handleClick">主要按钮</MButton>
  <MButton @click="handleClick">默认按钮</MButton>
  <MButton type="success" loading @click="handleClick">加载中</MButton>
</template>
<script setup>
import { MButton } from 'my-ui'
</script>

Props

参数 说明 类型 默认值
type 按钮类型 `primary success
size 尺寸 `large default
loading 加载态(支持 booleanPromise `boolean Promise`
auth 权限标识(无权限不渲染) string -

Events

事件名 说明 回调参数
click 点击按钮 (e: MouseEvent) => void

异步加载示例

vue 复制代码
<template>
  <MButton :loading="handleSave" type="primary">
    保存
  </MButton>
</template>

<script setup>
const handleSave = async () => {
  // 返回 Promise,按钮自动进入 loading 状态
  return fetch('/api/save', { method: 'POST' })
}
</script>
php 复制代码
### 6.4 VitePress 配置

```typescript
// docs/.vitepress/config.ts
import { defineConfig } from 'vitepress'
import { resolve } from 'path'

export default defineConfig({
  title: 'MyUI 组件库',
  description: '基于 Element Plus 的企业级组件库',
  themeConfig: {
    nav: [
      { text: '指南', link: '/guide/getting-started' },
      { text: '组件', link: '/components/mbutton' }
    ],
    sidebar: {
      '/guide/': [
        {
          text: '指南',
          items: [
            { text: '快速开始', link: '/guide/getting-started' },
            { text: '主题定制', link: '/guide/theme' }
          ]
        }
      ],
      '/components/': [
        {
          text: '基础组件',
          items: [
            { text: 'MButton 按钮', link: '/components/mbutton' },
            { text: 'MTable 表格', link: '/components/mtable' },
            { text: 'MDialog 弹窗', link: '/components/mdialog' },
            { text: 'MForm 表单', link: '/components/mform' }
          ]
        }
      ]
    }
  }
})

七、发布到 npm

7.1 构建

bash 复制代码
npm run build

生成 dist/ 目录:

perl 复制代码
dist/
├── my-ui.js            # ESM
├── my-ui.umd.cjs      # UMD
├── my-ui.css           # 样式
└── types/
    └── index.d.ts      # 类型声明

7.2 发布

bash 复制代码
# 登录 npm
npm login

# 发布(第一次)
npm publish --access public

# 更新版本
npm version patch   # 1.0.0 → 1.0.1
npm version minor   # 1.0.0 → 1.1.0
npm version major   # 1.0.0 → 2.0.0
npm publish

7.3 业务项目使用

bash 复制代码
npm install my-ui
typescript 复制代码
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import MyUI from 'my-ui'
import 'my-ui/dist/theme/index.css'

const app = createApp(App)
app.use(MyUI)
app.mount('#app')
vue 复制代码
<!-- 直接使用,无需单独 import -->
<template>
  <MButton type="primary">按钮</MButton>
  <MTable :data="data" />
</template>

八、最佳实践 & 踩坑指南

8.1 组件设计原则

markdown 复制代码
✅ 多做:
  - 透传原有 Props / Events / Slots
  - 用 computed 合并默认 Props
  - 提供 TypeScript 类型定义
  - 用 v-bind="$attrs" 透传未声明属性

❌ 不要做:
  - 重写组件逻辑(改源码)
  - 屏蔽原有 API(除非明确不推荐)
  - 在组件库里写业务逻辑
  - 硬编码样式(用 CSS 变量)

8.2 常见坑点

原因 解决
属性透传失效 Vue3 默认不继承非 Prop 属性 v-bind="$attrs"inheritAttrs: false
样式覆盖不生效 Scoped 样式优先级问题 :deep() 或全局样式
类型声明丢失 vite-plugin-dts 配置问题 检查 include 路径,确保 .vue 文件被包含
按需引入失败 未正确配置 unplugin-vue-components resolver 对接组件库
主题变量注入失败 SCSS additionalData 路径错误 resolve() 绝对路径

8.3 按需引入配置(业务项目侧)

typescript 复制代码
// vite.config.ts(业务项目)
import Components from 'unplugin-vue-components/vite'
import { resolve } from 'path'

export default defineConfig({
  plugins: [
    Components({
      resolvers: [
        // 自动引入 my-ui 组件
        (componentName) => {
          if (componentName.startsWith('M')) {
            return { name: componentName, from: 'my-ui' }
          }
        }
      ],
      dts: true // 生成 components.d.ts
    })
  ]
})

这样业务项目可以直接使用,无需手动 import:

vue 复制代码
<template>
  <!-- 自动引入,无需 import -->
  <MButton type="primary">按钮</MButton>
</template>

九、总结

环节 工具 / 方案 核心价值
组件封装 Vue3 <script setup> + TypeScript 类型安全,增强功能
属性透传 v-bind="$attrs" + computed 合并 保留原有 API,无缝增强
主题定制 SCSS 变量覆盖 + CSS 变量 统一风格,支持动态切换
全局注册 Vue Plugin + app.component() 业务侧开箱即用
构建打包 Vite 库模式 + vite-plugin-dts ESM/UMD 双格式 + 类型声明
文档 VitePress 组件 Demo + Props/Events 表格
按需引入 unplugin-vue-components 自动引入,减小包体积

一句话总结:二次封装不是造轮子,而是在轮子上装方向盘、油门和刹车------让业务开发者专注开车,不用管轮子怎么转。

相关推荐
KaMeidebaby1 小时前
卡梅德生物技术快报|单克隆抗体人源化 PEG 修饰质控方法体系构建与验证
服务器·前端·数据库·人工智能·算法·百度·新浪微博
shen_1 小时前
JS语法:生成器和可迭代对象
javascript
元宵大师1 小时前
[升级V2.1.5]回测模块重构:参数确认+异步进度+日志持久化!本地Web版多因子轮动系统
前端·重构
咋吃都不胖lyh1 小时前
限流重试、指数退避、随机抖动
前端
之歆1 小时前
DAY_11JavaScript BOM与DOM深度解析:底层原理与工程实践(上)
开发语言·前端·javascript·ecmascript
冴羽yayujs2 小时前
GitHub 前端热榜项目 - 日榜(2026-05-17)
前端·github
老马95272 小时前
opencode8-桌面应用实战 3
前端·人工智能·后端
逆yan_2 小时前
🧭 基于 pnpm Workspace 和 Turborepo 的 Monorepo 最佳实践
前端·javascript·架构
广州华水科技2 小时前
单北斗形变监测一体机在大坝安全监测中的应用与技术优势
前端