ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue2 + Element UI树形表格实现指南:递归、懒加载与父子联动踩坑实践

Vue2 + Element UI树形表格实现指南:递归、懒加载与父子联动踩坑实践 在vue2老项目里折腾树形表格几乎是每个前端都会遇到的事。你打开element-ui文档发现Tree组件能做树Table组件能做表格但产品要的偏偏是“树形表格”——左边按层级缩进的部门名称右边跟着负责人、人数、状态这些列每行还能展开收起子级。这个组合需求在vue2里没有官方统一的“开箱即用”方案不同团队走的路子也完全不同有人改数据结构硬套Tree组件有人引入第三方tree-table插件有人直接手写递归。我自己在维护一个vue2 element-ui的运营后台时就被这个需求结结实实折腾了大半个迭代。这篇东西就是把那段时间的踩坑、排查和最终沉淀下来的方案完整梳理一遍给正被同样问题卡住的同学一个能直接落地的参考。1. 树形表格为什么总让人头疼——数据和UI之间的关系要先理顺1.1 需求场景远比你想的杂树形表格听起来就是个“树状的表格”但真接到需求你就会发现场景五花八门组织架构列表部门下挂子部门子部门里还有组每一行要显示部门负责人、成员数、创建时间。商品分类管理一级分类下挂二级分类点击展开看到具体商品表格列是价格、库存、状态。菜单权限配置菜单层级好几层需要在表格里勾选哪些角色能看到哪些菜单。账单流水汇总父单下挂退款单、改签单金额要能看到父子汇总关系。这些场景的共同点是每一行都是同一个表格里的数据但行与行之间存在父子依赖且展开收起不能跳转页面必须原地完成。Tree组件只能展示树形节点做不了“多列对齐”的表格效果普通Table组件只会平铺渲染遇到层级数据就傻眼。所以树形表格本质上不是“表格加个树”而是“表格的行渲染逻辑要改成递归”。1.2 数据形状决定了实现难度树形表格能不能做得顺第一步就看后端给的字段长什么样。大多数老项目后端返回的并不是嵌套结构而是一张扁平列表类似这种[ { id: 1, parentId: 0, name: 产品中心, leader: 张三 }, { id: 2, parentId: 1, name: 前端组, leader: 李四 }, { id: 3, parentId: 1, name: 后端组, leader: 王五 }, { id: 4, parentId: 2, name: 小程序组, leader: 赵六 } ]而element-ui的table和tree组件都要求数据结构是嵌套的children字段{ id: 1, children: [ { id: 2, children: [ { id: 4, children: [] } ] } ] }这一步转换躲不掉哪怕你用第三方插件插件底层也是要递归这棵树的。所以先把数据转换成嵌套结构是后面所有操作的地基。地基没打牢后面展开、懒加载、勾选全都跟着出问题。1.3 三种主流方案的取舍对照我调研过市面上的做法也跟几个同行交流过主流基本就这三类方案实现方式优点缺点element-ui Table自带树形能力设置row-key tree-props数据带children字段官方维护、逻辑稳定、依赖最少不支持懒加载时的局部loading某些交互如父子勾选要自己补第三方tree-table插件如vue-table-with-tree-grid等展开收起样式丰富有一定封装维护停滞、跟element-ui版本冲突、遇到复杂列容易魔改困难手写递归表格用Table 自定义展开行或组件递归完全可控开发成本高分页、排序、多选全要自己处理基于稳定性和后续维护考虑我个人强烈建议优先使用第一种element-ui Table内置的树形数据展示能力。它不是最花哨的方案但一定是踩坑最少、查资料最容易的方案。后面所有内容都围绕这个方案展开。2. 选型思路为什么我主推element-ui自带树形能力2.1 自带成熟方案的真实边界element-ui的el-table在2.x版本里就支持了树形数据。你需要做的只是两件事给table设置row-key然后通过tree-props告诉组件哪个字段是子级。最基础的写法短得离谱el-table :datatableData row-keyid :tree-props{ children: children } el-table-column propname label名称/el-table-column el-table-column propleader label负责人/el-table-column /el-tabletableData里只要每个节点都有children数组哪怕是空数组表格就会自动渲染出展开箭头点击箭头原地进行展开收起。这个能力其实覆盖了80%的基础需求但它也有明显的边界所有数据必须一次性给到前端没有内置的“点击展开时才请求子级”机制。默认展开层级控制不直观需要通过default-expand-all或expand-row-keys控制。多选时父子级不会自动联动勾选表格只会老老实实按“行”勾选。认清边界之后你就知道哪些坑是组件帮你填平的哪些坑得自己动手。2.2 扁平数据转树形结构的通用封装既然接口大概率给扁平数据首先要封装一个转换函数。网上这类代码很多但不少版本在数据量上百条之后性能很差。我常用的写法是用Map做一次遍历把时间复杂度控制在O(n)function buildTree(list, parentIdField parentId, rootValue 0) { const map {} const roots [] // 第一遍先给每个节点准备好children数组 list.forEach(item { map[item.id] { ...item, children: [] } }) // 第二遍按parentId挂到对应父节点下 list.forEach(item { const node map[item.id] if (node[parentIdField] rootValue) { roots.push(node) } else { const parent map[node[parentIdField]] if (parent) { parent.children.push(node) } else { // 孤儿节点父级不存在时兜底避免数据丢失 roots.push(node) } } }) return roots }用的时候就这么简单this.tableData buildTree(flatList)这个函数有几个细节值得注意。第一我不要在转换时删除原始字段比如保留parentId字段用于后续回显和判断层级第二孤儿节点的处理一定要做后端数据偶尔会有父级被删但子级还在的情况不处理就会出现整条数据凭空消失排查起来极浪费时间第三Map的写法比双重for循环在500条以上数据时速度优势非常明显实测1000条数据Map方案大概1ms以内完成双重循环可能要几十毫秒甚至更久。2.3 网上很多tree-table插件的隐藏成本很多人不看element-ui文档就直接搜“vue2 树形表格插件”搜出来一堆star数不错的第三方库。我也试过但最后放弃了原因有三个第一这些插件的核心逻辑往往基于某个特定的element-ui版本封装元素版本依赖容易冲突。比如vue-table-with-tree-grid要求element-ui版本不能高于某个值而老项目为了修bug早就升上去了一安装直接报错逼着你回退版本或者用patch-package打补丁成本很高。第二插件的列渲染能力比el-table原生弱很多。当你需要用slot-scope自定义单元格内容、用formatter格式化字段、或者嵌套其他组件时插件的插槽命名和传参方式常常跟官方不一致文档又缺失只能靠猜改一个需求可能要翻源码。第三这类插件多半停留在“能跑”阶段对分页、排序、多选、触底懒加载这些常见需求没有系统处理真做下去你会发现还不如直接用官方的树表格能力加少量自研来得干净。3. 核心实现从基础行展示到树形交互3.1 开启树形模式只需三处配置确认了用官方能力之后基础实现其实就是我上面写的那几行。但要把“示例代码”变成“业务可用代码”通常还要补三个配置el-table :datatableData row-keyid :tree-props{ children: children, hasChildren: hasChildren } :default-expand-allfalse :expand-row-keysexpandedRowKeys expand-changehandleExpandChange el-table-column typeindex label# width50/el-table-column el-table-column propname label名称/el-table-column /el-tabletree-props里的children字段指定子级数组hasChildren则是告诉表格“这一行是否还有子级”的布尔字段用于某些异步加载场景。expand-row-keys接受一个数组元素是行的row-key值用来受控展开指定的行。expand-change会在某一行展开状态变化时触发接收row、expandedRows两个参数。这里有个容易踩的坑如果你不设置row-key表格在数据更新、排序、筛选时展开状态经常错乱。因为组件底层要根据row-key来标识每一行没有这个标识它不知道某一行是“新增”还是“已存在”展开状态就全乱了。所以row-key不是可选项是必选项。3.2 控制默认展开层级和展开状态产品经常提一个需求“进入页面默认只展开第一层”。这个用default-expand-all做不到因为它是全部展开。我用的思路是手动计算需要展开的row-key集合然后传给expand-row-keysfunction getFirstLevelKeys(data) { const keys [] data.forEach(topItem { keys.push(topItem.id) }) return keys } // 在拿到树形数据后 this.expandedRowKeys getFirstLevelKeys(this.tableData)如果想默认展开前两层就递归两层去收集key。这个方法在数据量几百条时毫无压力但我建议在前端加载完成后就马上计算好不要等用户去点否则会有一瞬间所有层级全部收缩视觉上很突兀。expand-change的回调里可以配合做其他事情比如记录当前展开的所有key用于切换tab后恢复状态。3.3 懒加载模式的接入方法与常见误区如果数据量大到不能一次性拉全就得用懒加载。el-table的懒加载不是通过:data传树而是配合tree-props里的hasChildren加lazy属性通过load方法在展开时去请求子级el-table :datarootData row-keyid lazy :loadloadChildren :tree-props{ children: children, hasChildren: hasChildren } /el-tableasync loadChildren(row, treeNode, resolve) { const res await fetchChildrenByParentId(row.id) const children res.data.map(item ({ ...item, hasChildren: item.childrenCount 0 })) resolve(children) }这里面最常见的误区是把hasChildren设置为true后即使接口返回空数组表格也会一直显示展开箭头。点开一次发现没有子级再点还能再次触发加载体验很差。正确做法是让后端返回一个childrenCount用它来计算hasChildren是true还是false或者干脆根据是否有子级数据判断。如果后端连count都不给那么接口返回空数组时你要手动把这条数据的hasChildren改为false并调用this.$set(row, hasChildren, false)否则视图不会更新。4. 多选、半选与父子级联动需求很狠、坑很深4.1 先搞清组件原生勾选逻辑很多需求会要求“勾选父级时自动勾选所有子级父级只勾了一部分子级时显示半选”。el-table本身没有这个逻辑它只能做到“勾选这一行”。所以你需要自己监听选中变化然后向上找父级、向下找子级。先看基本配置el-table reftableRef :datatableData row-keyid selection-changehandleSelectionChange el-table-column typeselection width55 reserve-selection/el-table-column /el-tablereserve-selection这个属性很关键它可以让表格在数据更新后尽量保留之前勾选的行。但它有要求行的row-key必须稳定唯一如果你用数组index做key那数据一变选中的行就跑偏了。4.2 按业务规则二次处理选中态既然原生不联动就得自己写联动逻辑。我的做法是维护一个全局勾选Mapkey是行idvalue是那一行的完整数据。每次selection-change触发时把当前勾选的所有行收集起来然后做两件事往下统一勾选子级往上校验父级半选状态。handleSelectionChange(selection) { // 用Map记录当前选中的行 const selectedMap new Map() selection.forEach(row selectedMap.set(row.id, row)) // 1. 对所有选中的行递归把子级也加入选中 const finalSelected [] selectedMap.forEach(row { collectWithChildren(row, selectedMap, finalSelected) }) // 2. 将最终结果同步回表格 this.$refs.tableRef.clearSelection() finalSelected.forEach(row { this.$refs.tableRef.toggleRowSelection(row, true) }) }collectWithChildren是递归收集子级的函数function collectWithChildren(row, map, result) { if (!map.has(row.id) !result.some(r r.id row.id)) { result.push(row) } ;(row.children || []).forEach(child { if (map.has(child.id)) { collectWithChildren(child, map, result) } }) }但这里有个隐患在selection-change里面调用toggleRowSelection会再次触发selection-change形成循环。我踩过一次之后用了防抖let selectionTimer null handleSelectionChange(selection) { if (selectionTimer) clearTimeout(selectionTimer) selectionTimer setTimeout(() { // 上面的处理逻辑 }, 50) }半选状态怎么处理el-table没有暴露“半选”这种API只能在选中变化时通过遍历父级的所有子级来判断。可以拿到getCheckedRows()和getHalfCheckedRows()前者是完整选中的行后者是半选行会触发selection-change再配合父级数据做展示上的文字说明。如果需求要求“子级全部选中时父级自动勾选、部分选中时父级显示半选”那你需要在前端维护一个“父级选中状态数据模型”并在表格渲染时用selectable或自定义状态列来呈现这已经超出组件默认能力只能自己包一层。4.3 校验、回显和不可选行的小技巧多选还有一个高频需求某些行不可勾选。比如权限树里根节点不允许被取消勾选。可以通过selectable回调控制el-table-column typeselection width55 :selectable(row) !row.disabled /el-table-column回显场景更坑。比如编辑角色时接口返回了已绑定的菜单id数组你要把这些行勾选上。用toggleRowSelection逐个设置即可this.$nextTick(() { this.checkedMenuIds.forEach(id { const row this.findRowById(this.tableData, id) if (row) this.$refs.tableRef.toggleRowSelection(row, true) }) })注意这里的findRowById是递归查找因为数据是树形结构不能只遍历一层。而勾选的顺序也有讲究如果父子节点都在勾选列表里建议先勾父子级再勾子级否则父级勾选时子级还没选中表格内部状态会留下差异。5. 大数据量场景下的性能优化与渲染体验5.1 展开方式的取舍如果你的树形表格一次性加载了上千条数据展开全部层级之后页面会明显卡顿。这其实是DOM节点太多导致的不是el-table的问题。我的实测数据是500条扁平数据转树后展开两层大概渲染80个tr页面还能流畅滚动一旦展开三层达到300个以上tr滚动时掉帧就非常明显了。几个缓解思路默认只展开第一层避免初始渲染大量DOM。结合懒加载只有用户主动展开的行才去取子级数据。如果只是看数据不需要频繁操作干脆用Tree组件的accordion模式一次只展开一条。这些不是el-table能帮你解决的要在产品需求阶段就沟通清楚。我跟产品对齐时常用一句话“数据量大没问题但不要把几百个节点一次性全展开给用户交互上没必要。”5.2 合并单元格的另类做法还有一个容易被忽略的思路如果“树形”只是用来做汇总展示不要求真的展开收起可以用扁平数据加span-method合并单元格来实现“伪树形表格”。比如财务账单场景父单一行显示汇总金额下面挂多个子单。如果产品允许用“父行展开后跳转明细”这种交互那你直接用Tree组件加一个“查看明细”按钮就行不需要树形表格。如果必须表格内展示那么span-method可以让你在拥有父子结构的扁平数据上手动合并第一列的行做出层级缩进效果。这种方案在大数据量时渲染压力更小因为不用递归生成多行tr但实现难度在于合并规则要自己算好数据源也要保持父子连续排列。我一般只在“数据量大但层级浅”的场景用这招两到三层扁平数据视觉上做缩进可比树形表格灵活得多。5.3 过滤与搜索时的渲染优化搜索是树形表格另一个性能杀手。如果在前端过滤树形结构过滤后的数据仍然要保持树形关系——也就是“子级命中了父级也要保留”。我之前写过一段过滤逻辑数据量上千时明显卡顿。后来优化成先过滤节点再用一次buildTree重新组装树searchData(keyword) { if (!keyword) { this.tableData this.originalTreeData return } const flatList flattenTree(this.originalTreeData) const filtered flatList.filter(item item.name.includes(keyword) ) const ids new Set() filtered.forEach(item { collectParents(item, flatList, ids) }) const resultIds [...new Set([...filtered.map(i i.id), ...ids])] const resultList flatList.filter(item resultIds.includes(item.id)) this.tableData buildTree(resultList) }把树压平成列表过滤后再重组树这套逻辑在几百条数据下完全够用。如果你要搜索的数据超过几千条建议直接走后端搜索前端过滤怎么优化都有极限。过滤之后的expandedRowKeys也要重新设置因为旧的key对应数据可能已经被过滤掉了。我通常会在搜索结束后默认展开全部匹配到的父级链保证用户能直接看到命中的那条数据。6. 实战避坑清单与我的最终建议6.1 高频问题对照表最后把我在几个项目里反复遇到的问题整理成一张表你在自测的时候可以直接对着查现象根本原因解决方案展开箭头不出现子级字段名和tree-props配置不一致检查后端返回字段改为children或统一数据转换展开箭点在数据为空的节点上也出现hasChildren被设为true根据子级数量动态设置hasChildren空数组时置为false点击展开时整表数据错乱未设置row-key或row-key值重复必须指定唯一id字段作为row-key多选勾选父级后子级没选中组件原生不做父子级联动自己实现递归选中与半选逻辑数据更新后勾选丢失未启用reserve-selection或row-key不稳定启用reserve-selection确保row-key全局唯一过滤后层级关系错乱直接对树数据进行了filter操作先压平过滤再重新buildTree大量数据展开后页面卡死一次性渲染过多DOM用懒加载默认折叠或改用span-method伪树表懒加载请求子级后在loading未在load中正确调用resolve确认resolve被调用且返回数组6.2 我在老项目里沉淀下来的固定写法如果你在vue2老项目里维护这类需求我建议你把以下几件事当成固定流程第一数据转换函数单独放到utils目录不要写在组件里。树形转换、找父级、找子级、压平树这几个操作几乎每个页面都会用到放到公共模块里能少写很多重复代码。第二统一使用row-keyid并要求后端保证id全局唯一。我遇到过两个不同分支的节点id一样结果展开一个另一个跟着动排查了一晚上。后来发现是后端分表返回数据时id重复了。遇到这种问题前端要主动做id前缀处理比如parentId_id拼接。第三每次改动数据后先调this.$nextTick再操作展开和勾选。树形表格的展开和勾选依赖DOM节点而DOM节点是在数据变化后的下一个tick才完成更新的。直接在赋值后立刻调用toggleRowSelection经常不生效官方文档不会告诉你这个但实际项目里十个问题有七个是这种时序问题。第四不要怕二次封装。我自己后来把树形表格封装了一个TreeTable组件props接收data、columns、defaultExpandLevel、lazy这些配置组件内部统一处理了buildTree、展开层级、父子勾选联动。老项目里每个页面都对着原生el-table写一遍这些逻辑出一堆小bug封装之后只维护一个组件迭代效率提升非常明显。最后再分享一个小技巧。如果你需要做“父级全选/半选/全不选”这种状态展示但没有UI上的三态勾选需求可以在自定义列里放一个复选框用计算属性判断当前节点的选中状态click时手动调toggleRowSelection。这样可以绕开el-table的selection列完全自己控制视觉上一样是三态效果但逻辑完全在你的掌控里反而比折腾组件内部状态好写很多。树形表格在vue2里不算无解核心就是选对方案、把数据转换封装好、想清楚展开和勾选的边界。希望这篇实践整理能帮你少走点弯路。
返回列表