简历项目经验描述
版本1 - 适合初中级
负责封装企业级表格组件 ProTable,集成分页、排序、筛选等常用功能
- 实现配置化表格开发模式,通过 JSON 配置即可生成完整表格,开发效率提升 60%
- 支持列拖拽排序、固定列、行内编辑等高级特性,用户体验显著改善
- 集成 Excel 导出功能,支持样式和公式导出,满足复杂报表需求
版本2 - 适合高级
主导设计并实现高性能企业级表格组件,覆盖 80% 业务场景
- 通过虚拟滚动和增量渲染优化大数据表格,10万行数据渲染时间从 8s 降至 500ms
- 设计树形表格递归渲染方案,支持无限层级嵌套,性能提升 3 倍
- 实现表格状态与 URL 同步机制,支持分享链接和页面刷新状态保持
- 开发行内编辑和批量编辑功能,支持数据校验和乐观更新,操作效率提升 40%
版本3 - 适合架构方向
设计并实现可扩展的表格引擎,支持插件化功能扩展
- 抽象表格核心能力为独立模块,通过插件机制实现功能组合,代码复用率 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 也恢复到干净状态。"
技术要点
// 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 即可。
最后是列配置的复用。我做了一个配置预设系统,常用的列配置(如用户名、创建时间、操作列)可以保存为预设,其他表格直接引用,不用重复写。"
配置示例
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 核心组件
<!-- 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. 列设置组件
<!-- 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 导出工具
// 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. 使用示例
<!-- 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: 如果后端不支持分页,前端怎么处理?
"这种情况我遇到过,主要是一些老系统或者数据量不大的接口。
我的处理方式是前端分页。把后端返回的全部数据缓存起来,然后在前端做分页、排序、筛选。具体实现是:
- 第一次请求时获取全部数据,存到一个 allData 变量
- 根据当前页码和每页条数,从 allData 里切片出当前页的数据
- 排序和筛选也在前端做,用 Array.sort() 和 Array.filter()
这个方案的优点是不用修改后端,而且用户操作响应很快,因为不需要等接口。缺点是首次加载慢,而且数据量大了内存吃不消。
所以我加了个数据量判断,如果总数超过 1 万条,会提示用户数据过多,建议添加筛选条件。同时用虚拟滚动优化渲染性能。"
Q: 表格的编辑状态管理有什么坑?
"行内编辑的状态管理确实有不少坑,主要几个:
第一个是多行同时编辑的问题。用户可能点编辑按钮很快,同时打开多行的编辑状态。我的处理是限制只能编辑一行,新点击会自动保存之前的编辑。
第二个是编辑中切换页面。用户编辑到一半,点了分页或者刷新,编辑状态会丢失。我做了一个未保存提示,切换前弹窗询问是否保存。
第三个是编辑数据的校验时机。是输入时校验还是保存时校验?我做了两层,输入时做基础校验(格式、长度),保存时做完整校验(唯一性、业务规则)。
第四个是编辑失败的回滚。保存接口失败时,要恢复到编辑前的值。我在编辑开始时克隆了一份原始数据,失败时用原始数据覆盖。"
Q: 虚拟滚动的动态高度计算有什么技巧?
"动态高度确实是虚拟滚动最难的部分。我的实现是:
- 初始给每行设置一个预估高度,比如 50px
- 渲染后用 ResizeObserver 监听每行的实际高度
- 把实际高度存到一个 Map 里,key 是行索引,value 是高度
- 计算滚动位置时,遍历 Map 累加高度得到偏移量
优化点是用二分查找。因为高度是累加的,可以构建一个前缀和数组,用二分查找快速定位某个滚动位置对应的行索引。
还有个坑是内容动态变化。比如展开折叠,高度会变化。我在内容变化时重新测量高度,更新缓存,然后调整滚动位置,保证视觉上的稳定。"
项目经验总结
踩过的坑
- 虚拟滚动在 Safari 上闪烁 - Safari 对 transform 的优化不如 Chrome,改用 position: relative 解决
- 导出 Excel 在 IE 下不兼容 - SheetJS 的某些 API 不支持 IE,降级为 CSV 导出
- 表格固定列在缩放时错位 - 监听 window.resize 重新计算固定列的位置
- 树形表格全选性能差 - 改成异步批量更新,用 requestIdleCallback 分片处理
性能数据
- 1000 行表格渲染时间:从 2s 降至 200ms
- 10000 行虚拟滚动帧率:稳定 60fps
- 导出 5000 行 Excel:3s 内完成
- 表格状态恢复时间:50ms 内
可以吹的点
- 支持 20+ 种列配置选项,覆盖绝大部分业务场景
- 接入成本低,平均只需 30 行代码即可实现完整功能
- 性能指标领先业界,10 万行数据仍可流畅交互
- 提供完整的类型定义和开发文档,团队接受度高