ProTable 表格组件
基于 element-plus 的 el-table 二次封装,内置列配置化、单选/多选、默认选中、分页合并、高度自适应视口、tooltip 滚动熔断等常用能力。未在 Props 中声明的属性和事件全部透传给底层 el-table。
组件名:
ProTable底层:
el-table+el-pagination适用场景:中后台普通列表页,数据量在千行以内、不需要虚拟滚动。
1. 最小示例
vue
<template>
<ProTable
:data="list"
:columns="columns"
:total="total"
v-model:page="page"
v-model:size="size"
@pagination-change="loadList"
@selection-change="onSelectionChange"
>
<!-- 自定义列插槽:列配置里写 slot: 'op' 即可 -->
<template #op="{ row }">
<el-button link type="primary" @click="edit(row)">编辑</el-button>
</template>
</ProTable>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ProTable from './ProTable.vue'
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const columns = [
{ prop: 'code', label: '编号', width: 180 },
{ prop: 'name', label: '名称', minWidth: 120 },
{ prop: 'status', label: '状态', width: 90, formatter: (row) => row.status === 1 ? '启用' : '停用' },
{ prop: 'price', label: '单价', width: 110, align: 'right', sortable: true },
{ prop: 'op', label: '操作', width: 140, slot: 'op', showOverflowTooltip: false },
]
const loadList = async () => {
// 用 page / size 请求接口,回填 list / total
}
onMounted(loadList)
const onSelectionChange = (rows) => { console.log('当前选中行:', rows) }
</script>
2. Props
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
data |
Array |
[] |
表格数据 |
columns |
Array |
[] |
列配置,见下表 |
rowKey |
String |
'id' |
行主键字段,单选/多选/跨页保留/默认选中都依赖它 |
border |
Boolean |
false |
是否显示表格边框 |
resizable |
Boolean |
false |
列宽是否可拖拽(开启时自动附带边框) |
height |
Number / String |
'adaptive' |
'adaptive' 自动撑满到视口底部;'auto' 随内容撑开;数字/'600'/'600px' 固定高度透传 el-table |
bottomOffset |
Number |
5 |
adaptive 模式下底部预留高度(px),表格下方有固定按钮栏时传入其高度 |
minHeight |
Number |
200 |
adaptive 模式下表格最小高度(px) |
selectionType |
'radio' / 'checkbox' / 'none' |
'none' |
选择列类型;radio 点击整行即可选中 |
defaultSelectedKeys |
Array |
[] |
默认选中行的主键数组,仅在首次数据到达时应用一次 |
disabledRow |
(row) => Boolean |
() => false |
返回 true 的行不可勾选/单选,且 radio 模式点击行会回退原选中行 |
page |
Number |
1 |
当前页码,支持 v-model:page |
size |
Number |
20 |
每页条数,支持 v-model:size |
total |
Number |
0 |
数据总条数 |
showPagination |
Boolean |
true |
是否显示分页 |
paginationOptions |
Object |
{} |
透传 el-pagination,如 { layout, pageSizes, background, pager-count } |
columns 列配置项
| 字段 | 类型 | 说明 |
|---|---|---|
prop |
String |
字段名 |
label |
String |
表头标题 |
width / minWidth |
Number / String |
列宽 |
fixed |
Boolean / 'left' / 'right' |
固定列,true 等价 'left' |
sortable |
Boolean |
前端排序;配合 sortMethod 自定义比较函数 (a, b) => Number |
align / headerAlign |
String |
对齐方式 |
resizable |
Boolean |
单列是否可拖拽,默认 true |
formatter |
(row, column, cellValue, index) => any |
单元格格式化 |
slot |
String / true |
自定义插槽名;写 true 时插槽名自动为 cell-${prop},写字符串时用该字符串 |
showOverflowTooltip |
Boolean |
是否溢出 tooltip;未配置时,插槽列默认关闭、普通文本列默认开启 |
type |
String |
透传 el-table-column 的 type(如 'index') |
3. 事件
| 事件名 | 回调参数 | 说明 |
|---|---|---|
update:page |
(page: Number) |
配合 v-model:page |
update:size |
(size: Number) |
配合 v-model:size |
pagination-change |
`({ page, size, type: 'page' | 'size' })` |
selection-change |
(rows: Row[]) |
选中变化;radio 模式永远返回长度为 0 或 1 的数组 |
current-change |
(row, oldRow) |
当前高亮行变化(透传 el-table) |
其他 el-table 原生事件(如
@sort-change、@row-click)直接绑定在组件上即可,会自动透传。
4. 插槽
| 插槽名 | 作用域 | 说明 |
|---|---|---|
| 列具名插槽 | { row, column, $index } |
列配置里 slot: 'xxx' 时使用 |
| 默认插槽 | --- | 直接写原生 <el-table-column>,会原样透传,适合特殊列 |
5. 通过 ref 调用的方法
ts
const tableRef = ref()
// <ProTable ref="tableRef" />
| 方法 | 签名 | 说明 |
|---|---|---|
tableRef |
--- | 原生 el-table 实例,可调用 doLayout、toggleRowSelection 等 |
getSelectionRows() |
() => Row[] |
当前多选选中的行 |
getRadioSelectedRow() |
`() => Row | null` |
clearSelection() |
() => void |
清空多选 |
toggleRowSelection(row, selected) |
(row, selected?) => void |
程序化勾选某行 |
clearRadio() |
() => void |
清空单选 |
recalculate() |
() => void |
手动触发一次高度重算 + el-table 布局重算(弹窗/侧边栏折叠等特殊场景自救用) |
6. 常用场景
6.1 单选 + 点击行选中
vue
<ProTable :data="list" :columns="columns" selection-type="radio" row-key="id"
@selection-change="onSelect" />
6.2 多选 + 禁用某些行
vue
<ProTable :data="list" :columns="columns" selection-type="checkbox" row-key="id"
:disabled-row="(row) => row.status === 0" />
6.3 表格下方有固定按钮栏
vue
<ProTable :data="list" :columns="columns" :bottom-offset="56" />
<!-- 按钮栏高度约 56px,传入后表格自动让出位置 -->
6.4 透传原生属性
所有未声明的属性都会透传给 el-table,例如斑马纹、远程排序:
vue
<ProTable stripe :data="list" :columns="columns" @sort-change="onSort" />
7. 注意事项
- 分页 v-model 字段是
page/size(不是currentPage/pageSize)。 - 组件不截取数据 ,
data永远是当前页数据;翻页后选中跨页保留需自行配合rowKey,多选跨页保留由 el-table 的reserve-selection自动开启。 height="adaptive"是默认行为,要求父容器不要给表格自身加额外滚动条;浏览器层面不会出现纵向滚动条。- 在
el-dialog等带过渡动画的容器中使用时,组件已自动补测两次高度,通常无需手动recalculate();若出现高度不对,可在弹窗opened事件里调tableRef.recalculate()。
8. 组件源码
javascript
<template>
<div ref="rootRef" class="pro-table">
<el-table :row-key="rowKey" ref="tableRef" v-bind="attrs" :data="data" :border="border || resizable"
:height="tableHeight" :highlight-current-row="tableHighlightCurrentRow" @selection-change="handleSelectionChange"
:row-style="selectedMaterielRowStyle" @current-change="handleCurrentRowChange">
<!-- 单选列:el-table 无原生单选,这里用 el-radio 自实现 -->
<el-table-column v-if="selectionType === 'radio'" width="55" align="center" fixed="left">
<template #default="{ row }">
<el-radio class="pro-table__radio" :model-value="radioSelectedKey" :label="row[rowKey]"
:disabled="isRowDisabled(row)" @change="handleRadioChange(row)">
<span></span>
</el-radio>
</template>
</el-table-column>
<!-- 多选列:原 生 selection,禁用行通过 selectable 拦截 -->
<el-table-column v-else-if="selectionType === 'checkbox'" type="selection" width="55" align="center" fixed="left"
:selectable="isRowSelectable" :reserve-selection="!!rowKey" />
<!-- 配置列 -->
<el-table-column v-for="col in columns" :key="col.prop ?? col.type ?? col.label" :type="col.type" :prop="col.prop"
:label="col.label" :width="col.width" :min-width="col.minWidth" :fixed="col.fixed" :sortable="col.sortable"
:sort-method="col.sortMethod" :align="col.align" :header-align="col.headerAlign"
show-overflow-tooltip
:resizable="col.resizable ?? true"
:formatter="col.formatter">
<template v-if="col.slot" #default="scope">
<slot :name="col.slot" v-bind="scope" />
</template>
</el-table-column>
<!-- 具名插槽:直接写原生 el-table-column 透传 -->
<slot />
</el-table>
<el-pagination v-if="showPagination" size="default" ref="paginationRef" class="pro-table__pagination"
v-bind="paginationProps" :current-page="page" :page-size="size" :page-sizes="[20, 50, 100, 200, 500]"
:total="total" @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</template>
<script setup>
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
defineOptions({ name: 'ProTable', inheritAttrs: false })
const props = defineProps({
// 表格数据源
data: { type: Array, default: () => [] },
// 表格列配置
columns: { type: Array, default: () => [] },
// 行数据的主键字段(默认选中、单选、跨页勾选都依赖它)
rowKey: { type: String, default: 'id' },
// 是否显示表格边框
border: { type: Boolean, default: false },
// 列是否可调整宽度(el-table 要求 border 为真才生效,这里自动开启)
resizable: { type: Boolean, default: false },
// 表格高度:
// - 'adaptive'(默认):自动撑满「组件顶部 → 浏览器视口底部」的剩余空间,
// 数据再多也只在表体内部滚动,浏览器不出现滚动条
// - 'auto':随内容自然撑开,滚动交给外层容器(旧行为)
// - 数字 / '600' / '600px' / '60%':固定高度,直接透传 el-table(兼容旧用法)
height: { type: [Number, String], default: 'adaptive' },
// adaptive 模式下的额外底部预留(px):表格下方还有固定按钮栏/底栏时传入其高度
bottomOffset: { type: Number, default: 5 },
// adaptive 模式下表格的最小高度(px),防止极端布局把表格压没
minHeight: { type: Number, default: 200 },
// 选择类型:'radio'(单选)/ 'checkbox'(多选)/ 'none'(不选,默认无选择列)
selectionType: {
type: String,
default: 'none',
validator: (value) => ['radio', 'checkbox', 'none'].includes(value),
},
// 默认选中的行主键数组(radio 取第一个,checkbox 全部生效)
defaultSelectedKeys: { type: Array, default: () => [] },
// 行禁用判断函数 (row) => boolean,禁用行不可勾选/单选
disabledRow: { type: Function, default: () => false },
// 当前页码(支持 v-model:currentPage)
page: { type: Number, default: 1 },
// 当前每页条数(支持 v-model:pageSize)
size: { type: Number, default: 20 },
// 数据总条数
total: { type: Number, default: 0 },
// 是否显示分页组件
showPagination: { type: Boolean, default: true },
// 分页组件额外配置项:layout / pageSizes / background / pager-count 等原生属性透传
paginationOptions: { type: Object, default: () => ({}) },
})
const emit = defineEmits([
'update:page',
'update:size',
'pagination-change',
'selection-change',
'current-change',
])
// 未声明为 prop 的属性/事件全部透传给 el-table(stripe、lazy、@sort-change 等)
const attrs = useAttrs()
const tableRef = ref(null)
const selectedRows = ref([])
const radioSelectedKey = ref(null)
let defaultsApplied = false
// 高亮当前行:radio 模式必须开启(点击行选中依赖它);
// 其他模式不强制------用户透传了 highlight-current-row 就用透传值,未传则关闭
const tableHighlightCurrentRow = computed(() => {
if (props.selectionType === 'radio') return true
return attrs['highlight-current-row'] ?? attrs.highlightCurrentRow ?? false
})
const paginationProps = computed(() => ({
layout: 'total, sizes, prev, pager, next, jumper',
pageSizes: [10, 20, 50, 100],
background: true,
...props.paginationOptions,
}))
const isRowDisabled = (row) => props.disabledRow?.(row) === true
const isRowSelectable = (row) => !isRowDisabled(row)
// ========== tooltip 按列解析 ==========
// 显式配置优先;未配置时:插槽列(标签/按钮,内容本身不易溢出、且是拖动时
// tooltip 残留的重灾区)默认关闭,普通文本列保持开启(长编号仍可 hover 看全)。
const resolveOverflowTooltip = (col) => {
if (col.showOverflowTooltip !== undefined) return col.showOverflowTooltip
return !col.slot
}
// ========== 行选择 ==========
const handleSelectionChange = (rows) => {
selectedRows.value = rows
emit('selection-change', rows)
}
const handleRadioChange = (row) => {
radioSelectedKey.value = row[props.rowKey]
tableRef.value?.setCurrentRow(row)
emit('selection-change', [row])
}
// 注意:原来这里硬编码 row.id,组件有 rowKey 字段却没用上,
// 换一个非 id 主键的数据源高亮就失效。统一改为按 rowKey 取值。
const selectedMaterielRowStyle = ({ row }) => {
const idArr = selectedRows.value.map((item) => item[props.rowKey])
if (idArr.includes(row[props.rowKey])) {
return { 'font-weight': '700' }
}
}
// 单选模式下点击行任意位置即选中;禁用行点击后回退到原选中行
const handleCurrentRowChange = (row, oldRow) => {
if (props.selectionType !== 'radio') {
// 非单选模式:仅透传原生当前行变化事件,不做任何选择逻辑
emit('current-change', row, oldRow)
return
}
if (row && !isRowDisabled(row) && radioSelectedKey.value !== row[props.rowKey]) {
radioSelectedKey.value = row[props.rowKey]
emit('selection-change', [row])
}
if (row && isRowDisabled(row)) {
const prev = props.data.find((item) => item[props.rowKey] === radioSelectedKey.value)
nextTick(() => tableRef.value?.setCurrentRow(prev ?? null))
}
emit('current-change', row, oldRow)
}
// 默认选中:数据首次到达后应用一次(radio 取第一个 key,checkbox 全部应用)
const applyDefaultSelection = () => {
if (defaultsApplied || !props.data.length || !tableRef.value) return
defaultsApplied = true
if (props.selectionType === 'radio') {
const key = props.defaultSelectedKeys[0]
const row = props.data.find((item) => item[props.rowKey] === key)
if (row && !isRowDisabled(row)) {
radioSelectedKey.value = row[props.rowKey]
tableRef.value.setCurrentRow(row)
}
} else if (props.selectionType === 'checkbox') {
props.data.forEach((row) => {
if (props.defaultSelectedKeys.includes(row[props.rowKey]) && !isRowDisabled(row)) {
tableRef.value.toggleRowSelection(row, true)
}
})
}
}
watch([() => props.data, tableRef], () => nextTick(applyDefaultSelection), { immediate: true })
// ========== 分页 ==========
// 改每页条数时,el-pagination 可能因当前页超出新总页数而自动钳制页码,
// 连发 size-change + current-change 两个事件。这里把同一次用户操作
// (同一轮事件流)内的多次触发合并成一次 pagination-change,
// 父组件只会收到一次回调,避免重复请求接口。
let paginationTimer = null
const pendingPagination = { page: null, size: null, type: 'page' }
const schedulePaginationChange = (patch) => {
if (patch.page !== undefined) pendingPagination.page = patch.page
if (patch.size !== undefined) {
pendingPagination.size = patch.size
pendingPagination.type = 'size' // 条数变化是主动操作,页码钳制只是它的副作用
} else if (pendingPagination.size === null) {
pendingPagination.type = 'page'
}
clearTimeout(paginationTimer)
paginationTimer = setTimeout(() => {
emit('pagination-change', {
page: pendingPagination.page ?? props.page,
size: pendingPagination.size ?? props.size,
})
pendingPagination.page = null
pendingPagination.size = null
pendingPagination.type = 'page'
})
}
const handlePageChange = (page) => {
emit('update:page', page)
schedulePaginationChange({ page })
}
const handleSizeChange = (size) => {
emit('update:size', size)
schedulePaginationChange({ size, page: 1 })
}
// ========== 高度自适应:列表撑满剩余视口,浏览器不出滚动条 ==========
const rootRef = ref(null)
const paginationRef = ref(null)
const adaptiveHeight = ref(props.minHeight)
// 'adaptive'(自适应视口)/ 'auto'(随内容撑开)/ 'fixed'(固定值透传)
const heightMode = computed(() => {
const h = props.height
if (h === 'adaptive' || h === undefined || h === null || h === '') return 'adaptive'
if (h === 'auto') return 'auto'
return 'fixed'
})
// 传给 el-table 的最终 height:自适应用测量值,auto 不传,其余原样透传
const tableHeight = computed(() => {
if (heightMode.value === 'adaptive') return adaptiveHeight.value
if (heightMode.value === 'auto') return undefined
return props.height
})
// 测量:可用高度 = 视口底部 - 组件顶部 - 分页栏占位 - 额外底部预留
const measureHeight = () => {
if (heightMode.value !== 'adaptive' || !rootRef.value) return
const rootTop = rootRef.value.getBoundingClientRect().top
let reserved = props.bottomOffset
const paginationEl = paginationRef.value?.$el
if (paginationEl) {
const style = window.getComputedStyle(paginationEl)
reserved += paginationEl.offsetHeight + (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0)
}
adaptiveHeight.value = Math.max(Math.floor(window.innerHeight - rootTop - reserved), props.minHeight)
}
// 强制 el-table 重算列宽 / 横向滚动宽度。
// 不做这一步时:数据行内容(长编号、标签)先渲染、列宽测量滞后,
// 拖动横向滚动条过程中用的是旧列宽,表头与表体列边界对不上、松手才跳正确位置。
const syncTableLayout = () => tableRef.value?.doLayout()
// ========== tooltip 滚动熔断 ==========
// overflow tooltip 的 popper 挂在 body 上(teleported),表格滚动不触发 cell 的
// mouseleave,创建后销毁不及就会残留成悬浮黑块(element-plus 已知问题)。
// 保险丝:表格滚动进行中把本表相关的可见 tooltip 压掉,松手后重新 hover 会正常再弹。
// 默认 overflow tooltip 是 dark 效果(.el-popper.is-dark),只压这一类,避免误伤页面其他 tooltip。
const tableScrollEl = ref(null)
const hideOverflowTooltips = () => {
document.body.querySelectorAll('.el-popper.is-dark').forEach((el) => {
if (el.style.display !== 'none') el.style.display = 'none'
})
}
// 同一帧内的多次触发合并成一次测量(仅用于 resize/延时兜底等非 RO 场景)
let measurePending = false
const scheduleMeasure = () => {
if (measurePending) return
measurePending = true
requestAnimationFrame(() => {
measurePending = false
measureHeight()
})
}
let rootObserver = null
let parentObserver = null
onMounted(() => {
scheduleMeasure()
window.addEventListener('resize', scheduleMeasure)
// 挂载后补测两次,兼容 el-dialog 等带过渡动画的容器(动画期间 rect.top 不准)
setTimeout(scheduleMeasure, 120)
setTimeout(scheduleMeasure, 400)
// 监听自身与父容器尺寸变化:搜索表单展开/收起、侧边栏折叠、分页换行等都会触发重算
rootObserver = new ResizeObserver(() => {
measureHeight()
syncTableLayout()
})
if (rootRef.value) rootObserver.observe(rootRef.value)
if (rootRef.value?.parentElement) {
parentObserver = new ResizeObserver(() => {
measureHeight()
syncTableLayout()
})
parentObserver.observe(rootRef.value.parentElement)
}
// 表格滚动兜底:横向/纵向滚动时压掉残留 tooltip
nextTick(() => {
tableScrollEl.value = rootRef.value?.querySelector('.el-table .el-scrollbar__wrap')
tableScrollEl.value?.addEventListener('scroll', hideOverflowTooltips, { passive: true })
})
})
// keep-alive 页面切回来时重算一次(nextTick 后同步测量,与 RO 同帧生效,不闪)
onActivated(() => nextTick(() => {
measureHeight()
syncTableLayout()
}))
onBeforeUnmount(() => {
window.removeEventListener('resize', scheduleMeasure)
rootObserver?.disconnect()
parentObserver?.disconnect()
tableScrollEl.value?.removeEventListener('scroll', hideOverflowTooltips)
clearTimeout(paginationTimer)
})
watch(() => [props.height, props.showPagination, props.bottomOffset], () => nextTick(measureHeight))
// 数据到达后重算布局:首帧渲染与列宽测量之间有时间差,
// 恰好是「拖滚动条时表头/表体错位」的高发窗口
watch(() => props.data, () => nextTick(syncTableLayout))
// ========== 对外暴露的方法 ==========
defineExpose({
tableRef,
getSelectionRows: () => selectedRows.value,
getRadioSelectedRow: () => props.data.find((item) => item[props.rowKey] === radioSelectedKey.value) ?? null,
clearSelection: () => tableRef.value?.clearSelection(),
toggleRowSelection: (row, selected) => tableRef.value?.toggleRowSelection(row, selected),
clearRadio: () => {
radioSelectedKey.value = null
tableRef.value?.setCurrentRow(null)
},
// 手动触发一次高度重算 + el-table 布局重算(特殊容器/时机下自救用)
recalculate: () => {
nextTick(measureHeight)
tableRef.value?.doLayout()
},
})
</script>
<style scoped lang="less">
.pro-table {
display: flex;
flex-direction: column;
min-height: 0;
}
/* 分页回归文档流,跟随在表格下方;自适应模式下其占位会被计入高度测量 */
.pro-table__pagination {
justify-content: flex-end;
flex-shrink: 0;
margin: 10px 15px 0px 0px;
// height: 18px;
}
.pro-table :deep(.pro-table__radio) {
margin-right: 0;
}
.pro-table :deep(.pro-table__radio .el-radio__label) {
display: none;
}
</style>