在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 :data="tableData" row-key="id" :tree-props="{ children: 'children' }" > <el-table-column prop="name" label="名称"></el-table-column> <el-table-column prop="leader" label="负责人"></el-table-column> </el-table>tableData里只要每个节点都有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 :data="tableData" row-key="id" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :default-expand-all="false" :expand-row-keys="expandedRowKeys" @expand-change="handleExpandChange" > <el-table-column type="index" label="#" width="50"></el-table-column> <el-table-column prop="name" label="名称"></el-table-column> </el-table>tree-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-keys:
function 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 :data="rootData" row-key="id" lazy :load="loadChildren" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" > </el-table>async 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 ref="tableRef" :data="tableData" row-key="id" @selection-change="handleSelectionChange" > <el-table-column type="selection" width="55" reserve-selection></el-table-column> </el-table>reserve-selection这个属性很关键,它可以让表格在数据更新后尽量保留之前勾选的行。但它有要求:行的row-key必须稳定唯一,如果你用数组index做key,那数据一变,选中的行就跑偏了。
4.2 按业务规则二次处理选中态
既然原生不联动,就得自己写联动逻辑。我的做法是维护一个全局勾选Map:key是行id,value是那一行的完整数据。每次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 type="selection" width="55" :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-key="id",并要求后端保证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里不算无解,核心就是选对方案、把数据转换封装好、想清楚展开和勾选的边界。希望这篇实践整理能帮你少走点弯路。