1. Element Plus 组件二次封装技巧
vue-element-plus-admin 项目基于 Element Plus 组件库进行了全面的二次封装,形成了一套符合业务需求的高阶组件体系,具有较强的扩展性和统一性。本部分将分析项目中组件二次封装的主要技巧和设计思路。
1.1 二次封装的核心思想
项目中对 Element Plus 组件的二次封装主要基于以下几个核心思想:
- 保留原有功能:通过透传 props、事件和插槽,确保原组件的所有功能特性在二次封装后依然可用
- 扩展新特性:针对业务场景需求,添加额外的功能和属性
- 简化调用:将常用配置封装为默认值,减少使用时的重复代码
- 统一样式:维持整个系统的设计风格一致性
- 类型支持:完善的 TypeScript 类型定义,提升开发体验
1.2 Props 处理与透传技巧
在项目中,组件封装普遍采用了 Props 透传与筛选的处理方式,以 Dialog 组件为例:
// src/components/Dialog/src/Dialog.vue
const getBindValue = computed(() => {
const delArr: string[] = ['fullscreen', 'title', 'maxHeight']
const attrs = useAttrs()
const obj = { ...attrs, ...props }
for (const key in obj) {
if (delArr.indexOf(key) !== -1) {
delete obj[key]
}
}
return obj
})
这段代码实现了:
- 获取所有传入的属性和事件(
useAttrs()) - 与显式声明的 props 合并
- 移除已经在组件内部特殊处理的属性
- 将剩余属性透传给 Element Plus 原始组件
这种处理方式确保了二次封装不会破坏原组件的功能,同时可以添加自定义属性和逻辑。
1.3 插槽透传与增强
插槽的处理是二次封装的关键部分,项目中采用了多种方式处理插槽:
- 简单透传:直接将外部插槽内容传递给内部组件
<ElDialog v-bind="getBindValue">
<template #default>
<slot></slot>
</template>
</ElDialog>
- 条件插槽:根据是否提供插槽内容决定渲染逻辑
<template #footer v-if="slots.footer">
<slot name="footer"></slot>
</template>
- 增强插槽:在原插槽基础上增加额外内容
<template #header="{ close }">
<div class="flex justify-between items-center h-54px pl-15px pr-15px relative">
<slot name="title">
{{ title }}
</slot>
<div class="...">
<!-- 额外的控制按钮 -->
<Icon v-if="fullscreen" ... @click="toggleFull" />
<Icon ... @click="close" />
</div>
</div>
</template>
1.4 组合式函数与逻辑抽取
为了保持组件代码的简洁与可维护性,项目大量使用了组合式函数(Composable)抽取通用逻辑,例如 Dialog 组件的调整大小功能:
// src/components/Dialog/hooks/useResize.ts
export const useResize = (props?: {
minHeightPx?: number
minWidthPx?: number
initHeight?: number
initWidth?: number
}) => {
// ...实现逻辑
return {
setupDrag,
maxHeight,
minWidth
}
}
在 ResizeDialog 组件中使用:
// src/components/Dialog/src/ResizeDialog.vue
const { maxHeight, minWidth, setupDrag } = useResize({
minHeightPx: props.minResizeHeight,
minWidthPx: props.minResizeWidth,
initHeight: props.initHeight,
initWidth: props.initWidth
})
这种方式有以下优势:
- 关注点分离,使组件逻辑更加清晰
- 实现代码复用,避免重复逻辑
- 便于独立测试和维护
1.5 统一样式与设计系统
项目通过统一的设计变量和工具函数维持样式的一致性:
// 使用统一的样式前缀
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('icon')
同时,在样式中使用 Less 变量系统:
@prefix-cls: ~'@{adminNamespace}-icon';
.@{prefix-cls} {
// 样式定义
}
这种方式确保了整个系统的样式命名一致,便于主题定制和样式覆盖。
2. 通用业务组件实现
项目中实现了一系列高度封装的业务组件,这些组件针对常见的业务场景提供了开箱即用的解决方案,大大提高了开发效率。
2.1 Form 组件的设计与实现
Form 组件是项目中最复杂的封装之一,它通过 schema 配置化的方式生成表单,支持多种布局和验证方式。
核心设计特点:
- Schema 驱动:通过统一的数据结构定义表单
// 使用示例
const schema: FormSchema[] = [
{
field: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名'
},
required: true
}
]
- 动态表单项:支持动态增删和条件显示
// 动态增加表单项
const addSchema = (formSchema: FormSchema, index?: number) => {
const { schema } = unref(getProps)
if (index !== void 0) {
schema.splice(index, 0, formSchema)
return
}
schema.push(formSchema)
}
- 组件映射:通过映射表将字符串类型转换为实际组件
// src/components/Form/src/helper/componentMap.ts
import { ElInput, ElSelect, /* ... */ } from 'element-plus'
export const componentMap = {
Input: ElInput,
Select: ElSelect,
// ... 其他组件映射
}
- TSX 渲染:使用 TSX 灵活构建表单结构
// 渲染表单项
const renderFormItem = (item: FormSchema) => {
// ... 处理逻辑
return (
<ElFormItem
v-show={!item.hidden}
ref={(el: any) => setFormItemRefMap(el, item.field)}
{...(item.formItemProps || {})}
prop={item.field}
label={item.label || ''}
>
{formItemSlots}
</ElFormItem>
)
}
核心功能实现:
Form 组件的核心在于将 schema 转换为实际的表单元素,并处理数据绑定、验证等逻辑。关键实现包括:
// 初始化表单数据
watch(
() => unref(getProps).schema,
(schema = []) => {
formModel.value = initModel(schema, unref(formModel))
},
{
immediate: true,
deep: true
}
)
// 处理不同类型的表单组件
if (item.component === ComponentNameEnum.SELECT) {
slotsMap.default = !componentSlots.default
? () => renderSelectOptions(item)
: () => {
return componentSlots.default(
unref((item?.componentProps as SelectComponentProps)?.options)
)
}
}
2.2 Table 组件的设计与实现
Table 组件针对后台管理系统的数据表格场景进行了高度封装,支持分页、排序、筛选、自定义列等功能。
核心设计特点:
- 列配置化:通过配置对象定义表格列
const columns: TableColumn[] = [
{ type: 'selection' },
{ type: 'index', width: '60' },
{
field: 'title',
label: '标题',
search: {
show: true
}
}
]
- 动态控制:支持列的动态显示、隐藏和排序
// 设置列属性
const setColumn = (columnProps: TableSetProps[], columnsChildren?: TableColumn[]) => {
const { columns } = unref(getProps)
for (const v of columnsChildren || columns) {
for (const item of columnProps) {
if (v.field === item.field) {
set(v, item.path, item.value)
} else if (v.children?.length) {
setColumn(columnProps, v.children)
}
}
}
}
- 内容渲染:支持多种内容渲染方式,包括格式化、自定义插槽和多级表头
// 渲染表格列
const renderTableColumn = (columnsChildren?: TableColumn[]) => {
// ... 处理逻辑
return (columnsChildren || columns).map((v) => {
// ... 处理每列的渲染逻辑
return (
<ElTableColumn
showOverflowTooltip={showOverflowTooltip}
align={align}
headerAlign={headerAlign}
{...props}
prop={v.field}
>
{slots}
</ElTableColumn>
)
})
}
- 媒体预览:内置图片和视频预览功能
const renderPreview = (url: string, field: string) => {
const { imagePreview, videoPreview } = unref(getProps)
return (
<div class="flex items-center">
{imagePreview.includes(field) ? (
<ElImage
src={url}
fit="cover"
class="w-[100%]"
lazy
preview-src-list={[url]}
preview-teleported
/>
) : videoPreview.includes(field) ? (
<BaseButton
type="primary"
icon={<Icon icon="vi-ep:video-play" />}
onClick={() => {
createVideoViewer({
url
})
}}
>
预览
</BaseButton>
) : null}
</div>
)
}
2.3 Dialog 组件的设计与实现
Dialog 组件扩展了 Element Plus 的 Dialog,添加了更多实用功能,如全屏、可调整大小等。
核心设计特点:
- 多功能头部:自定义头部,添加全屏切换按钮
<template #header="{ close }">
<div class="flex justify-between items-center h-54px pl-15px pr-15px relative">
<slot name="title">
{{ title }}
</slot>
<div class="...">
<Icon
v-if="fullscreen"
class="cursor-pointer is-hover !h-54px mr-10px"
:icon="
isFullscreen ? 'vi-radix-icons:exit-full-screen' : 'vi-radix-icons:enter-full-screen'
"
color="var(--el-color-info)"
hover-color="var(--el-color-primary)"
@click="toggleFull"
/>
<Icon
class="cursor-pointer is-hover !h-54px"
icon="vi-ep:close"
hover-color="var(--el-color-primary)"
color="var(--el-color-info)"
@click="close"
/>
</div>
</div>
</template>
- 可调整大小:ResizeDialog 组件通过自定义指令和 useResize 钩子实现大小调整功能
// 自定义指令实现可调整大小
const vResize = {
mounted(el) {
const observer = new MutationObserver(() => {
const elDialog = el.querySelector('.el-dialog')
if (elDialog) {
setupDrag(elDialog, el)
}
})
observer.observe(el, { childList: true, subtree: true })
}
}
- 嵌套内容处理:使用 ElScrollbar 组件处理内容溢出
<ElScrollbar :style="dialogStyle">
<slot></slot>
</ElScrollbar>
3. 自定义 Hook 设计与应用场景
项目大量使用自定义 Hook (组合式函数) 来封装和复用逻辑,这是 Vue 3 Composition API 的一个重要实践。
3.1 Hook 设计原则
项目中的 Hook 设计遵循以下原则:
- 单一职责:每个 Hook 只负责一个明确的功能点
- 组合优先:通过组合简单的 Hook 构建复杂功能
- 命名规范:统一使用
useXxx命名约定 - 参数可配置:通过参数配置 Hook 的行为
- 返回值明确:返回值包含状态和操作方法
3.2 常用 Hook 分析
useClipboard - 剪贴板操作封装
// src/hooks/web/useClipboard.ts
const useClipboard = () => {
const copied = ref(false)
const text = ref('')
const isSupported = ref(false)
// 检查浏览器支持
if (!navigator.clipboard && !document.execCommand) {
isSupported.value = false
} else {
isSupported.value = true
}
// 复制功能实现
const copy = (str: string) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => {
text.value = str
copied.value = true
resetCopied()
})
return
}
// 降级处理
const input = document.createElement('input')
// ... 实现细节
}
// 重置复制状态
const resetCopied = () => {
setTimeout(() => {
copied.value = false
}, 1500)
}
return { copy, text, copied, isSupported }
}
这个 Hook:
- 封装了跨浏览器的剪贴板操作
- 提供了复制状态反馈
- 支持降级处理
- 自动重置复制状态
useResize - 元素大小调整
// src/components/Dialog/hooks/useResize.ts
export const useResize = (props?: {
minHeightPx?: number
minWidthPx?: number
initHeight?: number
initWidth?: number
}) => {
// ... 初始化参数
// 设置拖拽调整大小
const setupDrag = (elDialog: any, el: any) => {
// ... 实现拖拽调整大小的逻辑
}
return {
setupDrag,
maxHeight,
minWidth
}
}
这个 Hook:
- 接受可配置参数
- 封装了复杂的 DOM 操作和事件处理
- 返回状态和方法供组件使用
useCrudSchemas - 统一表单和表格配置
// src/hooks/web/useCrudSchemas.ts
export const useCrudSchemas = (
crudSchema: CrudSchema[]
): {
allSchemas: AllSchemas
} => {
// 所有结构数据
const allSchemas = reactive<AllSchemas>({
searchSchema: [],
tableColumns: [],
formSchema: [],
detailSchema: []
})
// 过滤处理各种 Schema
const searchSchema = filterSearchSchema(crudSchema)
allSchemas.searchSchema = searchSchema || []
const tableColumns = filterTableSchema(crudSchema)
allSchemas.tableColumns = tableColumns || []
// ... 其他处理
return {
allSchemas
}
}
这个 Hook:
- 接受统一的配置结构
- 转换为不同组件所需的配置格式
- 减少重复配置的工作量
3.3 Hook 与组件的结合使用
项目中的 Hook 往往与组件紧密结合,有两种主要使用模式:
- 外部引入:在组件中直接导入并使用 Hook
<script setup lang="ts">
import { useClipboard } from '@/hooks/web/useClipboard'
const { copy, copied, isSupported } = useClipboard()
// 组件中使用
const handleCopy = () => {
copy('要复制的文本')
}
</script>
- 内部定义:在组件文件中定义并使用特定于该组件的 Hook
// src/components/Menu/src/components/useRenderMenuItem.tsx
export const useRenderMenuItem = (menuMode) => {
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
// ... 渲染逻辑
}
return {
renderMenuItem
}
}
4. 组件通信模式与事件处理
项目采用了多种组件通信模式,根据不同场景选择最合适的方式。
4.1 Props/Emit 基础通信
最基本的组件通信方式是通过 Props 传递数据和通过 Emit 触发事件:
// Props 定义
const props = defineProps({
modelValue: propTypes.bool.def(false),
title: propTypes.string.def('Dialog'),
fullscreen: propTypes.bool.def(true),
maxHeight: propTypes.oneOfType([String, Number]).def('400px')
})
// 事件触发
emit('update:currentPage', val)
在父组件中使用:
<Table
v-model:currentPage="currentPage"
v-model:pageSize="pageSize"
:columns="columns"
:data="data"
@register="registerTable"
/>
4.2 Expose/Ref 实例引用
对于复杂组件,项目使用 expose 和 ref 实现直接调用组件方法:
// 子组件暴露方法
expose({
setValues,
formModel,
setProps,
delSchema,
addSchema,
setSchema,
getComponentExpose,
getFormItemExpose
})
// 父组件引用
const formRef = ref<ComponentRef<typeof Form>>()
// 调用方法
const setSchemaField = async () => {
await nextTick()
unref(formRef)?.setSchema([
{
field: 'field1',
path: 'componentProps.options',
value: [{ label: '选项1', value: 1 }]
}
])
}
4.3 provide/inject 依赖注入
对于深层嵌套的组件通信,项目使用 provide/inject 机制:
// 在上层组件提供数据
provide(configProviderContextKey, props.configGlobal)
// 在深层组件中注入数据
const configGlobal = inject(configProviderContextKey, {})
4.4 事件总线
对于复杂的跨组件通信,项目实现了基于 mitt 的事件总线:
// src/hooks/event/useEventBus.ts
import mitt from 'mitt'
import { onBeforeUnmount } from 'vue'
const emitter = mitt()
export const useEventBus = (key: string) => {
// 监听事件
function on(callback: Fn) {
emitter.on(key, callback)
}
// 触发事件
function emit<T = any>(event?: T) {
emitter.emit(key, event)
}
// 组件卸载时自动移除监听
onBeforeUnmount(() => {
emitter.off(key)
})
return {
on,
emit
}
}
使用示例:
// 组件 A 触发事件
const { emit } = useEventBus('refresh-table')
emit()
// 组件 B 监听事件
const { on } = useEventBus('refresh-table')
on(() => {
fetchData()
})
4.5 组件注册与回调
项目中广泛使用"注册回调"模式,通过 register 事件实现对组件实例的引用:
// 组件内部
onMounted(() => {
emit('register', unref(elTableRef)?.$parent, elTableRef)
})
// 使用组件
const [registerTable] = useTable({
columns,
loading: () => loading.value
})
// 组件注册
<Table @register="registerTable" />
这种模式使得父组件可以优雅地控制子组件,同时保持组件的封装性。
5. 函数式组件与 JSX/TSX 实践
项目中大量使用了 JSX/TSX 来构建复杂的组件结构,特别是需要动态渲染的场景。
5.1 JSX/TSX 的使用场景
在项目中,JSX/TSX 主要用于以下场景:
- 动态渲染组件:如 Form 组件根据 schema 生成表单项
- 复杂条件渲染:包含多层条件判断的渲染逻辑
- 递归组件:如菜单树的渲染
- 辅助渲染函数:作为组件的辅助渲染工具
5.2 useRenderMenuItem - 菜单项渲染
这是项目中 TSX 应用的典型示例,用于动态渲染菜单项:
// src/components/Menu/src/components/useRenderMenuItem.tsx
export const useRenderMenuItem = (menuMode) => {
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
return routers
.filter((v) => !v.meta?.hidden)
.map((v) => {
const meta = v.meta ?? {}
const { oneShowingChild, onlyOneChild } = hasOneShowingChild(v.children, v)
const fullPath = isUrl(v.path) ? v.path : pathResolve(parentPath, v.path)
if (
oneShowingChild &&
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
!meta?.alwaysShow
) {
return (
<ElMenuItem
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
>
{{
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
}}
</ElMenuItem>
)
} else {
return (
<ElSubMenu
index={fullPath}
teleported
popperClass={unref(menuMode) === 'vertical' ? `${prefixCls}-popper--vertical` : ''}
>
{{
title: () => renderMenuTitle(meta),
default: () => renderMenuItem(v.children!, fullPath)
}}
</ElSubMenu>
)
}
})
}
return {
renderMenuItem
}
}
这个实现的优点:
- 声明式代码:JSX 使复杂的条件渲染更加清晰
- 递归渲染:轻松处理嵌套菜单结构
- 动态属性:可以根据条件动态设置组件属性
- 组合插槽:使用对象语法构造插槽内容
5.3 表单组件中的渲染函数
Form 组件使用 TSX 实现了复杂的动态渲染逻辑:
// 渲染 Form 包装
const renderWrap = () => {
const { isCol } = unref(getProps)
const content = isCol ? (
<ElRow gutter={20}>{renderFormItemWrap()}</ElRow>
) : (
renderFormItemWrap()
)
return content
}
// 渲染表单项包装
const renderFormItemWrap = () => {
const { schema = [], isCol } = unref(getProps)
return schema
.filter((v) => !v.remove)
.map((item) => {
// 如果是 Divider 组件,需要自己占用一行
const isDivider = item.component === 'Divider'
const Com = componentMap['Divider'] as ReturnType<typeof defineComponent>
return isDivider ? (
<Com {...{ contentPosition: 'left', ...item.componentProps }}>{item?.label}</Com>
) : isCol ? (
// 如果需要栅格,需要包裹 ElCol
<ElCol {...setGridProp(item.colProps)}>{renderFormItem(item)}</ElCol>
) : (
renderFormItem(item)
)
})
}
5.4 组件渲染辅助函数
项目封装了一些辅助函数,简化 JSX/TSX 的使用:
// src/utils/tsxHelper.ts
import { Slots } from 'vue'
export const getSlot = (slots: Slots, slot = 'default', data?: any) => {
if (!slots || !Reflect.has(slots, slot)) {
return null
}
if (!data) {
return slots[slot]?.()
}
const slotFn = slots[slot]
if (!slotFn) return null
return slotFn(data)
}
这个辅助函数使得在 TSX 中处理插槽变得简单:
// 使用辅助函数渲染插槽
{getSlot(slots, 'footer')}
// 或者带数据的插槽
{getSlot(slots, 'content', item)}
6. 组件设计的最佳实践总结
通过对 vue-element-plus-admin 项目组件设计的分析,我们可以总结出以下最佳实践:
6.1 统一的组件架构
-
目录结构标准化:每个组件都遵循相似的目录结构
ComponentName/ ├── index.ts # 导出入口 └── src/ ├── ComponentName.vue # 主组件 ├── types/ # 类型定义 ├── components/ # 子组件 └── hooks/ # 相关 hooks -
类型系统:使用 TypeScript 提供完善的类型支持
export interface TableColumn extends TableColumnParams { children?: TableColumn[] field: string label: string } -
统一的命名规范:前缀一致,命名有意义
const prefixCls = getPrefixCls('form')
6.2 可配置化设计
-
Schema 驱动:通过配置对象描述组件结构
const schema: FormSchema[] = [ { field: 'name', label: '姓名', component: 'Input' } ] -
默认值合理化:为常用配置提供合理默认值
const pagination = computed(() => { return Object.assign( { small: false, background: false, pagerCount: 7, layout: 'sizes, prev, pager, next, jumper, ->, total', pageSizes: [10, 20, 30, 40, 50, 100], disabled: false, hideOnSinglePage: false, total: 10 }, unref(getProps).pagination ) }) -
扩展点预留:通过插槽和回调函数预留扩展点
6.3 代码复用策略
-
组合式 API 优先:使用 Composition API 抽取复用逻辑
// 提取可复用的逻辑 const useClipboard = () => { // ...实现 return { copy, text, copied, isSupported } } -
Mixin → Hook 转换:将旧的 Mixin 模式转换为 Hook 模式
-
渲染函数复用:将复杂的渲染逻辑抽取为单独函数
const renderMenuItem = (routers, parentPath) => { // ...渲染逻辑 }
6.4 性能优化考量
-
组件按需加载:全局组件和按需导入组件分离
// 全局组件注册 export const setupGlobCom = (app: App<Element>): void => { app.component('Icon', Icon) app.component('Permission', Permission) app.component('BaseButton', BaseButton) } -
计算属性缓存:使用 computed 缓存计算结果
const getBindValue = computed(() => { // ...计算逻辑 return bindValue }) -
合理的响应性控制:使用 unref 优化响应式对象访问
// 解包响应式对象 const props = { ...unref(getProps) }
6.5 可维护性设计
-
关注点分离:逻辑、UI、样式分离
// 逻辑提取到 hook const { maxHeight, minWidth, setupDrag } = useResize(props) -
命名语义化:函数和变量名能表达其用途
const toggleFull = () => { isFullscreen.value = !unref(isFullscreen) } -
文档化注释:关键函数和组件有注释说明
/** * @description: 获取表单组件实例 * @param filed 表单字段 */ const getComponentExpose = (filed: string) => { return unref(formComponents)[filed] }
7. 总结
vue-element-plus-admin 项目的组件设计体现了现代化前端项目的最佳实践,通过组件封装、Hook 设计和 TSX 应用,实现了高度可复用、可配置的组件库。
这些设计不仅提高了开发效率,也使得代码更具可维护性和扩展性。对于大型中后台项目,这些实践提供了宝贵的参考:
-
二次封装不是简单包装,而是要深入理解原组件设计意图,在保留原功能的基础上提供更高层次的抽象
-
组合式 API 的优势在项目中得到充分体现,实现了更清晰的关注点分离和逻辑复用
-
TSX/JSX 在复杂渲染场景中的价值显著,特别是在需要大量条件渲染和动态组件的场景
-
配置驱动的理念贯穿整个组件设计,通过 Schema 和声明式配置简化了开发
-
类型系统的重要性不可忽视,完善的类型定义使得组件使用更加安全可靠
这些实践对于构建企业级 Vue 3 应用具有很强的指导意义,值得在实际项目中借鉴和应用。