拓冰建站拓冰建站
首页 / 资讯中心 / 正文

原生JavaScript实现TreeGrid树形表格:原理、源码与踩坑实践

简介面向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 classtag tag-success启用/span; } return span classtag tag-danger停用/span; }这里返回字符串会被作为innerHTML处理返回值是DOM节点也可以支持。两种方式各有适用场景字符串方式更简单DOM节点方式更安全不会被XSSDemo里我两处都用了方便你对比。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 classtree-indent stylepadding-left: ${level * 20}px; {hasChildren ? span classtree-toggle▸/span : span classtree-placeholder/span} {showCheckbox ? input typecheckbox : } /span span classtree-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来也快很多。这套源码的整体设计是靠数据结构建模、状态管理这些基本功撑起来的没有依赖任何魔法。读完代码之后你可以自己试着扩展一个功能比如增加图标自定义、支持过滤条件高亮匹配节点改着改着你对组件开发的思路就打开了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门