简介:TreeGrid树枝表控件及Demo源码面向需要在Web项目中实现树形表格展示的开发者,尤其适合使用ASP.NET技术栈、希望快速集成可展开折叠层级数据表格的中初级程序员。资源包共105个文件,约175KB,以gif界面截图、cs源码文件、dll与pdb程序集、aspx页面及css样式表为主,另含svn版本控制相关文件与Web.config配置,整体体量轻巧,便于直接解压查看与二次开发。已有192人学习下载,可作为树形表格控件的入门参考。包内提供Default.aspx示例页面与Builder.cs等核心构建代码,配合截图可直观了解控件渲染效果与层级展开逻辑,帮助读者理解树枝表的数据组织方式、节点渲染流程及前后端配合要点,为在自有项目中复用或改造该控件提供可运行的Demo基础与调试线索。
1. 树形表格控件:为什么后台系统总绕不开它
做过管理后台的开发者大概都有过这种经历:需求文档上写着“菜单权限用树形展示,最好能像 Excel 一样直接编辑”,你打开组件库翻了一圈,发现 Tree 组件只能勾选不能编辑,Table 组件能编辑但展不开层级,最后只能自己拼一个。TreeGrid 树枝表控件就是冲着这个夹缝场景来的——它把树形结构和表格列布局揉在一起,既能像树一样展开收起父子节点,又能像表格一样按列对齐、按列编辑。这份源码包里除了控件本体,还带了可直接运行的 Demo,适合正在做权限菜单、组织架构、物料分类这类层级数据的开发者。拿到手先别急着改,跑通 Demo 看效果,再回头读实现,会顺很多。
2. 控件架构拆解:树形结构与表格渲染怎么合到一起
2.1 整体分层:数据层、视图层、交互层各管什么
TreeGrid 这类控件最容易写乱的地方,是把“树”和“表”的逻辑搅在一坨。合格的实现一般会拆成三层:数据层负责把扁平列表转成带父子引用的树,并维护展开状态;视图层负责按当前可见节点渲染行,同时保证列宽、表头、斑马纹这些表格特性;交互层处理展开收起、行选中、单元格编辑、列排序这些事件。三层之间通过一个状态对象通信,视图不直接改数据,交互也不直接操作 DOM。
我一般会先找源码里的状态定义,看它有没有把expandedIds、selectedId、columns这几样东西集中管理。如果散落在各个函数里,二次开发时加一个“默认全部展开”功能就得改五六个地方。Demo 里通常会有初始化配置的示例,从配置项能反推出状态结构,这是读源码最快的入口。
// 典型的状态对象结构(示意,具体字段名以源码为准) const state = { rawData: [], // 原始扁平数据 treeData: [], // 转换后的树形数据 expandedIds: new Set(), // 当前展开的节点 id 集合 selectedId: null, // 当前选中行 columns: [], // 列定义:field / title / width / editor visibleRows: [] // 当前可见行(展开状态过滤后) };这段结构的关键在于visibleRows是派生出来的,不是手动维护的。每次展开或收起,只需要改expandedIds,然后重算visibleRows再重渲染。这样避免了“展开一个节点结果行号全乱”的经典 bug。参数上expandedIds用 Set 而不是数组,是为了 O(1) 判断某节点是否展开,节点多了以后差别很明显。
2.2 扁平数据转树:一次遍历还是递归查找
层级数据从后端拿到的往往是扁平的,带id和parentId。转树有两种常见写法:一种是递归查找父节点再挂子节点,节点一多就是 O(n²);另一种是先建 id 到节点的映射,再一遍遍历挂载,O(n)。源码里如果用了前者,数据量上千就会卡,这是二次开发时值得留意的点。
// 两遍遍历建树:先建索引,再挂父子关系 function buildTree(list, idKey = 'id', parentKey = 'parentId') { const map = new Map(); const roots = []; // 第一遍:每个节点建索引,顺便初始化 children list.forEach(item => { map.set(item[idKey], { ...item, children: [] }); }); // 第二遍:挂到父节点下,找不到父节点的当根 list.forEach(item => { const node = map.get(item[idKey]); const parent = map.get(item[parentKey]); if (parent) { parent.children.push(node); } else { roots.push(node); } }); return roots; }逻辑说明:第一遍只做浅拷贝和索引,不碰父子关系;第二遍才根据parentId找父节点。这样每个节点只被访问两次,复杂度线性。参数idKey和parentKey做成可配置,是因为不同后端返回的字段名不统一,有的叫menuId,有的叫deptId,写死的话换个接口就得改源码。注意children要初始化为空数组而不是 undefined,否则渲染层判断node.children.length会报错。
2.3 行渲染与缩进:层级视觉怎么表达
树形表格和普通表格最大的视觉差异是缩进。常见做法是在第一列单元格里根据节点深度加左内边距,同时放一个展开箭头。深度可以在建树时算好存到节点上,也可以渲染时沿父链往上数。前者快但占内存,后者省内存但每次渲染都要遍历父链,节点深了会慢。
// 渲染单行时计算缩进和箭头 function renderRow(node, depth) { const indent = depth * 20; // 每层缩进 20px const hasChildren = node.children && node.children.length > 0; const arrow = hasChildren ? `<span class="tg-arrow ${state.expandedIds.has(node.id) ? 'expanded' : ''}">▶</span>` : '<span class="tg-arrow placeholder"></span>'; return `<tr>function toggleExpand(id) { if (state.expandedIds.has(id)) { state.expandedIds.delete(id); } else { state.expandedIds.add(id); } state.visibleRows = flattenVisible(state.treeData, state.expandedIds); renderTable(state.visibleRows); // 统一重渲染 } // 按展开状态把树拍平成可见行,同时记录每行深度 function flattenVisible(nodes, expandedIds, depth = 0, out = []) { nodes.forEach(node => { out.push({ node, depth }); if (expandedIds.has(node.id) && node.children.length) { flattenVisible(node.children, expandedIds, depth + 1, out); } }); return out; }flattenVisible是整条链路的核心:它把树按当前展开状态拍平成一个数组,每项带上深度,渲染层只管按数组顺序画行。这样“展开某节点后子节点插在哪”这种问题就不存在了,数组顺序天然正确。参数out用默认值做累加器,避免每次递归新建数组。
3. 跑起来:Demo 环境搭建与首次运行
3.1 目录结构先摸清,别上来就 npm install
拿到源码包,第一件事是看目录。TreeGrid 这类控件加 Demo 的组合,常见结构是源码目录、Demo 目录、样式目录、构建配置四块。先确认 Demo 是直接引源码还是引打包产物,这决定了你改完源码要不要重新构建。
# 典型目录结构(以实际源码为准) treegrid/ ├── src/ # 控件源码 │ ├── treegrid.js # 核心逻辑 │ └── treegrid.css ├── demo/ # 演示页面 │ ├── index.html │ └── demo.js ├── dist/ # 打包产物(如果有) └── package.json如果demo/index.html里 script 标签引的是../src/treegrid.js,那改源码刷新页面就生效,开发体验最好;如果引的是../dist/treegrid.min.js,就得先跑构建。看 package.json 的 scripts 字段能确认有没有 build 命令。没有构建配置的纯静态项目也常见,直接用浏览器打开 html 就行,但要注意本地文件协议下某些模块加载会受限,起个静态服务器更稳。
3.2 起服务与首次渲染验证
纯前端控件不依赖后端,起个静态服务器就能跑。用 Python 自带的 http.server 最省事,不用装额外依赖。
# 在项目根目录执行,端口按需改 python3 -m http.server 8080 # 浏览器访问 http://localhost:8080/demo/index.html打开后先确认三件事:树能不能展开收起、列对齐是否正常、控制台有没有报错。如果页面空白,先看控制台是不是 404——常见原因是 html 里引的路径和实际目录对不上,比如源码在src/但 html 写的是js/。如果树渲染出来了但点箭头没反应,多半是事件绑定没生效,检查是不是在 DOM 还没加载完就执行了初始化,把初始化包在DOMContentLoaded里通常能解决。
3.3 用 Demo 数据反推配置项
Demo 里的初始化代码是理解控件能力的最好材料。重点看它传了哪些配置:数据从哪来、列怎么定义、默认展开哪些节点、有没有开启编辑。把这些配置项抄下来,换成自己的数据试一遍,比读十页文档快。
// Demo 初始化示意:配置项是二次开发的抓手 const grid = new TreeGrid('#container', { data: menuData, // 扁平或树形数据 idField: 'id', parentField: 'parentId', columns: [ { field: 'name', title: '名称', width: 240, treeColumn: true }, { field: 'code', title: '编码', width: 160 }, { field: 'status', title: '状态', width: 100 } ], defaultExpandAll: false, // 是否默认全展开 editable: true // 是否开启单元格编辑 });treeColumn: true标记哪一列显示树形缩进和箭头,这个设计很关键——树形不一定在第一列,有的系统把名称放第二列,第一列放复选框。defaultExpandAll在菜单树场景常用,但节点多了会一次性渲染大量行,建议配合懒加载或限制层级。把data换成自己的数组,字段名通过idField、parentField对齐,基本就能跑起来。
4. 核心模块代码:编辑、选中与数据回写
4.1 单元格编辑:进入、提交、取消三态
树形表格的编辑比普通表格多一层顾虑:编辑到一半展开别的节点,正在编辑的值会不会丢。稳妥的实现会把“正在编辑的单元格”也放进状态,重渲染时如果该单元格仍在可见行里就恢复编辑态,不在就自动提交或取消。源码里如果有editingCell这类字段,说明考虑过这个场景。
// 进入编辑态:把单元格替换成输入框 function startEdit(rowId, field, cellEl) { const row = findRow(rowId); state.editingCell = { rowId, field, original: row[field] }; const input = document.createElement('input'); input.value = row[field] ?? ''; input.className = 'tg-editor'; input.addEventListener('blur', () => commitEdit(input.value)); input.addEventListener('keydown', e => { if (e.key === 'Enter') commitEdit(input.value); if (e.key === 'Escape') cancelEdit(); }); cellEl.innerHTML = ''; cellEl.appendChild(input); input.focus(); } function commitEdit(value) { const { rowId, field } = state.editingCell; findRow(rowId)[field] = value; // 回写到数据 state.editingCell = null; renderTable(state.visibleRows); // 重渲染还原成文本 }逻辑上,startEdit只负责把单元格变成输入框并记录原值,提交和取消分开处理。blur触发提交是为了点别处时自动保存,但要注意如果用户点的是“取消”按钮,blur 会先触发提交,所以取消按钮要用mousedown而不是click,或者在取消时设个标志位跳过提交。这个坑我在好几个项目里都踩过,表现是“点了取消结果值还是改了”。
4.2 行选中与父子联动:勾选要不要传染
带复选框的树形表格,产品经理最爱问“勾父节点要不要自动勾子节点”。这没有标准答案,取决于业务:权限分配通常要父子联动,分类选择往往不要。源码如果支持checkStrictly这类配置,说明两种模式都考虑了。联动逻辑本身不难,难的是半选状态——子节点勾了一部分,父节点要显示半选。
// 父子联动勾选:向下传染 + 向上回算半选 function toggleCheck(nodeId, checked, strict) { const node = findNode(nodeId); setChecked(node, checked); if (!strict) { // 向下:所有后代跟随 walkDescendants(node, n => setChecked(n, checked)); // 向上:逐级回算父节点状态 let parent = findParent(nodeId); while (parent) { const children = parent.children; const allChecked = children.every(c => c.checked); const someChecked = children.some(c => c.checked || c.indeterminate); parent.checked = allChecked; parent.indeterminate = !allChecked && someChecked; parent = findParent(parent.id); } } renderTable(state.visibleRows); }strict为 true 时只改当前节点,父子互不影响。向下传染用walkDescendants递归,向上回算用 while 循环沿父链走。indeterminate是半选标志,渲染时对应复选框的indeterminate属性,注意这个属性只能用 JS 设,HTML 属性里写不了,这是新手常卡的点。
4.3 数据回写与变更收集:改完怎么交给后端
控件里改的数据最终要提交。常见做法是控件内部维护一份数据副本,对外暴露getChanges()或getData()方法。增量提交比全量提交友好,但需要记录哪些行被改过。简单实现是在commitEdit时把rowId塞进一个dirtyRows集合,提交时只取这些行。
// 变更收集:编辑和勾选都记到脏集合 const dirty = new Set(); function markDirty(rowId) { dirty.add(rowId); } function getChanges() { return [...dirty].map(id => { const row = findRow(id); return { id, ...pick(row, ['name', 'code', 'status', 'checked']) }; }); } // 提交成功后清空脏标记 function clearDirty() { dirty.clear(); }pick是取指定字段的小工具,避免把children这种大对象也序列化进去。提交成功后一定要清脏标记,否则下次提交会把旧变更再发一遍。如果后端要求全量提交,直接遍历treeData拍平后返回即可,但要注意拍平顺序和父子关系,别把子节点发在父节点前面导致后端建树失败。
5. 避坑与排查:树形表格最容易翻车的几个地方
5.1 展开后行错位:多半是 key 重复或深度算错
现象:展开某个节点后,子节点插到了别的父节点下面,或者缩进层级乱跳。原因通常是数据里id有重复,Map建索引时后者覆盖前者,挂父子关系就挂错了;也可能是深度在拍平时没跟着递归传,所有行都按根层级渲染。解决:初始化前先校验 id 唯一性,重复的直接报错而不是静默覆盖;拍平函数里depth + 1要确认传给了递归调用,别写成depth++。
5.2 编辑态丢失:重渲染把输入框冲掉了
现象:正在编辑一个单元格,点了另一个节点的展开箭头,输入框消失,刚输入的内容没了。原因:展开触发了整表重渲染,而重渲染只按数据画文本,不知道有单元格在编辑。解决:把editingCell纳入状态,重渲染时检查当前行是否仍在可见行里,在的话重新生成输入框并聚焦,不在的话先提交再渲染。更简单的做法是展开前强制提交当前编辑,牺牲一点体验换稳定。
5.3 大数据量卡顿:一次性渲染几千行
现象:菜单树有几千个节点,默认全展开后页面卡死好几秒。原因:defaultExpandAll把所有节点都拍平渲染,DOM 节点数爆炸。解决:默认只展开第一层,或者引入虚拟滚动只渲染视口内的行。虚拟滚动对树形表格要额外处理——可见行数组是拍平后的,滚动时按索引取切片渲染即可,但行高必须固定,否则算不准偏移。
5.4 勾选状态与数据不同步:半选标志没清
现象:父节点显示半选,但子节点全勾上之后父节点还是半选。原因:向上回算时只更新了checked,忘了把indeterminate重置为 false。解决:每次回算都显式设置两个字段,parent.indeterminate = !allChecked && someChecked,全选时自然为 false。另外取消勾选时也要走同一套回算逻辑,别只在勾选时算。
5.5 样式冲突:表格边框和树形缩进打架
现象:引入控件样式后,缩进列的左边框时有时无,或者斑马纹在展开后错行。原因:缩进用的是单元格padding-left,但斑马纹按行索引奇偶算,展开收起后行索引变了,条纹跟着跳。解决:斑马纹按可见行数组的索引算而不是按数据原始索引,重渲染时自然重算;边框问题检查是不是box-sizing没统一,控件样式里加box-sizing: border-box通常能压住。
6. 二次开发:从改配置到加功能的实操路径
改造 TreeGrid 这类控件,我的习惯是先分清“配置能解决的”和“必须动源码的”。换字段名、调列宽、改默认展开层级,这些走配置就行,别碰源码;加懒加载、加拖拽排序、加自定义单元格渲染,才需要进源码。判断标准很简单:看初始化配置里有没有对应钩子,有就用钩子,没有再看源码里有没有预留扩展点。
加懒加载是个典型例子。菜单树节点多的时候,一次性拉全量数据不现实,常见做法是展开时才请求子节点。源码如果建树时假设数据已完整,就得改两处:一是toggleExpand里判断该节点children是否为空且未加载过,是的话先发请求再展开;二是flattenVisible要能处理“有子节点但未加载”的状态,箭头照常显示但点击触发加载。
// 懒加载改造:展开前先拉子节点 async function toggleExpandLazy(id) { const node = findNode(id); if (!state.expandedIds.has(id) && node.children.length === 0 && !node.loaded) { node.loading = true; renderTable(state.visibleRows); // 显示加载态 const children = await fetchChildren(id); // 你的请求方法 node.children = children.map(c => ({ ...c, children: [] })); node.loaded = true; node.loading = false; } toggleExpand(id); // 走原有展开逻辑 }改造后的验证方法:先断网看加载态是否正常显示、请求失败后能否重试;再快速连点箭头,确认不会重复发请求(用loading标志挡住);最后检查收起再展开,已加载的子节点是否还在,别每次展开都重新请求。这三步走完,懒加载基本就稳了。
另一个高频改造是自定义单元格渲染,比如状态列要显示成彩色标签而不是纯文本。源码里如果有formatter或render配置,直接在列定义里传函数即可;没有的话,找到渲染单元格的那段模板字符串,把文本插值换成函数调用,注意对返回值做转义,别把用户输入直接拼进 HTML。
| 改造需求 | 优先方案 | 需要动源码的情况 |
|---|---|---|
| 换字段名 | 配置 idField/parentField | 字段嵌套在多层对象里 |
| 默认展开层级 | 配置 defaultExpandLevel | 需要按权限动态决定 |
| 懒加载子节点 | 一般需改源码 | 源码无异步加载钩子 |
| 自定义单元格 | 配置 formatter | 需要复杂交互组件 |
| 拖拽排序 | 一般需改源码 | 源码无拖拽扩展点 |
表格里“需要动源码”的那几项,动手前先备份原文件,改完用 Demo 数据回归一遍展开、编辑、勾选三条主链路。我吃过一次亏:给一个后台加拖拽排序,改完只测了拖拽,没测展开,结果拖完再展开子节点顺序全乱——因为拖拽只更新了 DOM 顺序没更新数据数组。从那以后我每次改完控件,都强制把 Demo 里的展开、编辑、勾选、提交走一遍再交付。希望这些能帮到你,少走几个我踩过的坑。
本文还有配套的精品资源,点击获取