返回笔记首页

ProTable 高级表格 - 深度剖析

主题配置

简历项目经验描述

版本1 - 适合初中级

plain
负责封装企业级表格组件 ProTable,集成分页、排序、筛选等常用功能
- 实现配置化表格开发模式,通过 JSON 配置即可生成完整表格,开发效率提升 60%
- 支持列拖拽排序、固定列、行内编辑等高级特性,用户体验显著改善
- 集成 Excel 导出功能,支持样式和公式导出,满足复杂报表需求

版本2 - 适合高级

plain
主导设计并实现高性能企业级表格组件,覆盖 80% 业务场景
- 通过虚拟滚动和增量渲染优化大数据表格,10万行数据渲染时间从 8s 降至 500ms
- 设计树形表格递归渲染方案,支持无限层级嵌套,性能提升 3 倍
- 实现表格状态与 URL 同步机制,支持分享链接和页面刷新状态保持
- 开发行内编辑和批量编辑功能,支持数据校验和乐观更新,操作效率提升 40%

版本3 - 适合架构方向

plain
设计并实现可扩展的表格引擎,支持插件化功能扩展
- 抽象表格核心能力为独立模块,通过插件机制实现功能组合,代码复用率 85%
- 建立表格性能监控体系,识别并优化关键渲染路径,首屏时间降低 70%
- 制定表格开发规范和最佳实践文档,降低团队成员 50% 的学习成本

面试标准回答话术

Q1: 你做的 ProTable 和普通表格有什么区别?

标准回答

"ProTable 是我在项目中封装的企业级表格组件,主要解决业务开发中表格功能重复开发的问题。

和普通表格相比,ProTable 有几个核心优势。第一是配置化,业务开发只需要定义列配置和数据请求函数,分页、排序、筛选这些通用逻辑都内置了,不用每次都写一遍。我记得之前做一个用户列表页,用普通表格写了快 200 行代码,用 ProTable 只要 50 行配置。

第二是状态管理。ProTable 会把分页、筛选、排序这些状态同步到 URL,用户刷新页面或者分享链接,都能保持当前的表格状态。这个功能在数据分析场景特别有用,用户经常需要把某个筛选条件的结果截图发给别人。

第三是扩展性。ProTable 提供了很多插槽和事件钩子,可以自定义列渲染、工具栏按钮、行操作等。我们还做了插件机制,像导出、打印、列设置这些功能都是插件,需要哪个就加载哪个,不会增加不必要的代码体积。

性能方面,我们针对大数据场景做了虚拟滚动和懒加载,现在 1 万行数据也能流畅滚动。树形表格还做了按需展开,只渲染可见的节点,性能提升很明显。"

Q2: 表格的分页、排序、筛选是怎么联动的?

标准回答

"这几个功能的联动是 ProTable 的核心逻辑,我是这样设计的。

首先,我用一个统一的状态对象管理表格参数,包含 current(当前页)、pageSize(每页条数)、sorter(排序字段和顺序)、filters(筛选条件)。这个状态对象既存在组件内部,也同步到 URL 的 query 参数里。

用户操作时,比如点击排序,会触发一个 handleTableChange 方法。这个方法会更新状态对象,然后做两件事:一是更新 URL,二是重新请求数据。因为状态是唯一数据源,所以数据请求总是基于最新的参数。

有个细节是,用户改变筛选条件时,要把当前页重置为第 1 页。因为筛选后数据总数变了,当前页可能已经超出范围。我在 handleTableChange 里做了判断,如果是筛选变化,就强制设置 current 为 1。

还有一个优化是防抖。用户在搜索框输入时,不能每输入一个字就请求一次接口,那样会造成大量无效请求。我用 debounce 做了 500ms 的防抖,用户停止输入半秒后才真正触发筛选。

数据请求这块,我封装了一个 useRequest hook,它会自动处理 loading 状态、错误处理、数据缓存这些逻辑。每次参数变化时,会取消之前的请求,发起新请求,避免请求竞态问题。"

Q3: 列拖拽排序和固定列是怎么实现的?

标准回答

"列拖拽排序我用的是原生 HTML5 的 drag and drop API,配合 Vue 的响应式系统实现。

具体来说,每个表头单元格都添加 draggable 属性,监听 dragstart、dragover、drop 这些事件。拖拽开始时记录源列的索引,拖拽结束时把源列插入到目标位置。因为列配置是个数组,我只需要调整数组顺序,Vue 会自动重新渲染表格。

拖拽过程中会显示一个虚线占位符,告诉用户放下后会插入到哪里。这个是通过动态添加 CSS class 实现的,拖拽到某列上方时给那列加个 border-left。

固定列用的是 CSS 的 position: sticky。左固定列设置 left: 0,右固定列设置 right: 0,配合 z-index 让它们浮在普通列上面。

有个坑是 sticky 在某些浏览器兼容性不好,而且在 flex 布局下会失效。我做了兼容处理,不支持 sticky 的浏览器会降级为 position: relative,虽然不能固定但至少不会报错。

列宽调整这块,我在列之间加了一个拖拽手柄,监听 mousedown 和 mousemove 事件,实时计算列宽变化。调整完成后会把列宽保存到 localStorage,下次打开页面能恢复用户的设置。"

Q4: 表格数据导出功能是怎么实现的?

标准回答

"导出功能我支持 Excel 和 CSV 两种格式,都是在前端完成的,不需要后端接口。

CSV 导出比较简单,就是把表格数据转成逗号分隔的文本。我遍历表格的行和列,按照 CSV 格式拼接字符串,然后创建一个 Blob 对象,用 URL.createObjectURL 生成下载链接,触发浏览器下载。

Excel 导出用的是 SheetJS 这个库,它能生成标准的 xlsx 文件。基本流程是:先把表格数据转成二维数组,然后用 XLSX.utils.aoa_to_sheet 创建工作表,用 XLSX.utils.book_new 创建工作簿,最后用 XLSX.writeFile 触发下载。

难点在于样式和格式的处理。比如金额列要显示千分位和货币符号,日期要格式化成特定格式,某些列要加粗或者改颜色。SheetJS 支持单元格样式,我在列配置里加了 excelStyle 字段,导出时根据配置应用样式。

还有个需求是导出当前筛选的数据。用户可能筛选了某些条件,只想导出筛选后的结果。我在导出前会检查当前的筛选参数,如果有筛选,就只导出筛选后的数据。数据量大的话会弹窗提示,让用户选择是导出当前页还是全部数据。

性能优化方面,如果数据量超过 1 万行,会用 Web Worker 在后台线程处理导出,避免阻塞主线程。还做了进度提示,用户能看到导出进度,体验比较好。"

Q5: 行内编辑和批量编辑是怎么实现的?

标准回答

"行内编辑我做了两种模式:单元格编辑和整行编辑。

单元格编辑是点击单元格后,原地变成输入框或下拉框,修改完自动保存。实现上,我给每个可编辑列加了 editable 配置,包含编辑组件类型、校验规则、保存回调。点击单元格时,会把当前行设为编辑状态,渲染编辑组件替换原来的文本。

难点在于编辑状态的管理。我用了一个 editingKey 变量记录当前编辑的行 ID,同时用 editingValue 记录编辑中的值。用户可以按回车保存,按 Esc 取消,也可以点击其他地方自动保存。

整行编辑是点击编辑按钮,整行的可编辑列都变成编辑状态,最后统一保存或取消。这个模式适合需要同时修改多个字段的场景。我用了 Form 组件包裹编辑行,利用表单的校验能力。

批量编辑是选中多行,统一修改某些字段。比如批量修改状态、批量分配负责人。我做了一个批量编辑弹窗,显示可批量修改的字段,用户填写后会批量调用保存接口。

保存策略上,我用了乐观更新。用户保存时,先更新本地数据,让界面立即响应,然后异步调用接口。如果接口成功,保持本地数据;如果失败,回滚到原来的值,并提示用户。这样用户感觉操作很快,体验更流畅。

还有个细节是冲突处理。如果用户编辑时,数据已经被别人修改了,保存会失败。我在保存接口返回时检查版本号,如果版本不一致,会提示用户数据已更新,询问是覆盖还是取消。"

Q6: 树形表格的递归渲染有什么难点?

标准回答

"树形表格的难点主要是性能和状态管理。

性能方面,如果把整棵树全部渲染,节点多了会很卡。我做了按需渲染,初始只渲染顶层节点和已展开的子节点。用户展开某个节点,才递归渲染它的子节点。

具体实现是,我维护了一个 expandedKeys 集合,记录哪些节点是展开的。渲染时遍历数据,如果节点的父节点没有展开,就跳过。这样大大减少了 DOM 节点数量。

递归组件这块,我定义了一个 TreeRow 组件,它接收当前节点和层级作为 props。如果节点有子节点且已展开,就递归渲染 TreeRow 组件。为了避免无限递归,我加了最大层级限制,默认 10 层。

状态管理比较复杂,因为展开、选中、编辑这些状态都是树形的。比如勾选一个父节点,它的所有子节点都要勾选;勾选某个子节点,父节点要变成半选状态。我写了一套算法,基于树的遍历来更新状态。

还有个难点是懒加载。如果树很大,不可能一次性加载所有节点。我做了懒加载机制,展开节点时才请求子节点数据。为了性能,会把已加载的节点缓存起来,下次展开直接用缓存。

数据结构上,我支持两种格式:嵌套结构(children 属性)和扁平结构(parentId 关联)。扁平结构性能更好,我内部会把它转成 Map 结构,访问节点是 O(1) 时间复杂度。"


核心难点与解决方案

难点1: 大数据量表格的性能优化

问题描述: 表格数据超过 1000 行时,渲染明显卡顿,滚动不流畅,用户体验很差。

解决方案

"这个问题我是分几步解决的。

第一步是虚拟滚动。我参考 react-window 的原理,只渲染可视区域的行,其他行用空白占位。具体来说,我监听滚动事件,计算当前可视区域的起始行和结束行,只渲染这个范围内的数据。

实现时有几个细节。一是要计算总高度,用所有行高的总和作为容器高度,这样滚动条才正常。二是要动态计算行高,因为有些行可能内容多,高度不一样。我用了一个高度缓存 Map,记录每行的真实高度。

第二步是分片渲染。即使用了虚拟滚动,如果一次性渲染几百行,还是会卡。我用 requestIdleCallback 做分片,每次渲染 50 行,等浏览器空闲时再渲染下一批。这样不会阻塞主线程,滚动和交互都很流畅。

第三步是避免不必要的重新渲染。我给每个单元格组件用 memo 包裹,只有数据变化时才重新渲染。列配置变化时,也做了深度对比,避免引用变化导致的全表重绘。

还有个优化是滚动防抖。用户快速滚动时,不能每次都重新计算可视范围,那样会触发大量计算。我用 requestAnimationFrame 做节流,保证每帧只计算一次,滚动帧率稳定在 60fps。

最后是数据分页加载。如果数据真的很大,比如几万行,就算虚拟滚动也有压力。我改成无限滚动,滚动到底部时自动加载下一页,用户感觉是一个连续的长列表,实际上数据是分批加载的。"

实际效果数据
  • 10000 行数据首屏渲染:从 8s 降至 500ms
  • 滚动帧率:从 20fps 提升至 60fps
  • 内存占用:减少 70%(只保留可视区域 DOM)

难点2: 表格状态持久化和 URL 同步

问题描述: 用户设置的筛选、排序、分页状态,刷新页面后丢失,而且无法通过链接分享当前的表格视图。

解决方案

"这个需求看起来简单,实际上有很多细节要处理。

首先是状态序列化。分页参数比较简单,current 和 pageSize 直接存到 URL。排序参数需要记录字段名和排序方向,我用 sorter=field:order 的格式,比如 sorter=age:ascend。

筛选参数最复杂,因为可能有多个筛选条件,而且值的类型不一样。日期范围是数组,下拉选择可能是单值或多值。我把筛选参数序列化成 JSON 字符串,用 Base64 编码后存到 URL,避免特殊字符问题。

然后是状态恢复。组件初始化时,先读 URL 参数,解析出表格状态,再请求数据。要注意参数的容错,如果 URL 参数格式不对,不能报错,而是降级到默认值。

还有个坑是状态同步时机。用户改变筛选条件,要立即更新 URL,但如果每次输入都更新,URL 会跳来跳去,影响体验。我做了防抖,用户停止操作 300ms 后再更新 URL。同时用了 router.replace 而不是 router.push,避免产生历史记录堆积。

数据缓存这块,我用了 sessionStorage。相同参数的请求,如果 5 分钟内发起过,直接用缓存数据,不重新请求接口。这样用户在不同页面切换时,表格状态和数据都能快速恢复。

最后是清空状态。我加了一个'重置筛选'按钮,点击后清空所有筛选条件,URL 也恢复到干净状态。"

技术要点
javascript
// URL 格式示例
/users?current=2&pageSize=20&sorter=age:descend&filters=eyJzdGF0dXMiOlsiYWN0aXZlIl19

// 状态同步逻辑
watch(
  () => tableState,
  debounce((newState) => {
    const query = {
      current: newState.current,
      pageSize: newState.pageSize,
    }
    if (newState.sorter) {
      query.sorter = `${newState.sorter.field}:${newState.sorter.order}`
    }
    if (Object.keys(newState.filters).length > 0) {
      query.filters = btoa(JSON.stringify(newState.filters))
    }
    router.replace({ query })
  }, 300),
  { deep: true }
)

难点3: 列配置的灵活性和可扩展性

问题描述: 不同业务场景对表格列的需求差异很大,如何设计一套通用的列配置系统,既简单易用,又足够灵活?

解决方案

"我设计的列配置系统分三层:基础配置、扩展配置、插槽定制。

基础配置包含 dataIndex(数据字段)、title(列标题)、width(列宽)、fixed(固定位置)这些必选项。这一层满足 80% 的基本需求,配置很简单。

扩展配置包含 sorter(是否可排序)、filters(筛选选项)、ellipsis(超长省略)、copyable(可复制)等功能性配置。这一层通过开关控制,按需启用。

插槽定制是最灵活的,支持自定义单元格渲染。我提供了 customRender 函数,接收 { text, record, index } 作为参数,返回自定义的 VNode。这样可以渲染任何复杂的内容,比如状态标签、进度条、操作按钮。

还有个设计是 valueType。通过设置 valueType,可以自动处理常见的数据类型。比如:

  • valueType: 'date' → 自动格式化日期
  • valueType: 'money' → 自动显示货币格式
  • valueType: 'percent' → 自动显示百分比
  • valueType: 'enum' → 根据枚举映射显示文本

这些 valueType 不仅影响显示,也会影响筛选器的类型。date 类型会渲染日期选择器,enum 类型会渲染下拉框。

列配置还支持继承和覆盖。我定义了一套默认配置,业务代码只需要覆盖需要改的部分。比如所有列默认可排序,如果某列不需要,设置 sorter: false 即可。

最后是列配置的复用。我做了一个配置预设系统,常用的列配置(如用户名、创建时间、操作列)可以保存为预设,其他表格直接引用,不用重复写。"

配置示例
javascript
const columns = [
  {
    dataIndex: 'name',
    title: '姓名',
    width: 120,
    fixed: 'left',
    sorter: true,
    ellipsis: true,
    copyable: true,
  },
  {
    dataIndex: 'age',
    title: '年龄',
    width: 80,
    valueType: 'digit',
    sorter: true,
  },
  {
    dataIndex: 'status',
    title: '状态',
    width: 100,
    valueType: 'enum',
    valueEnum: {
      active: { text: '活跃', status: 'Success' },
      inactive: { text: '禁用', status: 'Default' },
    },
    filters: true,
  },
  {
    dataIndex: 'action',
    title: '操作',
    width: 150,
    fixed: 'right',
    customRender: ({ record }) => (
      <Space>
        <a onClick={() => handleEdit(record)}>编辑</a>
        <a onClick={() => handleDelete(record)}>删除</a>
      </Space>
    ),
  },
]

完整技术实现

1. ProTable 核心组件

vue
<!-- components/ProTable/ProTable.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div class="pro-table-toolbar" v-if="showToolbar">
      <div class="toolbar-left">
        <slot name="toolbarLeft"></slot>
      </div>
      <div class="toolbar-right">
        <a-space>
          <!-- 刷新 -->
          <a-tooltip title="刷新">
            <a-button @click="refresh" :loading="loading">
              <template #icon><ReloadOutlined /></template>
            </a-button>
          </a-tooltip>

          <!-- 列设置 -->
          <a-tooltip title="列设置">
            <a-dropdown>
              <a-button>
                <template #icon><SettingOutlined /></template>
              </a-button>
              <template #overlay>
                <ColumnSetting
                  v-model:value="columnState"
                  :columns="columns"
                />
              </template>
            </a-dropdown>
          </a-tooltip>

          <!-- 导出 -->
          <a-tooltip title="导出">
            <a-button @click="handleExport">
              <template #icon><ExportOutlined /></template>
            </a-button>
          </a-tooltip>

          <slot name="toolbarRight"></slot>
        </a-space>
      </div>
    </div>

    <!-- 表格主体 -->
    <a-table
      v-bind="$attrs"
      :columns="processedColumns"
      :data-source="dataSource"
      :loading="loading"
      :pagination="paginationConfig"
      :row-key="rowKey"
      @change="handleTableChange"
    >
      <!-- 透传所有插槽 -->
      <template v-for="(_, name) in $slots" #[name]="slotData">
        <slot :name="name" v-bind="slotData || {}"></slot>
      </template>
    </a-table>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ReloadOutlined, SettingOutlined, ExportOutlined } from '@ant-design/icons-vue'
import ColumnSetting from './ColumnSetting.vue'
import { exportToExcel } from './utils/export'
import { debounce } from 'lodash-es'

const props = defineProps({
  // 列配置
  columns: {
    type: Array,
    required: true,
  },
  // 数据请求函数
  request: {
    type: Function,
    required: true,
  },
  // 额外的请求参数
  params: {
    type: Object,
    default: () => ({}),
  },
  // 行唯一标识
  rowKey: {
    type: [String, Function],
    default: 'id',
  },
  // 是否显示工具栏
  showToolbar: {
    type: Boolean,
    default: true,
  },
  // 分页配置
  pagination: {
    type: [Object, Boolean],
    default: () => ({}),
  },
})

const emit = defineEmits(['change'])

const route = useRoute()
const router = useRouter()

// 表格数据
const dataSource = ref([])
const loading = ref(false)
const total = ref(0)

// 表格参数
const tableParams = ref({
  current: 1,
  pageSize: 20,
  sorter: null,
  filters: {},
})

// 列状态(顺序、显示隐藏)
const columnState = ref({
  order: props.columns.map(col => col.dataIndex),
  show: props.columns.reduce((acc, col) => {
    acc[col.dataIndex] = true
    return acc
  }, {}),
})

// 从 URL 初始化参数
onMounted(() => {
  const query = route.query
  if (query.current) tableParams.value.current = Number(query.current)
  if (query.pageSize) tableParams.value.pageSize = Number(query.pageSize)
  if (query.sorter) {
    const [field, order] = query.sorter.split(':')
    tableParams.value.sorter = { field, order }
  }
  if (query.filters) {
    try {
      tableParams.value.filters = JSON.parse(atob(query.filters))
    } catch (e) {
      console.error('解析筛选参数失败', e)
    }
  }

  // 加载数据
  fetchData()
})

// 处理后的列配置
const processedColumns = computed(() => {
  return columnState.value.order
    .map(key => props.columns.find(col => col.dataIndex === key))
    .filter(col => col && columnState.value.show[col.dataIndex])
    .map(col => {
      const column = { ...col }

      // 处理排序
      if (column.sorter) {
        column.sortOrder =
          tableParams.value.sorter?.field === column.dataIndex
            ? tableParams.value.sorter.order
            : null
      }

      // 处理筛选
      if (column.filters) {
        column.filteredValue = tableParams.value.filters[column.dataIndex] || null
      }

      return column
    })
})

// 分页配置
const paginationConfig = computed(() => {
  if (props.pagination === false) return false

  return {
    current: tableParams.value.current,
    pageSize: tableParams.value.pageSize,
    total: total.value,
    showSizeChanger: true,
    showQuickJumper: true,
    showTotal: (total) => `共 ${total} 条`,
    ...props.pagination,
  }
})

// 请求数据
async function fetchData() {
  loading.value = true
  try {
    const requestParams = {
      current: tableParams.value.current,
      pageSize: tableParams.value.pageSize,
      ...tableParams.value.filters,
      ...props.params,
    }

    // 添加排序参数
    if (tableParams.value.sorter) {
      requestParams.sortField = tableParams.value.sorter.field
      requestParams.sortOrder = tableParams.value.sorter.order
    }

    const result = await props.request(requestParams)

    dataSource.value = result.data || []
    total.value = result.total || 0
  } catch (error) {
    console.error('请求数据失败', error)
    message.error('加载数据失败')
  } finally {
    loading.value = false
  }
}

// 表格变化处理
function handleTableChange(pagination, filters, sorter) {
  // 更新参数
  tableParams.value.current = pagination.current
  tableParams.value.pageSize = pagination.pageSize

  // 处理排序
  if (sorter.field) {
    tableParams.value.sorter = {
      field: sorter.field,
      order: sorter.order,
    }
  } else {
    tableParams.value.sorter = null
  }

  // 处理筛选
  const activeFilters = {}
  Object.keys(filters).forEach(key => {
    if (filters[key] && filters[key].length > 0) {
      activeFilters[key] = filters[key]
    }
  })

  // 筛选变化时重置到第一页
  if (JSON.stringify(activeFilters) !== JSON.stringify(tableParams.value.filters)) {
    tableParams.value.current = 1
  }

  tableParams.value.filters = activeFilters

  // 同步到 URL
  updateURL()

  // 重新请求数据
  fetchData()

  // 触发事件
  emit('change', tableParams.value)
}

// 更新 URL(防抖)
const updateURL = debounce(() => {
  const query = {
    current: tableParams.value.current,
    pageSize: tableParams.value.pageSize,
  }

  if (tableParams.value.sorter) {
    query.sorter = `${tableParams.value.sorter.field}:${tableParams.value.sorter.order}`
  }

  if (Object.keys(tableParams.value.filters).length > 0) {
    query.filters = btoa(JSON.stringify(tableParams.value.filters))
  }

  router.replace({ query })
}, 300)

// 刷新
function refresh() {
  fetchData()
}

// 导出
function handleExport() {
  exportToExcel(
    processedColumns.value,
    dataSource.value,
    '表格数据.xlsx'
  )
  message.success('导出成功')
}

// 暴露方法
defineExpose({
  refresh,
  fetchData,
  getTableData: () => dataSource.value,
  getParams: () => tableParams.value,
})
</script>

<style scoped>
.pro-table-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  padding: 12px;
  background: #fafafa;
  border-radius: 4px;
}
</style>

2. 列设置组件

vue
<!-- components/ProTable/ColumnSetting.vue -->
<template>
  <div class="column-setting">
    <div class="column-setting-header">
      <span>列设置</span>
      <a @click="resetColumns">重置</a>
    </div>

    <div class="column-setting-body">
      <draggable
        v-model="localOrder"
        :component-data="{ name: 'list' }"
        item-key="key"
        handle=".drag-handle"
      >
        <template #item="{ element }">
          <div class="column-item">
            <MenuOutlined class="drag-handle" />
            <a-checkbox
              :checked="localShow[element]"
              @change="(e) => handleShowChange(element, e.target.checked)"
            >
              {{ getColumnTitle(element) }}
            </a-checkbox>
          </div>
        </template>
      </draggable>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { MenuOutlined } from '@ant-design/icons-vue'
import draggable from 'vuedraggable'

const props = defineProps({
  value: {
    type: Object,
    required: true,
  },
  columns: {
    type: Array,
    required: true,
  },
})

const emit = defineEmits(['update:value'])

// 本地状态
const localOrder = ref([...props.value.order])
const localShow = ref({ ...props.value.show })

// 监听变化,同步到父组件
watch(
  [localOrder, localShow],
  () => {
    emit('update:value', {
      order: localOrder.value,
      show: localShow.value,
    })
  },
  { deep: true }
)

// 获取列标题
function getColumnTitle(dataIndex) {
  const column = props.columns.find(col => col.dataIndex === dataIndex)
  return column?.title || dataIndex
}

// 切换显示
function handleShowChange(dataIndex, show) {
  localShow.value[dataIndex] = show
}

// 重置
function resetColumns() {
  localOrder.value = props.columns.map(col => col.dataIndex)
  localShow.value = props.columns.reduce((acc, col) => {
    acc[col.dataIndex] = true
    return acc
  }, {})
}
</script>

<style scoped>
.column-setting {
  width: 250px;
  padding: 12px;
}

.column-setting-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 12px;
  font-weight: 500;
}

.column-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px;
  cursor: move;
}

.column-item:hover {
  background: #f5f5f5;
}

.drag-handle {
  cursor: move;
  color: #999;
}
</style>

3. Excel 导出工具

javascript
// components/ProTable/utils/export.js
import * as XLSX from 'xlsx'

/**
 * 导出表格数据为 Excel
 */
export function exportToExcel(columns, data, filename = 'export.xlsx') {
  // 构建表头
  const headers = columns
    .filter(col => !col.hideInExport)
    .map(col => col.title)

  // 构建数据行
  const rows = data.map(record => {
    return columns
      .filter(col => !col.hideInExport)
      .map(col => {
        const value = record[col.dataIndex]

        // 格式化值
        if (col.customRender) {
          // 如果有自定义渲染,尝试提取纯文本
          return extractText(col.customRender({ text: value, record }))
        }

        // 根据 valueType 格式化
        if (col.valueType) {
          return formatValueByType(value, col.valueType, col.valueEnum)
        }

        return value
      })
  })

  // 创建工作表
  const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows])

  // 设置列宽
  worksheet['!cols'] = columns
    .filter(col => !col.hideInExport)
    .map(col => ({ wch: col.width ? col.width / 10 : 15 }))

  // 创建工作簿
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')

  // 导出文件
  XLSX.writeFile(workbook, filename)
}

/**
 * 从 VNode 提取纯文本
 */
function extractText(vnode) {
  if (!vnode) return ''
  if (typeof vnode === 'string') return vnode
  if (typeof vnode === 'number') return String(vnode)
  if (Array.isArray(vnode)) {
    return vnode.map(extractText).join('')
  }
  if (vnode.children) {
    if (typeof vnode.children === 'string') return vnode.children
    if (Array.isArray(vnode.children)) {
      return vnode.children.map(extractText).join('')
    }
  }
  return ''
}

/**
 * 根据类型格式化值
 */
function formatValueByType(value, valueType, valueEnum) {
  if (value == null) return ''

  switch (valueType) {
    case 'date':
      return new Date(value).toLocaleDateString('zh-CN')

    case 'dateTime':
      return new Date(value).toLocaleString('zh-CN')

    case 'money':
      return `¥${Number(value).toLocaleString('zh-CN', {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      })}`

    case 'percent':
      return `${(Number(value) * 100).toFixed(2)}%`

    case 'enum':
      if (valueEnum && valueEnum[value]) {
        return valueEnum[value].text || value
      }
      return value

    default:
      return value
  }
}

4. 使用示例

vue
<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <ProTable
      ref="tableRef"
      :columns="columns"
      :request="fetchUsers"
      :params="searchParams"
      row-key="id"
    >
      <!-- 工具栏左侧 -->
      <template #toolbarLeft>
        <a-space>
          <a-input-search
            v-model:value="searchParams.keyword"
            placeholder="搜索用户名、邮箱"
            style="width: 250px"
            @search="handleSearch"
          />
          <a-button type="primary" @click="handleAdd">
            新增用户
          </a-button>
        </a-space>
      </template>

      <!-- 自定义列渲染 -->
      <template #bodyCell="{ column, record }">
        <template v-if="column.dataIndex === 'status'">
          <a-tag :color="record.status === 'active' ? 'green' : 'red'">
            {{ record.status === 'active' ? '活跃' : '禁用' }}
          </a-tag>
        </template>

        <template v-if="column.dataIndex === 'action'">
          <a-space>
            <a @click="handleEdit(record)">编辑</a>
            <a @click="handleDelete(record)">删除</a>
          </a-space>
        </template>
      </template>
    </ProTable>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import ProTable from '@/components/ProTable/ProTable.vue'
import { getUserList, deleteUser } from '@/api/user'

const tableRef = ref()

// 搜索参数
const searchParams = reactive({
  keyword: '',
})

// 列配置
const columns = [
  {
    dataIndex: 'id',
    title: 'ID',
    width: 80,
    sorter: true,
  },
  {
    dataIndex: 'username',
    title: '用户名',
    width: 150,
    sorter: true,
    ellipsis: true,
  },
  {
    dataIndex: 'email',
    title: '邮箱',
    width: 200,
    ellipsis: true,
    copyable: true,
  },
  {
    dataIndex: 'age',
    title: '年龄',
    width: 80,
    valueType: 'digit',
    sorter: true,
  },
  {
    dataIndex: 'status',
    title: '状态',
    width: 100,
    filters: [
      { text: '活跃', value: 'active' },
      { text: '禁用', value: 'inactive' },
    ],
  },
  {
    dataIndex: 'createdAt',
    title: '创建时间',
    width: 180,
    valueType: 'dateTime',
    sorter: true,
  },
  {
    dataIndex: 'action',
    title: '操作',
    width: 150,
    fixed: 'right',
  },
]

// 请求数据
async function fetchUsers(params) {
  try {
    const response = await getUserList(params)
    return {
      data: response.data.list,
      total: response.data.total,
      success: true,
    }
  } catch (error) {
    return {
      data: [],
      total: 0,
      success: false,
    }
  }
}

// 搜索
function handleSearch() {
  tableRef.value?.refresh()
}

// 新增
function handleAdd() {
  // 跳转到新增页面或打开弹窗
}

// 编辑
function handleEdit(record) {
  // 跳转到编辑页面或打开弹窗
}

// 删除
async function handleDelete(record) {
  try {
    await deleteUser(record.id)
    message.success('删除成功')
    tableRef.value?.refresh()
  } catch (error) {
    message.error('删除失败')
  }
}
</script>

面试常见追问

Q: 如果后端不支持分页,前端怎么处理?

"这种情况我遇到过,主要是一些老系统或者数据量不大的接口。

我的处理方式是前端分页。把后端返回的全部数据缓存起来,然后在前端做分页、排序、筛选。具体实现是:

  1. 第一次请求时获取全部数据,存到一个 allData 变量
  2. 根据当前页码和每页条数,从 allData 里切片出当前页的数据
  3. 排序和筛选也在前端做,用 Array.sort() 和 Array.filter()

这个方案的优点是不用修改后端,而且用户操作响应很快,因为不需要等接口。缺点是首次加载慢,而且数据量大了内存吃不消。

所以我加了个数据量判断,如果总数超过 1 万条,会提示用户数据过多,建议添加筛选条件。同时用虚拟滚动优化渲染性能。"

Q: 表格的编辑状态管理有什么坑?

"行内编辑的状态管理确实有不少坑,主要几个:

第一个是多行同时编辑的问题。用户可能点编辑按钮很快,同时打开多行的编辑状态。我的处理是限制只能编辑一行,新点击会自动保存之前的编辑。

第二个是编辑中切换页面。用户编辑到一半,点了分页或者刷新,编辑状态会丢失。我做了一个未保存提示,切换前弹窗询问是否保存。

第三个是编辑数据的校验时机。是输入时校验还是保存时校验?我做了两层,输入时做基础校验(格式、长度),保存时做完整校验(唯一性、业务规则)。

第四个是编辑失败的回滚。保存接口失败时,要恢复到编辑前的值。我在编辑开始时克隆了一份原始数据,失败时用原始数据覆盖。"

Q: 虚拟滚动的动态高度计算有什么技巧?

"动态高度确实是虚拟滚动最难的部分。我的实现是:

  1. 初始给每行设置一个预估高度,比如 50px
  2. 渲染后用 ResizeObserver 监听每行的实际高度
  3. 把实际高度存到一个 Map 里,key 是行索引,value 是高度
  4. 计算滚动位置时,遍历 Map 累加高度得到偏移量

优化点是用二分查找。因为高度是累加的,可以构建一个前缀和数组,用二分查找快速定位某个滚动位置对应的行索引。

还有个坑是内容动态变化。比如展开折叠,高度会变化。我在内容变化时重新测量高度,更新缓存,然后调整滚动位置,保证视觉上的稳定。"


项目经验总结

踩过的坑

  1. 虚拟滚动在 Safari 上闪烁 - Safari 对 transform 的优化不如 Chrome,改用 position: relative 解决
  2. 导出 Excel 在 IE 下不兼容 - SheetJS 的某些 API 不支持 IE,降级为 CSV 导出
  3. 表格固定列在缩放时错位 - 监听 window.resize 重新计算固定列的位置
  4. 树形表格全选性能差 - 改成异步批量更新,用 requestIdleCallback 分片处理

性能数据

  • 1000 行表格渲染时间:从 2s 降至 200ms
  • 10000 行虚拟滚动帧率:稳定 60fps
  • 导出 5000 行 Excel:3s 内完成
  • 表格状态恢复时间:50ms 内

可以吹的点

  • 支持 20+ 种列配置选项,覆盖绝大部分业务场景
  • 接入成本低,平均只需 30 行代码即可实现完整功能
  • 性能指标领先业界,10 万行数据仍可流畅交互
  • 提供完整的类型定义和开发文档,团队接受度高