封装组件该考虑的事情 - 从规则到边界...
封装组件不只是看到能复用的UI或者功能封装,既要考虑以一打十,又能单一牛逼,才是王道!!有些xd封装的组件里面业务代码和基础功能耦合混杂,尤其是边界极为不清晰,导致别人用到该组件时,不能很好的扩展和自定义,最后别人就弃用了,还不如自己写自己的..,下面结合规则先行给大家聊聊组件封装该考虑的核心点,团队都能用的爽~~
目录
- 封装组件的动机与价值
- 封装组件要遵守的规则
- 定义边界:组件能做什么/不能做什么
- 如何封装一个"完美"的组件:步骤与检查清单
- 通用组件封装示例:BaseSelect(Vue3+TS)
- [ElementPlus 表格二次封装:DataTablePlus](#ElementPlus 表格二次封装:DataTablePlus "#elementplus-%E8%A1%A8%E6%A0%BC%E4%BA%8C%E6%AC%A1%E5%B0%81%E8%A3%85datatableplus")
- 总结要点与落地建议
为什么要封装组件:动机与价值
- 复用与一致性:抽象通用交互与样式,降低重复代码与视觉/行为差异。
- 可维护性:集中修复缺陷与升级依赖,减少"同样问题修多次"。
- 性能与可用性:在统一抽象层引入缓存、虚拟滚动、懒加载、a11y 等策略。
- 可扩展性:通过插槽、事件与配置,平衡"开箱即用"和"按需扩展"。
封装组件要遵守的规则
- 单一职责与最小API面:每个组件仅解决一个清晰问题;API 小而稳。
- 受控优先(v-model)+ 同步事件:值从外部控制,内部通过
update:modelValue同步。 - 不劫持布局与样式:提供
class、style、size、status等可覆写点;避免硬编码布局。 - 组合大于继承:以组合(slots、props、emits)实现扩展,避免在同一组件里堆功能。
- 边界清晰:只做展示/交互逻辑,不内置业务;业务交给上层处理。
- 类型先行:用 TS 限定输入输出,显式化事件与插槽契约。
- 性能默认优化:避免不必要的响应式深拷贝;用 computed 缓存;按需渲染;支持异步/懒加载。
- 可测试可观测:暴露关键方法;定义可测行为;重要路径有日志与错误边界。
- 文档先于实现变更:任何破坏性变更需在文档与类型上先行体现。
定义边界:组件能做什么/不能做什么
- 能做:
- 提供清晰的输入 props,输出 emits/slots;
- 封装通用交互(校验、状态、空态、加载态);
- 提供 UI 可定制点(插槽/样式变量/配置);
- 性能优化策略的挂载点(虚拟滚动、节流、防抖)。
- 不能做:
- 内置具体业务请求/权限/路由跳转;
- 强入侵的全局样式与布局;
- 把"数据获取策略"与"视图展示"耦合在一起。
经验法则:当你想往组件里加入"接口调用/存储/路由逻辑"时,优先抽到外部,通过 props 或回调传入。
如何封装一个"完美"的组件:步骤与检查清单
步骤:
- 需求澄清:列出"必须/可选/未来可能"能力,绘制状态机。
- API 草案:定义 props/emits/slots/expose,按"最小API"删减一次。
- 性能设计:确定响应式粒度、渲染分层、缓存/懒加载策略。
- 可定制设计:样式覆写点、插槽范围、国际化/无障碍。
- 错误与边界:空数据/异常/加载/超时/禁用态的体验。
- 测试点:快照、交互、类型、可访问性、性能。
检查清单(节选):
- Props 有默认值且文档化;
- 至少一个具名插槽能改写核心内容;
- 值受控,支持
v-model与update:*; - 重要交互通过事件冒泡到父级;
- 渲染数量与依赖计算可控;
- 对外暴露必要方法(
defineExpose); - TS 严格模式通过,关键类型导出;
- 有演示用例与单测。
通用组件封装示例:BaseSelect(Vue3+TS)
目标:在 ElementPlus el-select 之上封装可搜索、远程加载、受控值、性能优化、可插槽扩展的选择器。
vue
<!-- components/BaseSelect.vue -->
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:filterable="filterable"
:clearable="clearable"
:disabled="disabled || loading"
:loading="loading"
:remote="remote"
:remote-method="remote ? handleRemote : undefined"
:reserve-keyword="true"
v-bind="$attrs"
@change="val => emit('update:modelValue', val)"
>
<slot name="prefix" />
<el-option
v-for="opt in displayOptions"
:key="getOptionKey(opt)"
:label="getOptionLabel(opt)"
:value="getOptionValue(opt)"
:disabled="opt.disabled"
>
<slot name="option" :option="opt">
{{ getOptionLabel(opt) }}
</slot>
</el-option>
<template #empty>
<slot name="empty">
<div class="empty">{{ emptyText }}</div>
</slot>
</template>
</el-select>
</template>
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
interface BaseSelectProps<T = any> {
modelValue?: any
options?: T[]
placeholder?: string
disabled?: boolean
clearable?: boolean
filterable?: boolean
loading?: boolean
remote?: boolean
emptyText?: string
labelKey?: string
valueKey?: string
optionKey?: string
remoteMethod?: (query: string) => Promise<T[]>
}
const props = withDefaults(defineProps<BaseSelectProps>(), {
options: () => [],
placeholder: '请选择',
disabled: false,
clearable: true,
filterable: true,
loading: false,
remote: false,
emptyText: '暂无数据',
labelKey: 'label',
valueKey: 'value',
optionKey: 'value'
})
const emit = defineEmits<{ (e: 'update:modelValue', val: any): void }>()
const remoteCache = shallowRef<any[]>([])
const displayOptions = computed(() => props.remote ? remoteCache.value : props.options)
const getOptionLabel = (opt: any) => (typeof opt === 'object' ? opt[props.labelKey] : String(opt))
const getOptionValue = (opt: any) => (typeof opt === 'object' ? opt[props.valueKey] : opt)
const getOptionKey = (opt: any) => (typeof opt === 'object' ? opt[props.optionKey] ?? opt[props.valueKey] : opt)
const handleRemote = async (query: string) => {
if (!props.remoteMethod) return
const list = await props.remoteMethod(query)
remoteCache.value = Array.isArray(list) ? list : []
}
defineExpose({ refreshRemote: () => handleRemote('') })
</script>
<style scoped>
.empty { color: #999; padding: 8px 12px; }
</style>
说明要点:
- 受控值:通过
modelValue+update:modelValue; - 性能:
shallowRef缓存远程数据,避免深层响应开销; - 扩展:
option/empty插槽重写选项与空态; - 边界:远程数据获取由上层注入
remoteMethod,组件不触达业务。
进阶:虚拟滚动片段(可复用到长列表/下拉)
vue
<template>
<div class="virtual-list" ref="containerRef" @scroll="handleScroll">
<div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface VirtualItem { id: number; content: string }
const props = defineProps<{ items: VirtualItem[]; itemHeight: number; containerHeight: number }>()
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight) + 2)
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const totalHeight = computed(() => props.items.length * props.itemHeight)
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = (e: Event) => { scrollTop.value = (e.target as HTMLElement).scrollTop }
let timer: number | null = null
const debouncedScroll = (e: Event) => { if (timer) clearTimeout(timer); timer = setTimeout(() => handleScroll(e), 16) as any }
onMounted(() => containerRef.value?.addEventListener('scroll', debouncedScroll))
onUnmounted(() => containerRef.value?.removeEventListener('scroll', debouncedScroll))
</script>
<style scoped>
.virtual-list { height: 400px; overflow-y: auto; position: relative; }
.virtual-list-phantom { position: absolute; top: 0; left: 0; right: 0; z-index: -1; }
.virtual-list-content { position: absolute; top: 0; left: 0; right: 0; }
.virtual-list-item { display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid #eee; }
</style>
ElementPlus 表格二次封装:DataTablePlus
目标:在 el-table 基础上统一"工具栏 + 表格 + 分页"的模式,抽象列定义、选择、排序、搜索、分页通信;保持业务无侵入,开放插槽与事件。
vue
<!-- components/DataTablePlus.vue -->
<template>
<div class="data-table">
<!-- 工具栏 -->
<div v-if="showToolbar" class="table-toolbar">
<div class="toolbar-left">
<slot name="toolbar-left">
<el-button v-if="showAdd" type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
<el-button v-if="showBatchDelete && hasSelection" type="danger" :icon="Delete" @click="handleBatchDelete">批量删除</el-button>
</slot>
</div>
<div class="toolbar-right">
<slot name="toolbar-right">
<el-input v-if="showSearch" v-model="searchKeyword" placeholder="请输入搜索关键词" :style="{ width: '200px' }" clearable @input="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button v-if="showRefresh" :icon="Refresh" circle @click="handleRefresh" />
</slot>
</div>
</div>
<!-- 表格 -->
<el-table
ref="tableRef"
:data="tableData"
:loading="loading"
:height="height"
:max-height="maxHeight"
:stripe="stripe"
:border="border"
:row-key="rowKey"
:default-sort="defaultSort"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
@row-click="handleRowClick"
v-bind="$attrs"
>
<el-table-column v-if="showSelection" type="selection" width="55" :selectable="selectable" />
<el-table-column v-if="showIndex" type="index" label="序号" width="80" :index="getIndex" />
<el-table-column
v-for="column in columns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
:fixed="column.fixed"
:sortable="column.sortable"
:show-overflow-tooltip="column.showOverflowTooltip !== false"
:align="column.align || 'left'"
:header-align="column.headerAlign || column.align || 'left'"
>
<template #default="{ row, column: col, $index }">
<slot :name="column.prop" :row="row" :column="col" :index="$index" :value="row[column.prop]">
<span v-if="column.formatter">{{ column.formatter(row, col, row[column.prop], $index) }}</span>
<span v-else>{{ row[column.prop] }}</span>
</slot>
</template>
</el-table-column>
<el-table-column v-if="showActions" label="操作" :width="actionWidth" :fixed="actionFixed" align="center">
<template #default="{ row, $index }">
<slot name="actions" :row="row" :index="$index">
<el-button v-if="showEdit" type="primary" link size="small" @click="handleEdit(row, $index)">编辑</el-button>
<el-button v-if="showDelete" type="danger" link size="small" @click="handleDelete(row, $index)">删除</el-button>
</slot>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div v-if="showPagination" class="table-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="pageSizes"
:layout="paginationLayout"
:background="paginationBackground"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElTable } from 'element-plus'
import { Plus, Delete, Search, Refresh } from '@element-plus/icons-vue'
interface TableColumn {
prop: string
label: string
width?: number | string
minWidth?: number | string
fixed?: boolean | 'left' | 'right'
sortable?: boolean | 'custom'
align?: 'left' | 'center' | 'right'
headerAlign?: 'left' | 'center' | 'right'
showOverflowTooltip?: boolean
formatter?: (row: any, column: any, cellValue: any, index: number) => string
}
interface DataTableProps {
data: any[]
columns: TableColumn[]
loading?: boolean
height?: number | string
maxHeight?: number | string
stripe?: boolean
border?: boolean
rowKey?: string | ((row: any) => string | number)
defaultSort?: { prop: string; order: 'ascending' | 'descending' }
showToolbar?: boolean
showAdd?: boolean
showBatchDelete?: boolean
showSearch?: boolean
showRefresh?: boolean
showSelection?: boolean
showIndex?: boolean
showActions?: boolean
showEdit?: boolean
showDelete?: boolean
actionWidth?: number | string
actionFixed?: boolean | 'left' | 'right'
selectable?: (row: any, index: number) => boolean
showPagination?: boolean
total?: number
currentPage?: number
pageSize?: number
pageSizes?: number[]
paginationLayout?: string
paginationBackground?: boolean
}
const props = withDefaults(defineProps<DataTableProps>(), {
loading: false,
stripe: false,
border: true,
rowKey: 'id',
showToolbar: true,
showAdd: true,
showBatchDelete: true,
showSearch: true,
showRefresh: true,
showSelection: false,
showIndex: false,
showActions: true,
showEdit: true,
showDelete: true,
actionWidth: 150,
actionFixed: 'right',
showPagination: true,
total: 0,
currentPage: 1,
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
paginationLayout: 'total, sizes, prev, pager, next, jumper',
paginationBackground: true
})
const emit = defineEmits<{
(e: 'update:currentPage', page: number): void
(e: 'update:pageSize', size: number): void
(e: 'add'): void
(e: 'edit', row: any, index: number): void
(e: 'delete', row: any, index: number): void
(e: 'batch-delete', selection: any[]): void
(e: 'search', keyword: string): void
(e: 'refresh'): void
(e: 'selection-change', selection: any[]): void
(e: 'sort-change', sort: { prop: string; order: string }): void
(e: 'row-click', row: any, column: any, event: Event): void
(e: 'size-change', size: number): void
(e: 'current-change', page: number): void
}>()
const tableRef = ref<InstanceType<typeof ElTable>>()
const searchKeyword = ref('')
const selection = ref<any[]>([])
const tableData = computed(() => props.data)
const hasSelection = computed(() => selection.value.length > 0)
const getIndex = (index: number) => (props.currentPage - 1) * props.pageSize + index + 1
const handleAdd = () => emit('add')
const handleEdit = (row: any, index: number) => emit('edit', row, index)
const handleDelete = (row: any, index: number) => emit('delete', row, index)
const handleBatchDelete = () => emit('batch-delete', selection.value)
const handleSearch = (keyword: string) => emit('search', keyword)
const handleRefresh = () => emit('refresh')
const handleSelectionChange = (val: any[]) => { selection.value = val; emit('selection-change', val) }
const handleSortChange = (sort: { prop: string; order: string }) => emit('sort-change', sort)
const handleRowClick = (row: any, column: any, event: Event) => emit('row-click', row, column, event)
const handleSizeChange = (size: number) => { emit('update:pageSize', size); emit('size-change', size) }
const handleCurrentChange = (page: number) => { emit('update:currentPage', page); emit('current-change', page) }
defineExpose({
clearSelection: () => tableRef.value?.clearSelection(),
toggleRowSelection: (row: any, selected?: boolean) => tableRef.value?.toggleRowSelection(row, selected),
toggleAllSelection: () => tableRef.value?.toggleAllSelection(),
sort: (prop: string, order: 'ascending' | 'descending') => tableRef.value?.sort(prop, order),
clearSort: () => tableRef.value?.clearSort()
})
</script>
<style scoped>
.data-table { background: #fff; border-radius: 4px; }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid #ebeef5; }
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; }
.table-pagination { display: flex; justify-content: flex-end; padding: 16px; border-top: 1px solid #ebeef5; }
</style>
使用建议:
- 将搜索/筛选参数、数据请求逻辑放在父组件;通过
data/total与分页双向绑定驱动渲染; - 列渲染优先使用具名插槽覆盖;操作列复用
actions插槽; - 长列表结合虚拟滚动或服务端分页。
使用示例
vue
<!-- pages/UserManagement.vue -->
<template>
<div class="user-management">
<DataTablePlus
:data="userList"
:columns="columns"
:loading="loading"
:total="total"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@add="handleAdd"
@edit="handleEdit"
@delete="handleDelete"
@search="handleSearch"
@refresh="handleRefresh"
>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
<template #actions="{ row, index }">
<el-button type="primary" link size="small" @click="handleEdit(row, index)">编辑</el-button>
<el-button type="success" link size="small" @click="handleToggleStatus(row)">{{ row.status === 1 ? '禁用' : '启用' }}</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row, index)">删除</el-button>
</template>
</DataTablePlus>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="handleDialogClose">
<!-- 可配合 BaseSelect/FormBuilder 等通用组件 -->
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import DataTablePlus from '@/components/DataTablePlus.vue'
interface User { id: number; name: string; email: string; phone: string; status: number; createTime: string }
const columns = [
{ prop: 'name', label: '姓名', width: 120 },
{ prop: 'email', label: '邮箱', minWidth: 200 },
{ prop: 'phone', label: '手机号', width: 130 },
{ prop: 'status', label: '状态', width: 100 },
{ prop: 'createTime', label: '创建时间', width: 180 }
]
const userList = ref<User[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const formData = reactive<Partial<User>>({})
const loadUserList = async () => {
loading.value = true
try {
const response = await fetchUsers({ page: currentPage.value, size: pageSize.value })
userList.value = response.data
total.value = response.total
} finally {
loading.value = false
}
}
const handleAdd = () => { dialogTitle.value = '新增用户'; isEdit.value = false; dialogVisible.value = true }
const handleEdit = (row: User) => { dialogTitle.value = '编辑用户'; isEdit.value = true; Object.assign(formData, row); dialogVisible.value = true }
const handleDelete = async (row: User) => { await deleteUser(row.id); loadUserList() }
const handleToggleStatus = async (row: User) => { await updateUserStatus(row.id, row.status === 1 ? 0 : 1); loadUserList() }
const handleSearch = () => loadUserList()
const handleRefresh = () => loadUserList()
const handleDialogClose = () => Object.assign(formData, {})
const fetchUsers = async (params: any) => new Promise<any>(r => setTimeout(() => r({
data: Array.from({ length: params.size }, (_, i) => ({
id: i + 1,
name: `用户${i + 1}`,
email: `user${i + 1}@example.com`,
phone: `138${String(i + 1).padStart(8, '0')}`,
status: i % 2,
createTime: new Date().toISOString()
})),
total: 100
}), 400))
const updateUserStatus = async () => new Promise(r => setTimeout(r, 300))
const deleteUser = async () => new Promise(r => setTimeout(r, 300))
onMounted(loadUserList)
</script>
<style scoped>
.user-management { padding: 20px; }
</style>
设计取舍:
- 不做数据请求,保持无业务侵入;
- 通过事件把交互上抛;
- 通过列插槽和操作插槽满足差异化渲染;
- 通过
defineExpose暴露选择/排序方法,支持父组件控制。
总结要点与落地建议
- 先规则再实现:单一职责、受控优先、最小API、边界清晰。
- 可扩展但不耦合:插槽/事件/样式变量优先于新增 props。
- 性能内建:
computed缓存、浅响应、懒加载、虚拟滚动、分页。 - 类型即文档:TS 明确 props/emits/slots;暴露方法可测可管。
- 二次封装库时:不改语义、补齐缺口(工具栏/分页/空态/国际化),保持与原库 API 心智对齐。
- 建议沉淀:在
components/下建立base-与biz-两层,基础可复用,业务按域聚合;配套 Story/Docs 与单测。
高性能组件封装
1. 响应式数据优化
使用 shallowRef 和 shallowReactive
vue
<template>
<div class="data-table">
<div v-for="item in displayData" :key="item.id" class="table-row">
{{ item.name }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, watchEffect } from 'vue'
interface TableItem {
id: number
name: string
status: string
// ... 其他字段
}
// ❌ 错误做法:使用 ref 包装大量数据
// const tableData = ref<TableItem[]>([])
// ✅ 正确做法:使用 shallowRef 避免深度响应式
const tableData = shallowRef<TableItem[]>([])
// 使用计算属性进行数据转换
const displayData = computed(() => {
return tableData.value.map(item => ({
id: item.id,
name: item.name,
// 只保留需要显示的数据
}))
})
// 使用 watchEffect 进行副作用处理
watchEffect(() => {
// 只在必要时更新DOM
if (tableData.value.length > 0) {
// 处理数据更新逻辑
}
})
</script>
合理使用 computed 和 watch
vue
<script setup lang="ts">
import { ref, computed, watch, watchEffect } from 'vue'
const props = defineProps<{
data: any[]
filter: string
sortBy: string
}>()
// ✅ 使用 computed 进行数据转换
const filteredData = computed(() => {
return props.data.filter(item =>
item.name.toLowerCase().includes(props.filter.toLowerCase())
)
})
const sortedData = computed(() => {
return [...filteredData.value].sort((a, b) => {
return a[props.sortBy] > b[props.sortBy] ? 1 : -1
})
})
// ✅ 使用 watch 监听特定变化
watch(
() => props.filter,
(newFilter, oldFilter) => {
// 只在过滤条件真正改变时执行
if (newFilter !== oldFilter) {
console.log('Filter changed:', newFilter)
}
}
)
// ❌ 避免在模板中直接使用复杂计算
// <div v-for="item in data.filter(...).sort(...)" :key="item.id">
</script>
2. 组件懒加载和代码分割
typescript
// components/LazyTable.vue
<template>
<div class="lazy-table">
<Suspense>
<template #default>
<AsyncTableContent :data="data" />
</template>
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// 异步组件,实现代码分割
const AsyncTableContent = defineAsyncComponent({
loader: () => import('./TableContent.vue'),
loadingComponent: () => import('./TableLoading.vue'),
errorComponent: () => import('./TableError.vue'),
delay: 200,
timeout: 3000
})
defineProps<{
data: any[]
}>()
</script>
3. 虚拟滚动优化
vue
<template>
<div class="virtual-list" ref="containerRef" @scroll="handleScroll">
<div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface VirtualItem {
id: number
content: string
}
const props = defineProps<{
items: VirtualItem[]
itemHeight: number
containerHeight: number
}>()
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
// 计算可见区域
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight) + 2
)
const startIndex = computed(() =>
Math.floor(scrollTop.value / props.itemHeight)
)
const endIndex = computed(() =>
Math.min(startIndex.value + visibleCount.value, props.items.length)
)
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
)
const totalHeight = computed(() =>
props.items.length * props.itemHeight
)
const offsetY = computed(() =>
startIndex.value * props.itemHeight
)
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
// 防抖处理
let scrollTimer: number | null = null
const debouncedScroll = (e: Event) => {
if (scrollTimer) clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => handleScroll(e), 16) // 60fps
}
onMounted(() => {
containerRef.value?.addEventListener('scroll', debouncedScroll)
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', debouncedScroll)
})
</script>
<style scoped>
.virtual-list {
height: 400px;
overflow-y: auto;
position: relative;
}
.virtual-list-phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-list-item {
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #eee;
}
</style>
高维护性组件封装
1. TypeScript 类型定义
typescript
// types/component.ts
export interface BaseComponentProps {
id?: string
class?: string | string[] | Record<string, boolean>
style?: string | Record<string, any>
disabled?: boolean
loading?: boolean
}
export interface TableColumn {
prop: string
label: string
width?: number | string
minWidth?: number | string
fixed?: boolean | 'left' | 'right'
sortable?: boolean
formatter?: (row: any, column: TableColumn, cellValue: any) => string
render?: (row: any, column: TableColumn, cellValue: any) => VNode
}
export interface TableProps extends BaseComponentProps {
data: any[]
columns: TableColumn[]
height?: number | string
maxHeight?: number | string
stripe?: boolean
border?: boolean
showHeader?: boolean
highlightCurrentRow?: boolean
emptyText?: string
loading?: boolean
loadingText?: string
}
export interface TableEmits {
(e: 'selection-change', selection: any[]): void
(e: 'row-click', row: any, column: TableColumn, event: Event): void
(e: 'sort-change', { column, prop, order }: { column: TableColumn, prop: string, order: string }): void
(e: 'update:loading', loading: boolean): void
}
2. 组件 API 设计
vue
<!-- components/BaseTable.vue -->
<template>
<div
:id="props.id"
:class="tableClasses"
:style="props.style"
>
<div v-if="props.loading" class="table-loading">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>{{ props.loadingText }}</span>
</div>
<div v-else-if="isEmpty" class="table-empty">
{{ props.emptyText }}
</div>
<div v-else class="table-content">
<table class="base-table">
<thead v-if="props.showHeader">
<tr>
<th
v-for="column in props.columns"
:key="column.prop"
:style="getColumnStyle(column)"
:class="getColumnClass(column)"
>
<div class="cell" @click="handleSort(column)">
{{ column.label }}
<el-icon v-if="column.sortable" class="sort-icon">
<Sort />
</el-icon>
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in props.data"
:key="getRowKey(row, rowIndex)"
:class="getRowClass(row, rowIndex)"
@click="handleRowClick(row, $event)"
>
<td
v-for="column in props.columns"
:key="column.prop"
:style="getColumnStyle(column)"
:class="getCellClass(row, column)"
>
<div class="cell">
<component
:is="column.render"
v-if="column.render"
:row="row"
:column="column"
:value="getCellValue(row, column)"
/>
<span v-else>{{ formatCellValue(row, column) }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRefs } from 'vue'
import { Loading, Sort } from '@element-plus/icons-vue'
import type { TableProps, TableEmits, TableColumn } from '../types/component'
const props = withDefaults(defineProps<TableProps>(), {
showHeader: true,
stripe: false,
border: false,
highlightCurrentRow: false,
emptyText: '暂无数据',
loadingText: '加载中...',
loading: false
})
const emit = defineEmits<TableEmits>()
// 计算属性
const tableClasses = computed(() => [
'base-table-wrapper',
{
'is-striped': props.stripe,
'is-bordered': props.border,
'is-loading': props.loading,
'is-disabled': props.disabled
},
props.class
])
const isEmpty = computed(() => !props.data || props.data.length === 0)
// 方法
const getRowKey = (row: any, index: number) => {
return row.id || row.key || index
}
const getRowClass = (row: any, index: number) => {
return {
'table-row': true,
'is-striped': props.stripe && index % 2 === 1,
'is-current': props.highlightCurrentRow && currentRow.value === row
}
}
const getColumnStyle = (column: TableColumn) => {
const style: Record<string, any> = {}
if (column.width) style.width = typeof column.width === 'number' ? `${column.width}px` : column.width
if (column.minWidth) style.minWidth = typeof column.minWidth === 'number' ? `${column.minWidth}px` : column.minWidth
return style
}
const getColumnClass = (column: TableColumn) => {
return {
'table-column': true,
'is-sortable': column.sortable,
'is-fixed': !!column.fixed,
'is-fixed-left': column.fixed === 'left',
'is-fixed-right': column.fixed === 'right'
}
}
const getCellClass = (row: any, column: TableColumn) => {
return {
'table-cell': true,
'is-fixed': !!column.fixed,
'is-fixed-left': column.fixed === 'left',
'is-fixed-right': column.fixed === 'right'
}
}
const getCellValue = (row: any, column: TableColumn) => {
return row[column.prop]
}
const formatCellValue = (row: any, column: TableColumn) => {
const value = getCellValue(row, column)
return column.formatter ? column.formatter(row, column, value) : value
}
const handleSort = (column: TableColumn) => {
if (!column.sortable) return
// 排序逻辑
emit('sort-change', { column, prop: column.prop, order: 'ascending' })
}
const handleRowClick = (row: any, event: Event) => {
if (props.disabled) return
emit('row-click', row, {} as TableColumn, event)
}
// 当前行状态
const currentRow = ref<any>(null)
</script>
<style scoped>
.base-table-wrapper {
position: relative;
overflow: hidden;
}
.base-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
.table-loading,
.table-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
.table-row {
transition: background-color 0.25s;
}
.table-row:hover {
background-color: #f5f7fa;
}
.table-row.is-current {
background-color: #ecf5ff;
}
.table-row.is-striped {
background-color: #fafafa;
}
.table-column {
background-color: #fafafa;
color: #909399;
font-weight: 500;
text-align: left;
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
}
.cell {
padding: 0 10px;
display: flex;
align-items: center;
min-height: 32px;
}
.sort-icon {
margin-left: 4px;
cursor: pointer;
}
.table-cell {
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
}
.is-bordered .table-cell {
border-right: 1px solid #ebeef5;
}
.is-bordered .table-column {
border-right: 1px solid #ebeef5;
}
</style>
3. 组件状态管理
typescript
// composables/useTableState.ts
import { ref, computed, watch } from 'vue'
export interface TableState {
currentPage: number
pageSize: number
total: number
loading: boolean
selection: any[]
sort: {
prop: string
order: 'ascending' | 'descending' | null
}
filter: Record<string, any>
}
export function useTableState(initialState?: Partial<TableState>) {
const state = ref<TableState>({
currentPage: 1,
pageSize: 10,
total: 0,
loading: false,
selection: [],
sort: {
prop: '',
order: null
},
filter: {},
...initialState
})
// 分页相关
const pagination = computed(() => ({
currentPage: state.value.currentPage,
pageSize: state.value.pageSize,
total: state.value.total,
pageCount: Math.ceil(state.value.total / state.value.pageSize)
}))
// 选择相关
const hasSelection = computed(() => state.value.selection.length > 0)
const selectionCount = computed(() => state.value.selection.length)
// 方法
const setCurrentPage = (page: number) => {
state.value.currentPage = page
}
const setPageSize = (size: number) => {
state.value.pageSize = size
state.value.currentPage = 1 // 重置到第一页
}
const setTotal = (total: number) => {
state.value.total = total
}
const setLoading = (loading: boolean) => {
state.value.loading = loading
}
const setSelection = (selection: any[]) => {
state.value.selection = selection
}
const clearSelection = () => {
state.value.selection = []
}
const setSort = (prop: string, order: 'ascending' | 'descending' | null) => {
state.value.sort = { prop, order }
}
const setFilter = (filter: Record<string, any>) => {
state.value.filter = { ...state.value.filter, ...filter }
}
const clearFilter = () => {
state.value.filter = {}
}
const reset = () => {
state.value = {
currentPage: 1,
pageSize: 10,
total: 0,
loading: false,
selection: [],
sort: { prop: '', order: null },
filter: {}
}
}
return {
state: readonly(state),
pagination,
hasSelection,
selectionCount,
setCurrentPage,
setPageSize,
setTotal,
setLoading,
setSelection,
clearSelection,
setSort,
setFilter,
clearFilter,
reset
}
}
ElementPlus 二次封装最佳实践
1. 表单组件封装
vue
<!-- components/FormBuilder.vue -->
<template>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
:label-width="labelWidth"
:label-position="labelPosition"
v-bind="$attrs"
@submit.prevent
>
<el-row :gutter="gutter">
<el-col
v-for="field in fields"
:key="field.prop"
:span="field.span || 24"
:offset="field.offset || 0"
>
<el-form-item
:label="field.label"
:prop="field.prop"
:required="field.required"
:rules="field.rules"
>
<component
:is="getFieldComponent(field)"
v-model="formData[field.prop]"
v-bind="getFieldProps(field)"
@change="handleFieldChange(field, $event)"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="showActions" class="form-actions">
<el-button
v-if="showReset"
@click="handleReset"
:disabled="loading"
>
重置
</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="loading"
>
{{ submitText }}
</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
interface FormField {
prop: string
label: string
type: 'input' | 'select' | 'date' | 'number' | 'textarea' | 'switch' | 'radio' | 'checkbox'
component?: string
span?: number
offset?: number
required?: boolean
rules?: FormRules[string]
options?: Array<{ label: string; value: any; disabled?: boolean }>
placeholder?: string
disabled?: boolean
readonly?: boolean
[key: string]: any
}
interface FormBuilderProps {
fields: FormField[]
modelValue: Record<string, any>
rules?: FormRules
labelWidth?: string
labelPosition?: 'left' | 'right' | 'top'
gutter?: number
showActions?: boolean
showReset?: boolean
submitText?: string
loading?: boolean
}
const props = withDefaults(defineProps<FormBuilderProps>(), {
labelWidth: '120px',
labelPosition: 'right',
gutter: 20,
showActions: true,
showReset: true,
submitText: '提交',
loading: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'submit', data: Record<string, any>): void
(e: 'reset'): void
(e: 'field-change', field: FormField, value: any): void
}>()
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({ ...props.modelValue })
// 监听外部数据变化
watch(
() => props.modelValue,
(newValue) => {
Object.assign(formData, newValue)
},
{ deep: true }
)
// 监听内部数据变化
watch(
formData,
(newValue) => {
emit('update:modelValue', { ...newValue })
},
{ deep: true }
)
// 获取字段组件
const getFieldComponent = (field: FormField) => {
const componentMap = {
input: 'el-input',
select: 'el-select',
date: 'el-date-picker',
number: 'el-input-number',
textarea: 'el-input',
switch: 'el-switch',
radio: 'el-radio-group',
checkbox: 'el-checkbox-group'
}
return field.component || componentMap[field.type] || 'el-input'
}
// 获取字段属性
const getFieldProps = (field: FormField) => {
const baseProps = {
placeholder: field.placeholder,
disabled: field.disabled,
readonly: field.readonly,
clearable: true
}
switch (field.type) {
case 'select':
return {
...baseProps,
options: field.options,
filterable: true
}
case 'date':
return {
...baseProps,
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD'
}
case 'textarea':
return {
...baseProps,
type: 'textarea',
rows: 3
}
case 'radio':
return {
...baseProps,
options: field.options
}
case 'checkbox':
return {
...baseProps,
options: field.options
}
default:
return baseProps
}
}
// 字段变化处理
const handleFieldChange = (field: FormField, value: any) => {
emit('field-change', field, value)
}
// 提交处理
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('submit', { ...formData })
} catch (error) {
console.error('Form validation failed:', error)
}
}
// 重置处理
const handleReset = () => {
formRef.value?.resetFields()
emit('reset')
}
// 验证表单
const validate = () => {
return formRef.value?.validate()
}
// 清除验证
const clearValidate = () => {
formRef.value?.clearValidate()
}
// 暴露方法
defineExpose({
validate,
clearValidate,
resetFields: handleReset
})
</script>
<style scoped>
.form-actions {
text-align: right;
margin-top: 20px;
}
.form-actions .el-button + .el-button {
margin-left: 10px;
}
</style>
2. 表格组件封装
vue
<!-- components/DataTable.vue -->
<template>
<div class="data-table">
<!-- 工具栏 -->
<div v-if="showToolbar" class="table-toolbar">
<div class="toolbar-left">
<slot name="toolbar-left">
<el-button
v-if="showAdd"
type="primary"
:icon="Plus"
@click="handleAdd"
>
新增
</el-button>
<el-button
v-if="showBatchDelete && hasSelection"
type="danger"
:icon="Delete"
@click="handleBatchDelete"
>
批量删除
</el-button>
</slot>
</div>
<div class="toolbar-right">
<slot name="toolbar-right">
<el-input
v-if="showSearch"
v-model="searchKeyword"
placeholder="请输入搜索关键词"
:style="{ width: '200px' }"
clearable
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
v-if="showRefresh"
:icon="Refresh"
circle
@click="handleRefresh"
/>
</slot>
</div>
</div>
<!-- 表格 -->
<el-table
ref="tableRef"
:data="tableData"
:loading="loading"
:height="height"
:max-height="maxHeight"
:stripe="stripe"
:border="border"
:row-key="rowKey"
:default-sort="defaultSort"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
@row-click="handleRowClick"
v-bind="$attrs"
>
<!-- 选择列 -->
<el-table-column
v-if="showSelection"
type="selection"
width="55"
:selectable="selectable"
/>
<!-- 序号列 -->
<el-table-column
v-if="showIndex"
type="index"
label="序号"
width="80"
:index="getIndex"
/>
<!-- 数据列 -->
<el-table-column
v-for="column in columns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
:fixed="column.fixed"
:sortable="column.sortable"
:show-overflow-tooltip="column.showOverflowTooltip !== false"
:align="column.align || 'left'"
:header-align="column.headerAlign || column.align || 'left'"
>
<template #default="{ row, column: col, $index }">
<slot
:name="column.prop"
:row="row"
:column="col"
:index="$index"
:value="row[column.prop]"
>
<span v-if="column.formatter">
{{ column.formatter(row, col, row[column.prop], $index) }}
</span>
<span v-else>{{ row[column.prop] }}</span>
</slot>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column
v-if="showActions"
label="操作"
:width="actionWidth"
:fixed="actionFixed"
align="center"
>
<template #default="{ row, $index }">
<slot name="actions" :row="row" :index="$index">
<el-button
v-if="showEdit"
type="primary"
link
size="small"
@click="handleEdit(row, $index)"
>
编辑
</el-button>
<el-button
v-if="showDelete"
type="danger"
link
size="small"
@click="handleDelete(row, $index)"
>
删除
</el-button>
</slot>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div v-if="showPagination" class="table-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="pageSizes"
:layout="paginationLayout"
:background="paginationBackground"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ElTable, ElTableColumn, ElPagination, ElButton, ElInput } from 'element-plus'
import { Plus, Delete, Search, Refresh } from '@element-plus/icons-vue'
interface TableColumn {
prop: string
label: string
width?: number | string
minWidth?: number | string
fixed?: boolean | 'left' | 'right'
sortable?: boolean | 'custom'
align?: 'left' | 'center' | 'right'
headerAlign?: 'left' | 'center' | 'right'
showOverflowTooltip?: boolean
formatter?: (row: any, column: any, cellValue: any, index: number) => string
}
interface DataTableProps {
data: any[]
columns: TableColumn[]
loading?: boolean
height?: number | string
maxHeight?: number | string
stripe?: boolean
border?: boolean
rowKey?: string | ((row: any) => string | number)
defaultSort?: { prop: string; order: 'ascending' | 'descending' }
// 工具栏
showToolbar?: boolean
showAdd?: boolean
showBatchDelete?: boolean
showSearch?: boolean
showRefresh?: boolean
// 表格
showSelection?: boolean
showIndex?: boolean
showActions?: boolean
showEdit?: boolean
showDelete?: boolean
actionWidth?: number | string
actionFixed?: boolean | 'left' | 'right'
selectable?: (row: any, index: number) => boolean
// 分页
showPagination?: boolean
total?: number
currentPage?: number
pageSize?: number
pageSizes?: number[]
paginationLayout?: string
paginationBackground?: boolean
}
const props = withDefaults(defineProps<DataTableProps>(), {
loading: false,
stripe: false,
border: true,
rowKey: 'id',
showToolbar: true,
showAdd: true,
showBatchDelete: true,
showSearch: true,
showRefresh: true,
showSelection: false,
showIndex: false,
showActions: true,
showEdit: true,
showDelete: true,
actionWidth: 150,
actionFixed: 'right',
showPagination: true,
total: 0,
currentPage: 1,
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
paginationLayout: 'total, sizes, prev, pager, next, jumper',
paginationBackground: true
})
const emit = defineEmits<{
(e: 'update:currentPage', page: number): void
(e: 'update:pageSize', size: number): void
(e: 'add'): void
(e: 'edit', row: any, index: number): void
(e: 'delete', row: any, index: number): void
(e: 'batch-delete', selection: any[]): void
(e: 'search', keyword: string): void
(e: 'refresh'): void
(e: 'selection-change', selection: any[]): void
(e: 'sort-change', sort: { prop: string; order: string }): void
(e: 'row-click', row: any, column: any, event: Event): void
(e: 'size-change', size: number): void
(e: 'current-change', page: number): void
}>()
const tableRef = ref<InstanceType<typeof ElTable>>()
const searchKeyword = ref('')
// 计算属性
const tableData = computed(() => props.data)
const hasSelection = computed(() => selection.value.length > 0)
// 状态
const selection = ref<any[]>([])
// 方法
const getIndex = (index: number) => {
return (props.currentPage - 1) * props.pageSize + index + 1
}
const handleAdd = () => {
emit('add')
}
const handleEdit = (row: any, index: number) => {
emit('edit', row, index)
}
const handleDelete = (row: any, index: number) => {
emit('delete', row, index)
}
const handleBatchDelete = () => {
emit('batch-delete', selection.value)
}
const handleSearch = (keyword: string) => {
emit('search', keyword)
}
const handleRefresh = () => {
emit('refresh')
}
const handleSelectionChange = (selection: any[]) => {
selection.value = selection
emit('selection-change', selection)
}
const handleSortChange = (sort: { prop: string; order: string }) => {
emit('sort-change', sort)
}
const handleRowClick = (row: any, column: any, event: Event) => {
emit('row-click', row, column, event)
}
const handleSizeChange = (size: number) => {
emit('update:pageSize', size)
emit('size-change', size)
}
const handleCurrentChange = (page: number) => {
emit('update:currentPage', page)
emit('current-change', page)
}
// 暴露方法
const clearSelection = () => {
tableRef.value?.clearSelection()
}
const toggleRowSelection = (row: any, selected?: boolean) => {
tableRef.value?.toggleRowSelection(row, selected)
}
const toggleAllSelection = () => {
tableRef.value?.toggleAllSelection()
}
const sort = (prop: string, order: 'ascending' | 'descending') => {
tableRef.value?.sort(prop, order)
}
const clearSort = () => {
tableRef.value?.clearSort()
}
defineExpose({
clearSelection,
toggleRowSelection,
toggleAllSelection,
sort,
clearSort
})
</script>
<style scoped>
.data-table {
background: #fff;
border-radius: 4px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #ebeef5;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.table-pagination {
display: flex;
justify-content: flex-end;
padding: 16px;
border-top: 1px solid #ebeef5;
}
</style>
3. 使用示例
vue
<!-- pages/UserManagement.vue -->
<template>
<div class="user-management">
<DataTable
:data="userList"
:columns="columns"
:loading="loading"
:total="total"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@add="handleAdd"
@edit="handleEdit"
@delete="handleDelete"
@search="handleSearch"
@refresh="handleRefresh"
>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
<template #actions="{ row, index }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row, index)"
>
编辑
</el-button>
<el-button
type="success"
link
size="small"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row, index)"
>
删除
</el-button>
</template>
</DataTable>
<!-- 用户表单弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<FormBuilder
v-model="formData"
:fields="formFields"
:rules="formRules"
@submit="handleSubmit"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import DataTable from '@/components/DataTable.vue'
import FormBuilder from '@/components/FormBuilder.vue'
interface User {
id: number
name: string
email: string
phone: string
status: number
createTime: string
}
// 表格列配置
const columns = [
{ prop: 'name', label: '姓名', width: 120 },
{ prop: 'email', label: '邮箱', minWidth: 200 },
{ prop: 'phone', label: '手机号', width: 130 },
{ prop: 'status', label: '状态', width: 100 },
{ prop: 'createTime', label: '创建时间', width: 180 }
]
// 表单字段配置
const formFields = [
{
prop: 'name',
label: '姓名',
type: 'input',
required: true,
rules: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
]
},
{
prop: 'email',
label: '邮箱',
type: 'input',
required: true,
rules: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
]
},
{
prop: 'phone',
label: '手机号',
type: 'input',
required: true,
rules: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
]
},
{
prop: 'status',
label: '状态',
type: 'radio',
required: true,
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 }
]
}
]
// 数据状态
const userList = ref<User[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 弹窗状态
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formData = reactive<Partial<User>>({})
const isEdit = ref(false)
// 计算属性
const formRules = computed(() => {
const rules: Record<string, any> = {}
formFields.forEach(field => {
if (field.rules) {
rules[field.prop] = field.rules
}
})
return rules
})
// 方法
const loadUserList = async () => {
loading.value = true
try {
// 模拟API调用
const response = await fetchUsers({
page: currentPage.value,
size: pageSize.value
})
userList.value = response.data
total.value = response.total
} catch (error) {
console.error('加载用户列表失败:', error)
} finally {
loading.value = false
}
}
const handleAdd = () => {
dialogTitle.value = '新增用户'
isEdit.value = false
Object.assign(formData, {
name: '',
email: '',
phone: '',
status: 1
})
dialogVisible.value = true
}
const handleEdit = (row: User) => {
dialogTitle.value = '编辑用户'
isEdit.value = true
Object.assign(formData, { ...row })
dialogVisible.value = true
}
const handleDelete = async (row: User) => {
try {
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// 模拟API调用
await deleteUser(row.id)
ElMessage.success('删除成功')
loadUserList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleToggleStatus = async (row: User) => {
try {
const newStatus = row.status === 1 ? 0 : 1
await updateUserStatus(row.id, newStatus)
ElMessage.success('状态更新成功')
loadUserList()
} catch (error) {
ElMessage.error('状态更新失败')
}
}
const handleSearch = (keyword: string) => {
// 实现搜索逻辑
console.log('搜索关键词:', keyword)
loadUserList()
}
const handleRefresh = () => {
loadUserList()
}
const handleSubmit = async (data: Partial<User>) => {
try {
if (isEdit.value) {
await updateUser(data.id!, data)
ElMessage.success('更新成功')
} else {
await createUser(data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadUserList()
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
}
}
const handleDialogClose = () => {
Object.assign(formData, {})
}
// 模拟API函数
const fetchUsers = async (params: any) => {
// 模拟API调用
return new Promise(resolve => {
setTimeout(() => {
resolve({
data: Array.from({ length: params.size }, (_, i) => ({
id: i + 1,
name: `用户${i + 1}`,
email: `user${i + 1}@example.com`,
phone: `138${String(i + 1).padStart(8, '0')}`,
status: i % 2,
createTime: new Date().toISOString()
})),
total: 100
})
}, 1000)
})
}
const createUser = async (data: Partial<User>) => {
// 模拟API调用
return new Promise(resolve => setTimeout(resolve, 1000))
}
const updateUser = async (id: number, data: Partial<User>) => {
// 模拟API调用
return new Promise(resolve => setTimeout(resolve, 1000))
}
const updateUserStatus = async (id: number, status: number) => {
// 模拟API调用
return new Promise(resolve => setTimeout(resolve, 1000))
}
const deleteUser = async (id: number) => {
// 模拟API调用
return new Promise(resolve => setTimeout(resolve, 1000))
}
onMounted(() => {
loadUserList()
})
</script>
<style scoped>
.user-management {
padding: 20px;
}
</style>
组件设计原则
1. 单一职责原则
每个组件应该只负责一个功能或一个UI区域:
vue
<!-- ❌ 错误:组件职责过多 -->
<template>
<div class="user-management">
<!-- 用户列表 -->
<el-table>...</el-table>
<!-- 用户表单 -->
<el-form>...</el-form>
<!-- 权限管理 -->
<el-tree>...</el-tree>
</div>
</template>
<!-- ✅ 正确:职责分离 -->
<template>
<div class="user-management">
<UserList @edit="handleEdit" />
<UserForm v-model:visible="formVisible" :user="currentUser" />
<UserPermission v-model:visible="permissionVisible" :user="currentUser" />
</div>
</template>
2. 开放封闭原则
组件应该对扩展开放,对修改封闭:
vue
<template>
<div class="base-button" :class="buttonClasses">
<el-button
v-bind="$attrs"
:type="computedType"
:size="computedSize"
:disabled="disabled || loading"
@click="handleClick"
>
<el-icon v-if="loading" class="is-loading">
<Loading />
</el-icon>
<el-icon v-else-if="icon" :class="iconClass">
<component :is="icon" />
</el-icon>
<span v-if="$slots.default">
<slot />
</span>
</el-button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ButtonProps {
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'text'
size?: 'large' | 'default' | 'small'
icon?: string
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'default',
size: 'default',
loading: false,
disabled: false
})
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
// 计算属性
const buttonClasses = computed(() => [
'base-button',
`base-button--${props.type}`,
`base-button--${props.size}`
])
const computedType = computed(() => props.type)
const computedSize = computed(() => props.size)
const iconClass = computed(() => ({
'button-icon': true,
'button-icon--left': true
}))
// 方法
const handleClick = (event: MouseEvent) => {
if (props.disabled || props.loading) return
emit('click', event)
}
</script>
3. 依赖倒置原则
组件应该依赖抽象而不是具体实现:
typescript
// 抽象接口
interface DataProvider {
fetchData(params: any): Promise<any>
createData(data: any): Promise<any>
updateData(id: string, data: any): Promise<any>
deleteData(id: string): Promise<any>
}
// 具体实现
class ApiDataProvider implements DataProvider {
async fetchData(params: any) {
const response = await fetch('/api/data', {
method: 'GET',
body: JSON.stringify(params)
})
return response.json()
}
async createData(data: any) {
const response = await fetch('/api/data', {
method: 'POST',
body: JSON.stringify(data)
})
return response.json()
}
// ... 其他方法
}
// 组件使用
const useDataTable = (provider: DataProvider) => {
const data = ref([])
const loading = ref(false)
const loadData = async (params: any) => {
loading.value = true
try {
data.value = await provider.fetchData(params)
} finally {
loading.value = false
}
}
return {
data: readonly(data),
loading: readonly(loading),
loadData
}
}
性能优化策略
1. 组件懒加载
typescript
// 路由懒加载
const routes = [
{
path: '/users',
component: () => import('@/views/UserManagement.vue')
}
]
// 组件懒加载
const LazyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
2. 虚拟滚动
vue
<template>
<div class="virtual-scroll" ref="containerRef">
<div class="virtual-scroll-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-scroll-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface VirtualScrollProps {
items: any[]
itemHeight: number
containerHeight: number
overscan?: number
}
const props = withDefaults(defineProps<VirtualScrollProps>(), {
overscan: 5
})
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight) + props.overscan
)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - Math.floor(props.overscan / 2))
)
const endIndex = computed(() =>
Math.min(props.items.length, startIndex.value + visibleCount.value)
)
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value).map((item, index) => ({
...item,
index: startIndex.value + index
}))
)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll)
})
</script>
3. 防抖和节流
typescript
// 防抖
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
// 节流
export function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let lastTime = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - lastTime >= wait) {
lastTime = now
func(...args)
}
}
}
// 在组件中使用
const debouncedSearch = debounce((keyword: string) => {
// 搜索逻辑
}, 300)
const throttledScroll = throttle((event: Event) => {
// 滚动处理逻辑
}, 16) // 60fps
测试与文档
1. 单元测试
typescript
// tests/components/BaseTable.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import BaseTable from '@/components/BaseTable.vue'
describe('BaseTable', () => {
const mockData = [
{ id: 1, name: 'John', age: 30 },
{ id: 2, name: 'Jane', age: 25 }
]
const mockColumns = [
{ prop: 'name', label: '姓名' },
{ prop: 'age', label: '年龄' }
]
it('renders table with data', () => {
const wrapper = mount(BaseTable, {
props: {
data: mockData,
columns: mockColumns
}
})
expect(wrapper.find('.base-table').exists()).toBe(true)
expect(wrapper.findAll('.table-row')).toHaveLength(2)
})
it('emits row-click event', async () => {
const wrapper = mount(BaseTable, {
props: {
data: mockData,
columns: mockColumns
}
})
await wrapper.find('.table-row').trigger('click')
expect(wrapper.emitted('row-click')).toBeTruthy()
})
it('shows loading state', () => {
const wrapper = mount(BaseTable, {
props: {
data: mockData,
columns: mockColumns,
loading: true
}
})
expect(wrapper.find('.table-loading').exists()).toBe(true)
})
})
2. 组件文档
vue
<!-- components/BaseTable.vue -->
<template>
<!-- 组件模板 -->
</template>
<script setup lang="ts">
/**
* 基础表格组件
*
* @description 基于ElementPlus封装的表格组件,提供数据展示、排序、筛选等功能
* @author Your Name
* @since 1.0.0
*/
interface TableProps {
/** 表格数据 */
data: any[]
/** 表格列配置 */
columns: TableColumn[]
/** 表格高度 */
height?: number | string
/** 是否显示斑马纹 */
stripe?: boolean
/** 是否显示边框 */
border?: boolean
/** 是否显示表头 */
showHeader?: boolean
/** 是否高亮当前行 */
highlightCurrentRow?: boolean
/** 空数据时显示的文本 */
emptyText?: string
/** 是否显示加载状态 */
loading?: boolean
/** 加载状态文本 */
loadingText?: string
}
interface TableEmits {
/** 行点击事件 */
(e: 'row-click', row: any, column: TableColumn, event: Event): void
/** 排序变化事件 */
(e: 'sort-change', { column, prop, order }: { column: TableColumn, prop: string, order: string }): void
/** 加载状态变化事件 */
(e: 'update:loading', loading: boolean): void
}
// 组件实现...
</script>
总结下
封装高质量Vue3组件需要考虑多个维度:
- 性能优化:合理使用响应式API、虚拟滚动、懒加载等技术
- 维护性:清晰的类型定义、良好的代码结构、完善的文档
- 可用性:直观的API设计、丰富的配置选项、良好的错误处理
- 可扩展性:遵循设计原则、提供插槽和事件、支持主题定制
- 测试覆盖:单元测试、集成测试、端到端测试
扩展:组件目录结构与命名规范
1) 通用 + 业务分层目录
bash
src/
components/
base/ # 通用基础组件(无业务,跨项目可复用)
BaseButton/
BaseButton.vue
index.ts # 导出与按需引入
BaseButton.test.ts
BaseButton.stories.vue
README.md
BaseSelect/
BaseSelect.vue
useBaseSelect.ts # 仅服务该组件的组合式函数
index.ts
compose/ # 组合型组件(由多个 base 组合而成)
DataTablePlus/
DataTablePlus.vue
useTableState.ts
index.ts
biz/ # 业务域组件(与具体领域绑定,可被多个页面复用)
user/
UserForm/
UserForm.vue
index.ts
UserTable/
UserTable.vue
index.ts
pages/
user/
UserManagement.vue # 页面(装配业务组件与数据)
composables/ # 跨组件的通用组合式函数(与 UI 解耦)
styles/ # 设计令牌、主题变量、全局样式
types/ # 跨组件共享的类型定义
设计要点:
- 基础组件放在
components/base,仅做通用交互与展示;不引入业务 API。 - 组合组件放在
components/compose,对通用能力进行组合编排(如工具栏+表格+分页)。 - 业务域组件放在
components/biz/<domain>,与领域模型、表单字段、策略等绑定。 - 跨组件复用的逻辑放
composables/;仅服务单组件的逻辑与该组件同目录共存。
2) 单组件目录模板(推荐)
bash
components/base/BaseXxx/
BaseXxx.vue # 组件主体
useBaseXxx.ts # 仅服务该组件的逻辑(可选)
index.ts # 统一导出
BaseXxx.test.ts # 单元测试(可选)
BaseXxx.stories.vue # 演示/文档(可选)
README.md # 使用文档(可选)
示例 index.ts:
ts
import BaseXxx from './BaseXxx.vue'
export { BaseXxx }
export default BaseXxx
3) 命名规范
- 组件命名前缀:
Base*表示通用基础组件(无业务);*Plus表示在三方组件之上做的增强组合;- 业务组件按域分组,文件夹使用小写短横线或驼峰,组件名使用大驼峰:
UserForm,user/。
- 文件命名:
- 组件文件与目录同名:
BaseSelect/BaseSelect.vue; - 导出文件统一
index.ts; - 组合式函数以
useXxx.ts命名; - 类型文件以
*.d.ts或集中在types/。
- 组件文件与目录同名:
- 样式:
- 优先局部样式
scoped/CSS Modules; - 全局设计令牌放
styles/tokens.scss,组件通过 CSS 变量消费。
- 优先局部样式
4) 文档与示例共存
- 每个通用/组合组件建议提供最小可运行示例:
.stories.vue或README.md中嵌入代码片段; - 关键 props/slots/emits 在 README 顶部声明表格;
- 破坏性变更在
CHANGELOG.md标注并同步到 README。
5) 测试与可观测
- 优先为通用/组合组件补单测:渲染、交互、事件、边界;
- 将可观测点(关键 emit、错误分支)稳定化,便于测试与排障;
- 大型组件拆分:将复杂状态抽到同目录下的
useXxx.ts,降低单文件复杂度。
6) 按需导出与入口聚合(可选)
ts
// components/index.ts - 聚合所有对外导出的组件
export * from './base/BaseButton'
export * from './base/BaseSelect'
export * from './compose/DataTablePlus'
export * from './biz/user/UserForm'
export * from './biz/user/UserTable'
结合构建工具(如 unplugin-vue-components)可实现自动按需加载,但仍建议保留显式 index.ts 以增强可读性与类型提示。