几乎所有 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 | 加载态(支持 boolean 或 Promise) |
`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 |
自动引入,减小包体积 |
一句话总结:二次封装不是造轮子,而是在轮子上装方向盘、油门和刹车------让业务开发者专注开车,不用管轮子怎么转。