Vue Ant Design Vue 树形表格行内编辑完整实现指南
Vue Ant Design Vue 树形表格行内编辑完整实现指南
前言
在实际项目中,我们经常需要构建复杂的数据编辑界面,特别是当数据存在层级关系、需要支持行内编辑、并且父子节点状态需要联动时。本文将通过一个真实的生产级组件(editParameter.vue)来详细讲解如何一步步实现这样的功能。
这个组件主要用于编辑 API 参数列表,涉及树形结构展示、双击行内编辑、父子级联动选择、数据校验等多个复杂功能。我将从数据结构设计、核心算法、到具体实现逐层讲解。
第一步:数据结构设计与扁平转树形
问题背景
后端通常会返回扁平的参数列表,像这样:
// 后端返回的扁平数据
const flatData = [
{ code: 'p1', parentCode: '0', apiParameterName: '用户ID', apiParameterType: 'String' },
{ code: 'p2', parentCode: 'p1', apiParameterName: '用户名', apiParameterType: 'String' },
{ code: 'p3', parentCode: 'p1', apiParameterName: '邮箱', apiParameterType: 'String' },
{ code: 'p4', parentCode: '0', apiParameterName: '订单ID', apiParameterType: 'Long' }
]
但 Ant Design Vue 的 a-table 组件需要树形结构来展示层级关系:
// a-table 需要的树形结构
const treeData = [
{
code: 'p1',
apiParameterName: '用户ID',
children: [
{ code: 'p2', apiParameterName: '用户名', children: [] },
{ code: 'p3', apiParameterName: '邮箱', children: [] }
]
},
{
code: 'p4',
apiParameterName: '订单ID',
children: []
}
]
核心方法:handleData
这个方法的职责是将扁平数据转换为树形结构,同时为每个节点注入前端所需的响应式状态。
handleData (flatArray) {
// 第一步:创建 map 对象,以 code 为键存储所有节点
const map = {}
const tree = []
flatArray.forEach(item => {
// 为每个节点初始化前端状态字段
item.editing = false // 是否处于编辑状态
item.isFocused = false // 是否获得焦点
item.indeterminate = false // 是否处于半选状态(用于父子联动)
// 将节点存入 map,并初始化 children 数组
map[item.code] = { ...item, children: [] }
})
// 第二步:遍历数组,根据 parentCode 建立父子关系
flatArray.forEach(item => {
if (item.parentCode !== '0') {
// 如果有父节点,将当前节点添加到父节点的 children 中
map[item.parentCode].children.push(map[item.code])
} else {
// 如果没有父节点(parentCode === '0'),说明是根节点
tree.push(map[item.code])
}
})
return tree
}
实现步骤详解
步骤 1:建立 code → node 的映射
const map = {}
flatArray.forEach(item => {
map[item.code] = { ...item, children: [] }
})
这一步的目的是快速查找任何节点。使用 map[item.code] 可以在 O(1) 时间内获取对应的节点对象。
步骤 2:建立父子关系
flatArray.forEach(item => {
if (item.parentCode !== '0') {
map[item.parentCode].children.push(map[item.code])
} else {
tree.push(map[item.code])
}
})
这一步遍历所有节点,根据 parentCode 判断:
- 如果
parentCode !== '0',说明有父节点,将当前节点的引用添加到父节点的children数组中 - 如果
parentCode === '0',说明是根节点,直接添加到tree数组中
关键点:这里使用的是引用而非拷贝。map[item.parentCode].children.push(map[item.code]) 添加的是对象引用,这样当修改子节点的属性时,树中的对应节点也会自动更新。
初始化前端状态
item.editing = false // 初始化为非编辑状态
item.isFocused = false // 初始化为非聚焦状态
item.indeterminate = false // 初始化为非半选状态
这三个字段是前端特有的,用于控制组件的交互行为:
editing:控制单元格是否显示输入框还是纯文本isFocused:记录输入框是否获得焦点,用于防抖处理indeterminate:用于父子级联动时的半选状态(部分子节点选中)
第二步:递归遍历与批量状态更新
问题背景
在树形数据中,我们经常需要对所有节点执行某个操作,比如:
- 校验所有节点的数据完整性
- 批量更新所有节点的状态
- 查找满足条件的节点
核心方法:handleRecursiveData
handleRecursiveData (func) {
try {
const handleData = (arr, func) => {
arr.forEach(item => {
// 执行回调函数
const result = func(item, arr)
// 如果回调返回 'stop',停止遍历
if (result === 'stop') {
return
}
// 递归处理子节点
if (item.children && item.children.length) {
handleData(item.children, func)
}
})
}
// 从根节点开始遍历
handleData(this.dataSource, func)
} catch (error) {
console.log(error)
}
}
使用示例
例子 1:校验所有节点的数据完整性
let messageInfo = ''
const func = (item) => {
this.columns.forEach(o => {
if (!item[o.dataIndex]) {
messageInfo = '请填写必要的信息'
return 'stop' // 停止遍历
}
})
}
this.handleRecursiveData(func)
if (messageInfo) {
this.$message.error(messageInfo)
}
例子 2:更新特定节点的状态
const func = (item) => {
if (item.code === targetCode) {
item.editing = false
item.isFocused = false
return 'stop' // 找到目标节点后停止遍历
}
}
this.handleRecursiveData(func)
例子 3:删除指定节点
const func = (item, arr) => {
const index = arr.findIndex(data => data.code === targetCode)
if (index !== -1) {
arr.splice(index, 1) // 从数组中删除
return 'stop'
}
}
this.handleRecursiveData(func)
设计要点
-
回调函数接收两个参数:
item:当前遍历的节点arr:当前节点所在的数组(便于删除操作)
-
支持提前停止遍历:
- 当回调返回
'stop'时,立即停止遍历 - 这样可以提高性能,避免不必要的遍历
- 当回调返回
-
递归处理子节点:
- 在处理完当前节点后,递归处理其子节点
- 这样可以遍历整个树形结构
第三步:行内编辑的实现
问题背景
用户需要通过双击单元格来编辑数据。这涉及几个复杂的问题:
- 如何切换编辑状态?
- 如何准确地聚焦到正确的输入框?
- 如何处理失焦时的保存和校验?
核心方法:onCellEdit 和 handleCellEdit
第一步:双击触发编辑
// 在模板中
<div
@dblclick="onCellEdit(record, column, 'input')"
class="editable-cell"
>
<!-- 编辑状态:显示输入框 -->
<a-input
v-if="record.editing"
v-model="record[`${col.dataIndex}`]"
:ref="`editInput-${column.dataIndex}-${record.code}`"
@blur="handleBlur(record, column)"
/>
<!-- 非编辑状态:显示纯文本 -->
<div v-else class="editable-cell-text-wrapper">
<div>{{ record[`${col.dataIndex}`] }}</div>
</div>
</div>
第二步:处理编辑状态
handleCellEdit (record, column, isEdit) {
const func = (item) => {
if (item.code === record.code) {
// 检查是否有空值
let isNull = false
this.columns.forEach(o => {
if (!record[o.dataIndex]) {
isNull = true
}
})
// 设置编辑状态
// 如果 isEdit 为 true,或者获得焦点,或者有空值,则进入编辑状态
item.editing = isEdit || item.isFocused ? true : !!isNull
if (isEdit) {
// 在下一个 tick 中聚焦到输入框
this.$nextTick(() => {
const input = this.$refs[`editInput-${column.dataIndex}-${item.code}`]
const select = this.$refs[`editSelect-${column.dataIndex}-${item.code}`]
if (input && input.length && input[0]) {
input[0].focus()
}
if (select && select.length && select[0]) {
select[0].focus()
}
})
}
return 'stop'
}
}
this.handleRecursiveData(func)
}
关键技术点
1. 动态 ref 命名
:ref="`editInput-${column.dataIndex}-${record.code}`"
这里使用了动态拼接的方式生成 ref 名称。好处是:
- 每个输入框都有唯一的 ref 名称
- 可以根据列名和行 code 准确定位到具体的输入框
- 支持树形表格中的多层嵌套
2. 使用 $nextTick 确保 DOM 更新
this.$nextTick(() => {
const input = this.$refs[`editInput-${column.dataIndex}-${item.code}`]
if (input && input.length && input[0]) {
input[0].focus()
}
})
为什么需要 $nextTick?
- 当
item.editing = true时,Vue 会更新 DOM,将隐藏的输入框显示出来 - 但这个 DOM 更新是异步的
- 如果立即调用
focus(),输入框还没有被渲染到 DOM 中,所以会失败 $nextTick确保 DOM 更新完成后再执行聚焦操作
3. ref 返回的是数组
if (input && input.length && input[0]) {
input[0].focus()
}
在 Vue 中,当 ref 用于 v-for 或动态渲染时,返回的是一个数组。所以需要:
- 检查
input.length是否大于 0 - 取第一个元素
input[0]来调用focus()
第四步:失焦保存与防抖处理
问题背景
当用户编辑完成后,需要:
- 退出编辑状态
- 校验数据
- 防止重复保存
核心方法:handleBlur
handleBlur (record, column) {
const func = (item) => {
if (item.code === record.code) {
item.isFocused = false
}
}
this.handleRecursiveData(func)
// 使用 setTimeout 实现防抖
let timer = setTimeout(() => {
// 检查是否有重复的名称
if (this.checkDuplicateName(record.apiParameterName, record)) {
return
}
// 退出编辑状态
this.handleCellEdit(record, column, false)
}, 0)
// 当模态框关闭时,清除定时器
this.$refs.modal.$once('cancel', () => {
clearInterval(timer)
timer = null
})
}
防抖的目的
为什么要用 setTimeout(..., 0) 来延迟保存?
let timer = setTimeout(() => {
// 保存逻辑
}, 0)
这是一个巧妙的防抖技巧:
- 当用户快速切换焦点时,可能会多次触发
blur事件 - 使用
setTimeout(..., 0)可以将保存操作推迟到下一个事件循环 - 如果在这个时间内又触发了
blur,可以清除之前的定时器,避免重复保存
校验重复名称
checkDuplicateName (text, node) {
const siblings = this.getSiblings(node, this.dataSource)
return siblings.some(sibling => sibling.apiParameterName === text)
}
getSiblings (node, nodes) {
const parent = this.getParent(node, nodes)
if (parent) {
return parent.children.filter(child => child !== node)
}
return nodes.filter(n => n !== node)
}
getParent (node, nodes) {
for (const item of nodes) {
if (item.children && item.children.length > 0) {
if (item.children.some(child => child === node)) {
return item
}
const parent = this.getParent(node, item.children)
if (parent) {
return parent
}
}
}
return null
}
逻辑流程:
getParent:递归查找节点的直接父节点getSiblings:获取节点的所有兄弟节点(同一级的其他节点)checkDuplicateName:检查是否存在同名的兄弟节点
第五步:父子级联动选择
问题背景
在某些场景下,需要实现类似 checkbox 的父子联动效果:
- 选中父节点时,所有子节点都应该被选中
- 取消选中父节点时,所有子节点都应该被取消选中
- 当部分子节点被选中时,父节点应该显示为"半选"状态
核心方法:handleCheckboxChange、checkChildren、checkParent
第一步:处理复选框变化
handleCheckboxChange (e, record) {
// 根据复选框的状态更新当前节点
record.apiParameterLimit = e.target.checked ? 2 : 1
// 更新父节点的状态
this.checkParent(record)
// 更新所有子节点的状态
this.checkChildren(e, record)
}
第二步:递归更新子节点
checkChildren (e, parent) {
if (parent.children) {
// 清除父节点的半选状态
parent.indeterminate = false
// 递归更新所有子节点
parent.children.forEach(child => {
child.apiParameterLimit = e.target.checked ? 2 : 1
this.checkChildren(e, child)
})
}
}
第三步:递归更新父节点
checkParent (child) {
const parent = this.findParent(this.dataSource, child)
if (parent) {
// 检查是否所有子节点都被选中
const allChecked = parent.children.every(child => child.apiParameterLimit === 2)
// 检查是否有部分子节点被选中
const someChecked = parent.children.some(child => child.apiParameterLimit === 2)
// 设置父节点的状态
parent.apiParameterLimit = allChecked ? 2 : 1
// 设置父节点的半选状态
parent.indeterminate = !allChecked && someChecked
// 递归更新祖父节点
this.checkParent(parent)
}
}
状态转换图
用户操作:选中子节点 A
↓
checkParent(A) 被调用
↓
检查 A 的父节点 P:
- 如果 P 的所有子节点都被选中 → P 也被选中,indeterminate = false
- 如果 P 的部分子节点被选中 → P 显示半选,indeterminate = true
- 如果 P 的所有子节点都未被选中 → P 也未被选中,indeterminate = false
↓
递归调用 checkParent(P),更新 P 的父节点
实际例子
假设我们有这样的树形结构:
根节点 (root)
├─ 节点 A (apiParameterLimit = 1)
│ ├─ 节点 A1 (apiParameterLimit = 1)
│ └─ 节点 A2 (apiParameterLimit = 1)
└─ 节点 B (apiParameterLimit = 1)
├─ 节点 B1 (apiParameterLimit = 1)
└─ 节点 B2 (apiParameterLimit = 1)
场景:用户选中节点 A1
// 1. handleCheckboxChange 被触发
handleCheckboxChange(e, A1)
// A1.apiParameterLimit = 2
// 2. checkParent(A1) 被调用
checkParent(A1)
// 检查 A 的子节点:A1 = 2, A2 = 1
// allChecked = false, someChecked = true
// A.apiParameterLimit = 1, A.indeterminate = true
// 3. checkParent(A) 被调用
checkParent(A)
// 检查 root 的子节点:A.indeterminate = true, B.apiParameterLimit = 1
// 由于 A 的 indeterminate = true,someChecked = true
// root.apiParameterLimit = 1, root.indeterminate = true
// 4. checkChildren(e, A1) 被调用
checkChildren(e, A1)
// A1 没有子节点,所以不做任何操作
结果:
根节点 (indeterminate = true)
├─ 节点 A (indeterminate = true)
│ ├─ 节点 A1 (apiParameterLimit = 2) ✓ 选中
│ └─ 节点 A2 (apiParameterLimit = 1)
└─ 节点 B (apiParameterLimit = 1)
├─ 节点 B1 (apiParameterLimit = 1)
└─ 节点 B2 (apiParameterLimit = 1)
第六步:数据校验
问题背景
在保存数据前,需要确保:
- 所有必填字段都有值
- 没有重复的参数名
- 参数类型已选择
核心方法:getInputValidateStatus 和 getInputValidateHelp
getInputValidateStatus (text, record) {
// 如果没有值,返回错误状态
if (!text) return 'error'
// 如果有值,检查是否重复
if (text) {
if (this.checkDuplicateName(text, record)) {
return 'error'
} else {
return 'success'
}
}
return 'success'
}
getInputValidateHelp (text, record, title) {
// 如果没有值,返回空值错误提示
if (!text) return `${title}不能为空`
// 如果有值,检查是否重复
if (text) {
if (this.checkDuplicateName(text, record)) {
return `${title}不能同名`
} else {
return ''
}
}
return ''
}
在模板中使用
<a-form-item
label=""
:validate-status="getInputValidateStatus(text, record)"
:help="getInputValidateHelp(text, record, col.title || '参数名')"
required
style="margin-bottom: 0;"
>
<a-input
v-if="record.editing"
v-model="record[`${col.dataIndex}`]"
placeholder="请输入"
/>
<div v-else class="editable-cell-text-wrapper">
<div>{{ record[`${col.dataIndex}`] }}</div>
</div>
</a-form-item>
这样,当用户输入时,会实时显示校验状态和错误提示。
第七步:新增和删除行
新增行
addParams () {
// 获取当前选中的行
const currentKey = this.selectedRowKeys[0] || null
// 初始化新行的数据
const initValue = {
code: 'add' + generateUuid(32),
apiParameterName: '',
apiParameterType: '',
apiParameterLimit: 1,
editing: true, // 新增行默认进入编辑状态
isFocused: false,
indeterminate: false
}
if (!currentKey) {
// 如果没有选中行,添加到根级
initValue.level = 1
initValue.parentCode = '0'
this.dataSource.unshift(initValue)
// 滚动到新增行
this.$nextTick(() => {
this.scrollToRow(initValue.code)
})
return
}
// 如果选中了行,添加为该行的子节点
const func = (item) => {
if (item.code === currentKey) {
initValue.level = item.level + 1
initValue.parentCode = item.code
// 如果该行没有 children,先创建
if (item.children && item.children.length) {
item.children.push(initValue)
} else {
this.$set(item, 'children', [])
item.children.push(initValue)
}
// 展开该行
this.expandedRowKeys.push(item.code)
// 滚动到新增行
this.$nextTick(() => {
this.scrollToRow(initValue.code)
})
return 'stop'
}
}
this.handleRecursiveData(func)
}
删除行
removeParams () {
const currentKey = this.selectedRowKeys[0] || null
if (!currentKey) {
return this.$message.warning('请先选中行!')
}
const func = (item, arr) => {
const index = arr.findIndex(data => data.code === currentKey)
if (index !== -1) {
arr.splice(index, 1)
this.selectedRowKeys = []
return 'stop'
}
}
this.handleRecursiveData(func)
}
总结
这个组件的实现涉及以下核心技术点:
| 功能 | 核心方法 | 关键技术 |
|---|---|---|
| 树形数据 | handleData |
使用 map 对象建立索引,两次遍历建立父子关系 |
| 递归遍历 | handleRecursiveData |
支持提前停止、传递数组引用便于删除 |
| 行内编辑 | handleCellEdit |
动态 ref 命名、$nextTick 确保 DOM 更新、聚焦处理 |
| 失焦保存 | handleBlur |
防抖处理、重复校验、定时器清理 |
| 父子联动 | checkParent/checkChildren |
递归向上/向下更新、半选状态计算 |
| 数据校验 | getInputValidateStatus |
实时校验、错误提示 |
| 增删操作 | addParams/removeParams |
递归查找、动态创建 children、滚动定位 |
这些技术点的组合使用,构成了一个功能完整、交互流畅的树形表格编辑组件。理解这些实现细节,可以帮助你在实际项目中处理类似的复杂需求。
参考资源
更多推荐


所有评论(0)