简介:这是一份面向H5移动端开发者的部门树展示实战资源,适合企业内部管理系统、协作平台等需要呈现组织架构与员工层级关系的场景,也适合想掌握移动端树形列表实现思路的前端学习者。压缩包共11个文件,约76KB,包含6个png图标素材、2个js脚本、1个css样式、1个json数据文件和1个html页面,覆盖树形列表的渲染逻辑、样式布局与数据配置。资源围绕响应式设计、JSON递归解析、展开折叠交互、触摸友好与懒加载等要点展开,可帮助读者理解如何用原生JavaScript构建轻量级移动端部门树,并参考其目录组织与组件拆分方式。目前已有4162人学习下载,适合作为移动端树形控件开发与优化的实践参考。
1. 移动端部门树展示:为什么你的树在手机上总是卡、乱、点不准
做过 PC 端部门树的人,第一次把它搬到 H5 移动端,大概率会翻车。PC 上跑得好好的递归组件,到了手机上要么展开三层就掉帧,要么缩进把文字挤成竖排,要么手指点下去命中的是隔壁节点。这不是代码写错了,是移动端的交互模型和渲染约束跟桌面端根本是两套东西。部门树展示在 H5 移动端要解决的核心问题就三个:层级深、节点多、手指粗。层级深意味着递归渲染和缩进策略要重新设计,节点多意味着不能一次性把整棵树塞进 DOM,手指粗意味着点击热区、展开收起、横向滚动都要重新算。这篇笔记面向的是正在做企业通讯录、组织架构、审批流选人这类需求的移动端前端,从数据结构选型一路讲到虚拟滚动和手势冲突,中间给可抄的代码和参数,最后收在几个我踩过的坑上。如果你手上正好有一个几千人的部门树要在手机浏览器里跑起来,下面的内容可以直接对着改。
2. 部门树的数据结构与移动端渲染选型:扁平化还是嵌套
2.1 嵌套结构和扁平结构在移动端的真实差异
部门树的数据来源通常是后端返回的嵌套 JSON,每个节点带children数组。这种结构在 PC 端递归渲染很自然,但在移动端会带来两个麻烦。第一,展开某个节点时,你需要递归查找并修改深层节点,React 或 Vue 的不可变更新会变得很啰嗦,稍不注意就写出 O(n) 的深拷贝。第二,移动端要做虚拟滚动或懒加载时,嵌套结构很难直接映射成一条线性列表,因为虚拟滚动需要知道「当前可见的扁平索引范围」。
我一般会在拿到后端数据后立刻做一次扁平化,把树转成带parentId和depth的数组,同时保留一个childrenIds映射。扁平化之后,展开收起只是改一个expanded标记,虚拟滚动的可见区间计算也变成纯数组操作。代价是每次渲染要自己算「哪些节点当前可见」,但这个计算量远小于递归 diff。
// 把后端嵌套部门树扁平化为数组,保留父子关系和层级 function flattenDeptTree(nodes, parentId = null, depth = 0, result = []) { for (const node of nodes) { const flatNode = { id: node.id, name: node.name, parentId, depth, // 层级,用于缩进计算 expanded: depth < 1, // 默认只展开第一层,避免首屏节点过多 hasChildren: !!(node.children && node.children.length), childrenIds: (node.children || []).map(c => c.id), }; result.push(flatNode); if (node.children && node.children.length) { flattenDeptTree(node.children, node.id, depth + 1, result); } } return result; }这段代码的关键参数是depth和expanded。depth决定缩进像素,移动端屏幕窄,我一般把每层缩进控制在 16px 到 20px,超过 5 层就不再增加缩进,改用颜色深浅区分层级,否则文字区域会被挤没。expanded默认只展开第一层,是因为首屏如果展开全部,几千个节点直接进 DOM,手机浏览器会卡死。扁平化之后,可见节点列表的计算逻辑是:从根节点开始,如果一个节点是展开的,它的子节点才进入可见列表,否则跳过整个子树。
// 根据 expanded 状态计算当前可见的扁平节点列表 function getVisibleNodes(flatNodes) { const visible = []; const nodeMap = new Map(flatNodes.map(n => [n.id, n])); const skipSet = new Set(); // 记录被折叠父节点跳过的子树 for (const node of flatNodes) { if (node.parentId && skipSet.has(node.parentId)) { skipSet.add(node.id); // 父节点被折叠,自己也跳过,并继续传递给子节点 continue; } visible.push(node); if (node.hasChildren && !node.expanded) { skipSet.add(node.id); } } return visible; }这个getVisibleNodes是虚拟滚动的输入。注意skipSet的传递逻辑:一旦某个父节点被折叠,它的所有后代都要跳过,所以用 Set 逐层标记。这个函数在每次展开收起时执行一次,复杂度 O(n),对于几千个节点来说耗时在 1ms 以内,完全可以接受。真正影响性能的是 DOM 渲染,所以下一步要接虚拟滚动。
2.2 移动端虚拟滚动的选型:自己写还是用现成库
移动端部门树的虚拟滚动,常见做法有三种:用react-window/vue-virtual-scroller这类通用库、用移动端组件库自带的树组件、自己写一个定高虚拟列表。我的经验是,如果节点高度固定(比如每行 48px),自己写一个不到 100 行的虚拟列表最可控,因为部门树有展开收起、缩进、选中态这些定制需求,通用库的 API 反而会限制你。
自己写虚拟滚动的核心逻辑是:监听滚动容器的scrollTop,算出当前应该渲染的起始索引和结束索引,只渲染这个区间内的节点,上下用 padding 或绝对定位撑开总高度。关键参数是itemHeight(每行高度)、bufferSize(上下各多渲染几行,防止快速滚动白屏)。移动端我一般设bufferSize为 5 到 8,太少会闪白,太多浪费渲染。
// 定高虚拟列表核心计算:根据 scrollTop 算出可见区间 function getRenderRange(scrollTop, containerHeight, itemHeight, total, buffer = 6) { const start = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer); const end = Math.min(total, Math.ceil((scrollTop + containerHeight) / itemHeight) + buffer); return { start, end }; }itemHeight必须固定,这是自己写虚拟列表的前提。部门树每行高度固定是合理的,因为部门名称一般一行显示,超长用省略号。如果你们的设计要求部门名称换行显示,那节点高度就不固定,自己写虚拟列表会非常麻烦,这时候建议改用IntersectionObserver做懒加载,或者限制名称最多两行并固定行高。containerHeight是滚动容器的高度,移动端要注意键盘弹起时容器高度会变,需要监听resize重新计算。
选型上还有一个容易忽略的点:移动端浏览器(尤其是微信内置浏览器)的滚动事件触发频率和桌面端不同,scroll事件在快速滑动时可能一帧触发多次。我一般会用requestAnimationFrame做节流,把滚动计算放到下一帧,避免滚动过程中频繁 setState 导致卡顿。
提示:扁平化 + 虚拟滚动是移动端部门树的标准组合,但扁平化会丢失树的递归语义,如果后端接口需要回传完整路径,记得在扁平节点上额外存一个
path字段,不要临时递归查找。
3. 缩进、点击热区和展开收起:移动端交互的三个必调参数
3.1 缩进策略:为什么超过 5 层就不该再加 padding
PC 端部门树习惯用padding-left: depth * 24px做缩进,到了移动端,375px 宽的屏幕,5 层就是 120px,加上展开图标和复选框,部门名称只剩不到 200px,稍微长一点的名字就换行或者省略。我的做法是缩进只在前 4 层生效,每层 16px,第 5 层开始不再增加 padding,改用左侧色条或背景色深浅来区分层级。
/* 移动端部门树缩进:前4层线性缩进,第5层起封顶 */ .dept-node { padding-left: calc(var(--depth) * 16px); /* depth 超过 4 时,通过 JS 把 --depth 钳制为 4 */ } .dept-node[data-depth="5"], .dept-node[data-depth="6"] { padding-left: 64px; /* 4 * 16px,封顶 */ border-left: 3px solid rgba(0, 0, 0, 0.15); /* 用色条区分层级 */ }这里用 CSS 变量--depth由 JS 设置,超过 4 的层级在 JS 里钳制为 4,同时给节点加><!-- 部门树单行结构:展开图标独立热区,整行点击用于选中 --> <div class="dept-row">// 展开收起:只改状态,不做高度动画,虚拟列表自动重算 function toggleExpand(nodeId) { const node = nodeMap.get(nodeId); if (!node || !node.hasChildren) return; node.expanded = !node.expanded; // 重新计算可见节点,虚拟列表会根据新的可见列表渲染 visibleNodes = getVisibleNodes(flatNodes); // 如果展开的是当前滚动位置以上的节点,需要补偿 scrollTop,防止视觉跳动 compensateScroll(nodeId); }
compensateScroll是展开收起时防止滚动跳动的关键。如果用户展开了一个在视口上方的节点,可见列表变长,原本视口内的节点会被往下推,用户看到的内容突然跳了。补偿逻辑是:记录展开前该节点在可见列表中的索引,展开后重新计算索引,差值乘以itemHeight加到scrollTop上。这个细节不做,用户会觉得「点一下树就乱跑」。
注意:移动端
click事件有 300ms 延迟的历史问题,现代浏览器加了viewport的width=device-width后基本消失,但如果你的页面在微信里仍然感觉点击迟钝,检查是否缺少<meta name="viewport" content="width=device-width, initial-scale=1">,或者用了touchstart和click同时绑定导致触发两次。
4. 部门树在 H5 移动端的避坑与排查:5 个血泪教训
4.1 现象:展开三层后页面卡死,滚动掉帧到个位数
原因:没有做虚拟滚动,整棵树几千个节点全部渲染成 DOM。移动端浏览器对 DOM 节点数量敏感,超过 1500 个节点后滚动帧率明显下降,超过 3000 个基本不可用。另一个常见原因是每个节点都绑定了事件监听,几千个监听器内存占用高,滚动时事件冒泡处理也慢。
解决:接入虚拟滚动,只渲染可见区域加 buffer 的节点。事件用委托,在滚动容器上绑一个click,通过event.target.closest('.dept-row')找到对应节点 ID。如果节点数在 500 以内,可以不做虚拟滚动,但也要用DocumentFragment或框架的批量更新减少重排。
4.2 现象:快速滑动时白屏,停下来才渲染出内容
原因:虚拟滚动的bufferSize设得太小,或者滚动事件没有做requestAnimationFrame节流,导致滚动过程中渲染跟不上。移动端快速滑动的速度可以到每秒几千像素,如果 buffer 只有 2 行,一帧滚过 10 行,中间就是白屏。
解决:bufferSize调到 6 到 8,滚动计算用requestAnimationFrame包一层,确保每帧最多计算一次。另外,如果节点高度是 48px,容器高度 600px,一屏可见约 12 行,buffer 6 行意味着实际渲染 24 行,对性能影响很小,但白屏概率大幅降低。
4.3 现象:点击展开图标,结果触发了整行的选中,跳转到部门详情
原因:展开图标和整行点击事件冲突,没有阻止冒泡。移动端手指点击时,touchstart和click的触发顺序和桌面端不同,如果同时绑定了touchstart和click,可能触发两次。
解决:展开图标和复选框的点击处理函数第一行加event.stopPropagation()。统一用click事件,不要混用touchstart。如果确实需要更快的响应,用touchstart加preventDefault,但要处理好滚动和点击的区分,否则用户滑动时也会触发点击。
4.4 现象:部门名称太长,把展开图标挤没了,或者文字竖排
原因:flex 布局中,部门名称没有设min-width: 0或overflow: hidden,长文本把 flex 容器撑开,图标被挤出视口。另一个原因是缩进太大,文字区域宽度不够,浏览器把中文逐字换行,看起来像竖排。
解决:部门名称容器加flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;。缩进按 3.1 的方案封顶。如果产品要求显示全名,用点击弹出 tooltip 或详情页展示,不要在列表里换行。
4.5 现象:在微信里打开,树的高度不对,底部被工具栏遮挡
原因:微信内置浏览器的视口高度100vh不准确,底部工具栏会遮挡内容。另外,键盘弹起时resize事件触发,容器高度变化,虚拟滚动没有重新计算,导致渲染区间错位。
解决:不要用100vh做容器高度,用window.innerHeight动态计算,或者用flex: 1配合父容器height: 100%。监听resize和orientationchange,重新计算容器高度和渲染区间。微信里还要注意scroll事件的passive选项,如果调用了preventDefault但没设passive: false,控制台会报警告且滚动可能失效。
提示:排查移动端部门树性能问题时,优先用 Chrome DevTools 的 Performance 面板录制一段展开和滚动操作,看是 JS 计算耗时还是渲染耗时。如果是渲染耗时,检查 DOM 节点数;如果是 JS 耗时,检查
getVisibleNodes是否被频繁调用,加个缓存或防抖。
5. 进阶:用 IntersectionObserver 做懒加载 + 搜索定位的配合技巧
虚拟滚动解决了渲染性能,但部门树还有一个高频需求:搜索。用户输入「技术部」,树要自动展开到匹配节点并高亮。如果树是虚拟滚动的,匹配节点可能在当前渲染区间之外,直接scrollIntoView找不到 DOM。我的做法是:搜索时先根据关键词过滤出匹配节点,然后从匹配节点向上回溯所有父节点,把它们的expanded设为true,重新计算可见列表,找到匹配节点在可见列表中的索引,用scrollTop = index * itemHeight直接定位,不依赖 DOM。
// 搜索定位:展开匹配节点的所有父级,计算滚动位置 function locateNode(keyword) { const matched = flatNodes.filter(n => n.name.includes(keyword)); if (!matched.length) return; // 展开所有匹配节点的父级 for (const node of matched) { let parentId = node.parentId; while (parentId) { const parent = nodeMap.get(parentId); if (parent) parent.expanded = true; parentId = parent ? parent.parentId : null; } } visibleNodes = getVisibleNodes(flatNodes); const targetIndex = visibleNodes.findIndex(n => n.id === matched[0].id); if (targetIndex >= 0) { scrollContainer.scrollTop = targetIndex * ITEM_HEIGHT; } }这段代码的关键是while (parentId)循环向上展开所有祖先,确保匹配节点在可见列表中。ITEM_HEIGHT是固定行高,直接乘索引得到滚动位置,比scrollIntoView更可控,也不会因为虚拟滚动导致目标 DOM 不存在而失败。搜索高亮用 CSS 类标记,在渲染时判断节点 ID 是否在匹配集合中。
另一个进阶技巧是用IntersectionObserver替代滚动事件监听。虚拟滚动的滚动监听在移动端高频触发,虽然用requestAnimationFrame节流了,但仍有优化空间。IntersectionObserver可以监听一个「哨兵」元素,当它进入视口时触发加载或渲染,不需要监听scroll。不过IntersectionObserver的触发时机和虚拟滚动的区间计算需要配合,我一般只在懒加载子节点数据时用它,虚拟滚动的区间计算还是用scrollTop更直接。
最后说一个我自己的习惯:每次做完移动端部门树,我会在真机上用「开发者选项」里的「指针位置」功能,看每个可点击区域的实际命中范围,确保展开图标、复选框、整行的热区没有重叠或遗漏。这个检查花 5 分钟,能省掉后面一堆「点不准」的客诉。希望帮到你。
本文还有配套的精品资源,点击获取