el-table 表格组件二次封装

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. 注意事项

  1. 分页 v-model 字段是 page / size (不是 currentPage / pageSize)。
  2. 组件不截取数据 ,data 永远是当前页数据;翻页后选中跨页保留需自行配合 rowKey,多选跨页保留由 el-table 的 reserve-selection 自动开启。
  3. height="adaptive" 是默认行为,要求父容器不要给表格自身加额外滚动条;浏览器层面不会出现纵向滚动条。
  4. 在 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>
相关推荐
+VX:Fegn08953 小时前
计算机毕业设计|基于springboot + vue外卖点餐系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
FYKJ_20104 小时前
springboot刑事案件管理系统03047-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
怕浪猫4 小时前
分享一个做视频的skill,这条白板视频,每一笔都是代码画的
前端·javascript·面试
無名路人6 小时前
小程序点餐页吸顶滚动之分类按需加载,上划切换
前端·vue.js·微信小程序
OpsEye8 小时前
当企业不再只关心能不能调用模型,而是关心值不值得调用
javascript·ai编程
FYKJ_20108 小时前
springboot雅集社区养老管理系统04456-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
FYKJ_20108 小时前
express皖美特色农产品网售系统53118-计算机课程设计、毕业设计
javascript·vue.js·spring boot·mysql·typescript·spark·express
FYKJ_20108 小时前
springboot羽毛球场地管理系统00626-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
晓得迷路了9 小时前
栗子前端技术周刊第 148 期 - Turborepo 2.11、Chrome 154 iframe、Node.js 26...
前端·javascript·css