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)

设计要点

  1. 回调函数接收两个参数

    • item:当前遍历的节点
    • arr:当前节点所在的数组(便于删除操作)
  2. 支持提前停止遍历

    • 当回调返回 'stop' 时,立即停止遍历
    • 这样可以提高性能,避免不必要的遍历
  3. 递归处理子节点

    • 在处理完当前节点后,递归处理其子节点
    • 这样可以遍历整个树形结构

第三步:行内编辑的实现

问题背景

用户需要通过双击单元格来编辑数据。这涉及几个复杂的问题:

  1. 如何切换编辑状态?
  2. 如何准确地聚焦到正确的输入框?
  3. 如何处理失焦时的保存和校验?

核心方法: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()

第四步:失焦保存与防抖处理

问题背景

当用户编辑完成后,需要:

  1. 退出编辑状态
  2. 校验数据
  3. 防止重复保存

核心方法: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
}

逻辑流程

  1. getParent:递归查找节点的直接父节点
  2. getSiblings:获取节点的所有兄弟节点(同一级的其他节点)
  3. 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)

第六步:数据校验

问题背景

在保存数据前,需要确保:

  1. 所有必填字段都有值
  2. 没有重复的参数名
  3. 参数类型已选择

核心方法: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、滚动定位

这些技术点的组合使用,构成了一个功能完整、交互流畅的树形表格编辑组件。理解这些实现细节,可以帮助你在实际项目中处理类似的复杂需求。


参考资源

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐