简介:面向Web开发者的TreeGrid树枝表控件及演示源码,主要解决企业后台系统中树形结构数据的层级展示与交互操作问题,适合正在使用ASP.NET WebForms、需要快速搭建树表联动页面的开发人员。压缩包为rar格式,共105个文件,大小仅175KB;内含21个gif图标用于节点展开/收起状态展示,5个cs文件包含Builder.cs等核心构建逻辑,aspx页面是演示入口,配合Web.config配置、css样式以及编译生成的dll程序集,可以清晰看出控件从数据组织到前端渲染的完整链路。SVN元数据文件虽然占比不少,但核心工程结构仍然完整,便于直接参考对照。通过Demo可重点学习TreeGrid控件的数据源绑定方式、树节点初始化写法、页面样式与图标资源的配合,以及如何将业务数据转化为树形表格。例如从Builder.cs可以了解树节点数据如何逐层生成,Default.aspx演示页面则展示了控件标签的声明与初始化参数。资源已有191人浏览学习,适合作为TreeGrid控件入门与二次开发的速查参考。
1. 为什么需要一个专门的树枝表:场景倒逼组件诞生
先聊一个实际场景。有一次我做后台管理系统,需求方给了一张组织架构数据表,部门有层级,子部门下面还有小组,小组里再挂人。如果按普通表格那样全拍平,一行一个部门,用户根本分不清谁是谁的下级。我当时的第一反应是用缩进模拟层级,数据渲染出来之后才发现问题一堆:缩进宽度不对齐、展开收起没有任何交互、没有连接线用户看不明白层级关系、父节点勾选子节点不同步。那段时间数据还少,凑合能用,但到了三级四级层级的时候,后台管理页基本没法看。
后来我决定自己写一个树形表格控件,也就是TreeGrid。这个控件说白了就是表格和树形结构的结合体:既保留表格的列字段展示能力,又具备树形结构的层级展开收起能力。你可以把它理解成Excel里点了分级分组之后的那个效果,每一行前面有个小箭头,点一下展开子行,再点一下收回去。
后来我把这个控件整理成了可复用的项目,附带了完整Demo源码,在没有依赖任何重型框架的情况下,用原生JavaScript实现了核心功能。这个项目很适合三类人看:一类是正在做后台管理系统、遇到类似层级表格展示需求的前端开发;一类是准备学习树形数据结构和DOM操作入门的初学者;还有一类是接手的旧项目技术栈比较老、没法轻易引入大型组件的维护者。
说到底,TreeGrid不是炫技的组件,它是解决实际业务问题的。这篇文章我把核心原理、源码结构、实现细节和踩过的坑完整拆开讲一遍,内容不算短,但你可以直接拿Demo源码对照着看,发现问题比单纯看概念快得多。
2. 树枝表的底层原理:数据、渲染、交互三层拆解
很多人上手写TreeGrid时第一反应是“不就是个递归渲染吗”,确实,核心思路不复杂,但真正实现出来能用的版本,需要同时处理好三个层面:数据结构怎么设计、视图怎么渲染、交互怎么维护状态。
2.1 数据的扁平化与父子关系的表达方式
树形数据的标准表达方式是嵌套对象,每个节点有children数组,数组里又是同样的节点结构。这是最符合人直觉的表示方式,JSON格式的数据天然支持这种嵌套。但渲染的时候有一个问题:我们最终要渲染到表格里的每一行,其实是扁平的,表格的DOM结构是一行一行排下去的,不存在“嵌套行”的概念。
所以核心转换逻辑就来了:要把树形嵌套数据展开成扁平数组,同时记录每个节点所在的层级深度。我把这个转换函数命名为flattenTree,思路就是深度优先遍历,每遇到一个节点就push进结果数组,记录它的level、parentId、hasChildren这些信息,然后判断它是否处于展开状态,如果展开就继续递归它的子节点。
function flattenTree(nodes, level = 0, parentId = null, expandedMap = {}) { const result = []; nodes.forEach(node => { const hasChildren = Array.isArray(node.children) && node.children.length > 0; const isExpanded = expandedMap[node.id] !== false; result.push({ ...node, level, parentId, hasChildren, isExpanded }); if (hasChildren && isExpanded) { result.push(...flattenTree(node.children, level + 1, node.id, expandedMap)); } }); return result; }expandedMap用来单独维护每个节点的展开状态,而不是直接把状态写到原始数据里。这是一个很重要的设计决定:数据结构只负责表达依赖关系和展示字段,UI状态必须和业务数据分离。
2.2 渲染层:为什么选择DOM操作而非直接拼接字符串
Demo源码里我用了createElement配合appendChild来渲染每行内容,没有用innerHTML拼接字符串。这个选择是有意的。用模板字符串拼HTML确实省事,代码也短,但有两个问题。
第一,事件绑定麻烦。拼接出的HTML字符串本身没有事件,你需要在渲染完成之后统一去getElementById或者querySelector找元素再绑事件,或者用事件委托。数据一更新,重新渲染,事件就全部失效,又得重新绑定。
第二,状态回写困难。如果你需要读取行内某个输入框的值、某个复选框的勾选状态,用字符串拼出来的DOM,你得通过DOM查询去找,代码绕一圈不说,还容易因为数组下标和DOM节点的对应关系搞错而出bug。
所以我选择了每次渲染时创建DOM节点、直接挂载事件的方式。虽然创建节点的代码看起来比较啰嗦,但逻辑是直接的:创建一个tr,循环列配置创建td,给td里的元素绑定事件,把tr挂到tbody上。这个思路在真实项目里更稳定,改起来也更清晰,一次绑定完成,重新渲染就重新创建,事件不会串。
2.3 交互层:展开收起、勾选、排序各自的数据流
展开收起是TreeGrid最核心的交互。用户点击行首的箭头箭头之后,需要做三件事:更新expandedMap里对应节点的状态、重新执行flattenTree、重新渲染表格。听起来简单,但这里有个细节容易忽略:点击展开的是“行”上的图标,你的事件处理函数里必须能拿到这个节点在原始树中的引用或者id,否则你没法准确定位是哪个节点被展开。
勾选联动是另一个典型交互。父节点勾选时要让所有子节点同步勾选,子节点勾选时父节点如果所有子节点都勾了、父节点要自动变成勾选状态,只要有任何一个子节点没勾、父节点就是半选状态。这个看起来业务逻辑像“遍历所有子节点”其实没那么简单,如果你从根节点判断,可能需要递归反复往上走很多层。我的方案是先收集所有叶子节点的选中状态,从下往上推算每个父节点的状态,避免重复遍历。
列排序我采用的是只对“当前层级可见数据”排序的方案。因为跨层级排序的语义本来就模糊,你按某个字段排序,到底是整个树一起排,还是每个层级内部各自排?我选了后者,原因是展开状态和父子关系不会被破坏,用户看到的是每个父节点下面的子节点保持彼此的相对顺序。
3. Demo源码的核心实现:一份可直接跑通逻辑主线
3.1 代码目录与文件职责
我把Demo源码按最小可运行原则设计,没有引入构建工具,也没有包管理器,打开index.html就能看到效果。目录结构大概是这样的:
treegrid-demo/ ├── index.html // 页面结构,表格的HTML骨架 ├── css/ │ └── treegrid.css // 表格样式、层级缩进、展开箭头样式 └── js/ ├── data.js // 模拟数据,三层树形结构 ├── treegrid.js // 核心控件代码 └── main.js // 入口文件,初始化控件treegrid.js是核心文件,对外暴露一个TreeGrid构造函数,接收容器DOM和配置项。配置项包括列定义、数据来源、是否支持多选、是否开启懒加载等。这个设计标准但实用,实际项目里我可以直接把这个文件拿过去,改一下列配置,替换数据源就能接入。
3.2 数据初始化与首屏渲染流程
用代码说明整个初始化流程比较直观:
const treeGrid = new TreeGrid({ container: document.getElementById('app'), columns: [ { field: 'name', label: '部门名称', width: '220px', render: (row) => row.name }, { field: 'manager', label: '负责人', width: '120px' }, { field: 'count', label: '人数', width: '80px', align: 'center' }, { field: 'createTime', label: '创建时间', width: '160px' } ], data: treeData, showCheckbox: true, defaultExpandLevel: 1, onExpand: (node) => console.log('展开节点:', node.name) });首屏渲染分四步:先把数据用flattenTree展平,拿到包含level字段的扁平数组,然后遍历数组生成tr节点,根据level设置padding-left,最后根据expandedMap设置箭头的方向状态,有children但是收起状态的节点显示“展开箭头”,没有children的节点显示空白占位。
渲染完成后,事件委托机制开始工作。整个tbody上只绑定了一个click事件,通过event.target.closest('[data-action]')判断点击的是展开图标、复选框还是其他操作按钮,然后对应分发到不同的处理逻辑。为什么不给每个节点单独绑事件?因为事件委托在大量数据场景下的性能优势非常明显,行数多的时候几千个事件监听器会拖慢页面,一个监听器的性能开销几乎可以忽略。
3.3 列配置驱动的单元格渲染器设计
列配置里我留了一个自定义渲染器的入口,也就是render函数。这个设计极大提升了组件的适用性。看上面的配置,第一列我没直接显示原始字段,而是拼接成了“部门名称+人数”的组合内容。
真实项目里这种需求非常多,比如某个字段是状态码,你要翻译成状态标签;某个字段是时间戳,你要格式化成“yyyy-MM-dd”;某个字段是用户头像,你要渲染成img标签。如果没有render函数,这些逻辑全得写在组件内部,组件会被各种业务细节污染。有了render函数,组件只负责“把配置提供的函数跑一遍、把结果塞进单元格”,业务方自己控制怎么展示。
render: (row, column) => { if (row.status === 1) { return `<span class="tag tag-success">启用</span>`; } return `<span class="tag tag-danger">停用</span>`; }这里返回字符串会被作为innerHTML处理,返回值是DOM节点也可以支持。两种方式各有适用场景,字符串方式更简单,DOM节点方式更安全不会被XSS,Demo里我两处都用了,方便你对比。
4. 展开折叠和勾选联动的细节:手感从哪来
4.1 展开收起的状态保持与动画过渡
实际使用TreeGrid时,用户最在意的往往是展开收起的动画效果。标签代码一秒切完、下一行突然出现,视觉上很生硬,体验好的TreeGrid应该有一个自然的过渡。
但我先踩了个坑:给tr加CSS过渡动画,height从0到实际高度。问题在于表格行的height比较特殊,tr的样式控制不像div那么灵活。很多浏览器里tr的height直接由内容撑开,你设一个transition属性根本没有变化过程。
后来我尝试了优先渲染内部内容、再动态设置高度。展开时先拿到子行的根tr,渲染内容但不显示,测量scrollHeight,再把这个高度作为一个过渡的帧起点,拉长到最终高度。收起时反过来,先设置一个明确的高度,等待过渡结束再真正隐藏。
.tg-row-expand { display: none; } .tg-row-expand.tg-expanding { display: table-row; animation: tgExpand 0.2s ease-out; } .tg-row-collapsing { animation: tgCollapse 0.2s ease-in; }用动画关键帧来模拟高度过渡,比直接操作style.height靠谱,至少在Chrome里实测稳定。这类细节很多组件库不一定处理好,因为它涉及的边界情况太多——数据动态变化的行、空数据行、懒加载后还没有子数据的行,高度计算全都不同。
4.2 勾选联动的三态逻辑:全选、半选、无选
树形表格的复选框,相比普通表格多了一个“半选”状态。半选不是说数据上勾了或者没勾,而是视觉上父节点显示一个横杠,表达“部分子节点被选中了”。
我实现三态选择的核心逻辑是维护一个checkedMap,结构是{ [节点id]: true/false }。勾选一个节点时,先把自己的值写进map,然后递归所有子节点写同样的值。父节点的状态不是单独记录的,而是每次变化之后重新计算:
function updateAncestors(nodeId) { let parentId = nodeMap[nodeId].parentId; while (parentId) { const siblings = nodeMap[parentId].children; const allChecked = siblings.every(child => checkedMap[child.id]); const partChecked = siblings.some(child => checkedMap[child.id]); checkedMap[parentId] = allChecked ? 'checked' : (partChecked ? 'indeterminate' : 'unchecked'); parentId = nodeMap[parentId].parentId; } }注意这里checkedMap里存的不是布尔值,而是三态字符串。真正提交数据时,只收集状态为checked的叶子节点。这种设计让组件行为符合用户预期:只把最末级的选项作为实际业务值,父级勾选状态只是“快捷操作”的辅助表达。
4.3 键盘操作支持:通过方向键遍历节点
表格类控件还有一个容易被忽略的体验点——键盘操作。虽然鼠标点击是主流,但后台系统重度用户往往是键盘党,他们希望用上下左右方向键就能在树节点间移动。
Demo里我实现了上下键切换行焦点,右键展开当前节点、左键收起当前节点,空格键切换勾选。这个实现不复杂,维护一个activeNodeId,方向键事件里根据flatten后的数组索引做移动。核心是把键盘事件绑在表格容器上而不是每个tr上,容器监听keydown事件,然后根据当前activeNodeId找到扁平数组里的当前位置,再根据按键类型计算目标位置。
这个功能虽然代码量不大,但它极大提升了一个表格组件“够不够专业”的印象分。很多开源组件树形表格连键盘支持都没有,但实际越用越觉得鸡肋。做出来之后,我自己的后台系统操作效率明显上了一个台阶。
5. 我踩过的坑:缩进异常、懒加载陷阱和数据更新性能
5.1 缩进样式为什么经常错位
树形表格最常见的一个视觉问题是缩进不对齐。每一行的第一个单元格要往右缩进,缩进量等于所处分层数乘以单层缩进宽度,这个逻辑理论上很直接,但实际经常出现错位。
容易忽视的原因是第一列可能还有一个复选框,复选框的宽度占位会挤掉缩进的空间。如果你给第一格的padding-left设了固定值,但复选框宽度是20px,导致实际有效缩进宽度不一致,视觉上就会一层比一层错位。
解决方案是第一个单元格里用一个固定的占位元素来控制缩进,复选框在这个占位元素后面紧跟,这样不管复选框宽度怎么变化,层级缩进始终以占位元素为基准。
<td> <span class="tree-indent" style="padding-left: ${level * 20}px;"> {hasChildren ? '<span class="tree-toggle">▸</span>' : '<span class="tree-placeholder"></span>'} {showCheckbox ? '<input type="checkbox">' : ''} </span> <span class="tree-label">{label}</span> </td>.icon和checkbox放在同一个缩进容器里,而不是放在外面,缩进对齐就比较稳定,不容易错位。
5.2 懒加载模式下展开状态丢失
懒加载是树形表格非常重要的能力。如果数据量大,一次性全部渲染会导致首屏加载几百毫秒甚至几秒;懒加载则是在展开节点时才向服务器请求子节点数据,展开后缓存起来,收回去再展开不重复请求。
但懒加载有一个非常隐蔽的坑:如果用户在展开后修改了数据,然后又折叠了节点,下次展开会发现状态丢了。原因是折叠的时候子数据被清空了,或者展开状态缓存了,但子数据被丢掉了。
我的解法是把懒加载的子节点也缓存到一个独立的数据池里,折叠时只清理视觉渲染,不清理数据缓存。再次展开时先判断数据池里有没有数据,有就直接渲染,没有才发请求。这个模式在树形表格里叫client-side caching,实现不复杂,但能把后端请求压力降低一个数量级。
async function toggleExpand(node) { if (!node.isExpanded && !node.children && !node._loaded) { const children = await fetchChildren(node.id); node.children = children; node._loaded = true; } node.isExpanded = !node.isExpanded; render(); }_loaded这个标记位表示子数据是否已经加载过,展开时如果为false就请求数据,为true就直接用缓存。这个标记位需要在初始化时挂到节点对象上,而不是存到全局数组里,避免多次渲染时标记位丢失。
5.3 大列表渲染卡顿和首屏白屏
1000行以内的数据,直接渲染问题不大。但到了5000行,每次滚动都重绘所有行会非常卡。严格来说这个问题的终极解法是虚拟滚动,只渲染可见区域的行,但虚拟滚动和树形表格结合实现复杂度比较高,因为行高会因为层级缩进和内容不同而不一致,计算滚动位置时边界情况很多。
我的折中方案是分块渲染。首屏只渲染前200行,用户滚动到底部附近时再追加渲染下一批,每次追加200行。这个方案的实现思路比虚拟滚动简单,它能解决90%的真实场景问题——大部分后台系统的树形结构数据总量也就几千行。如果你的数据量到几万行,建议优先考虑虚拟滚动方案,这个组件Demo里没有完整实现,但我在源码里留了注释和思路。
6. 这套Demo怎么用:接入方式与扩展思路
6.1 三步接入你自己的数据
第一步,替换data.js里的模拟数据,改成你实际的树形结构数据,确保每个节点有唯一id和可选的children。第二步,在main.js里调整columns配置,把字段名换成你的字段。第三步,如果有勾选提交需求,调用treeGrid.getCheckedNodes(),返回选中的叶子节点数组。
const checkedNodes = treeGrid.getCheckedNodes(); // [{id: 'node-04', name: '前端小组', parentId: 'node-02'}, ...]如果你的后端接口返回的不是嵌套结构而是平铺列表,字段带parentId,那接入时可能需要先转换一次。这里给一个简单转换函数:
function listToTree(list) { const map = {}; const roots = []; list.forEach(item => { map[item.id] = { ...item, children: [] }; }); list.forEach(item => { if (map[item.parentId]) { map[item.parentId].children.push(map[item.id]); } else { roots.push(map[item.id]); } }); return roots; }这个转换处理不了乱序数据,父节点还没出现在map里子节点就已经被挂载了,会导致节点丢失。如果后端数据顺序不稳定,用两次遍历先建map再挂载,不要一次遍历同时做两件事。
6.2 按需扩展:行拖拽排序、单元格编辑和联动高亮
Demo的核心功能是树形展示、展开收起、勾选联动,但你接入实际项目之后基本都会面临扩展需求。我在这套源码的注释里预留了几个常用的扩展点。
行拖拽排序是最常见的需求之一。实现思路是在tr上绑定dragstart和dragover事件,拖拽时记录目标节点的位置,drop时把该节点从原父节点的children数组里移除,再插入到新父节点的children数组合适位置。难点在于树形结构下“放到某个节点上面、里面还是下面”的语义判定,我的建议是拖拽时高亮目标行的上半部分表示插入到前面,下半部分表示插入到后面,中间缩进区域表示成为其子节点。
单元格编辑需求可以通过render函数实现,返回一个input元素而不是字符串,给input绑change事件,事件里更新数据源并重新渲染。注意渲染完成后的输入框会丢失用户尚未提交的输入,所以change和blur事件都要做数据同步,否则用户输入一半点别处重新渲染,内容就丢了。
联动高亮适合用在审批流、组织架构这类场景:选中某个部门,自动高亮它所有的上级和下级。实现上也简单,根据树形关系向上找祖先、向下找后代,给这些行加一个背景类名,重绘时保留选中态。这些扩展点的实现代码,我在源码里都用// === 扩展点 START ===注释标出来了,你直接往对应位置填逻辑就行。
6.3 关于组件定位的一些最后想法
顺便说一句定位。如果你在做的新项目,没有任何遗留负担,我还是建议优先考虑用成熟开源组件库的树形表格,比如Element Plus的el-table树形数据、Ant Design的Table树形展示,这些组件经过大量线上项目验证,边界情况处理得比我这个Demo丰富得多。
那为什么还要自己写一套?一方面,老项目技术栈不是Vue也不是React,原生JavaScript的组件可以直接塞进去;另一方面,自研控件最大的好处是可控性强,出了问题不需要依赖社区修复,自己改代码就能解决。我维护这套Demo源码一年多,后来每个接入项目的特定样式、特殊交互我都能快速适配,这种自由度是第三方组件给不了的。
另外,自己实现一遍树形表格,对理解前端框架里树形组件的底层逻辑也有很大帮助。你再去用Element Plus的树形表格时,看到expand-row-keys、tree-props这些配置项,脑子里会有很清楚的数据流路径,排起bug来也快很多。
这套源码的整体设计是靠数据结构建模、状态管理这些基本功撑起来的,没有依赖任何魔法。读完代码之后你可以自己试着扩展一个功能,比如增加图标自定义、支持过滤条件高亮匹配节点,改着改着,你对组件开发的思路就打开了。
本文还有配套的精品资源,点击获取