简介:这是一份面向H5移动端开发者的部门树展示实战资源,适合企业内部管理系统、协作平台等需要呈现组织架构与员工层级关系的场景,也适合想掌握移动端树形列表实现思路的初中级前端人员参考。压缩包共11个文件,约76KB,包含6个png图标素材、2个js脚本、1个css样式、1个json数据文件与1个html页面,覆盖树形列表的渲染逻辑、样式与示例数据。资源围绕响应式布局、JSON递归解析、展开折叠交互、触摸友好与懒加载等要点展开,可帮助读者理解移动端树形控件的构建方式,并借助现成脚本与样式快速搭建可运行的部门树示例,对照调试与二次开发。目前已有4163人学习下载,具备一定的参考热度。
1. 部门树在 H5 移动端为什么总翻车:从一次真实踩坑说起
做过后台系统的都清楚,部门树在 PC 端用个 el-tree 或者 antd 的 Tree 组件,半天就能收工。可一旦搬到 H5 移动端,事情就变了味。屏幕只有 375px 宽,层级动辄五六层,节点数轻松破千,用户还要在弱网环境下用手指戳来戳去。我见过太多项目,PC 端跑得好好的部门树,到了手机上要么卡成幻灯片,要么展开三层就找不到北,要么点击热区小得让人抓狂。
这个标题「h5移动端部门树展示」说的就是这件事:在手机浏览器或内嵌 WebView 里,把组织架构的树形数据渲染出来,并且让用户能顺畅地展开、折叠、搜索、选中。它解决的核心问题是——在有限的屏幕空间和触控交互下,如何让层级数据既看得清又点得准。适合谁看?前端工程师、移动端 H5 开发者,以及那些正在被「部门选择器」需求折磨的人。接下来我会把选型、数据结构、渲染方案、性能优化和避坑经验一次讲透,代码可以直接抄。
2. 移动端部门树的数据结构与扁平化处理:为什么不能直接递归渲染
2.1 后端返回的嵌套 JSON 有什么问题
大多数后端接口返回的部门数据长这样:每个节点带一个children数组,层层嵌套。PC 端组件通常能直接吃这种结构,但移动端不行。原因有三个:第一,递归渲染在节点多时会触发大量组件实例化,内存和渲染开销陡增;第二,移动端需要虚拟列表来应对长列表,而虚拟列表要求数据是扁平的;第三,展开/折叠状态如果绑在嵌套结构上,更新时容易触发整棵树重渲染。
所以第一步永远是:把嵌套树拍平成一维数组,同时保留父子关系和层级信息。
2.2 扁平化的核心字段设计
我一般会设计这样几个字段:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | string | 部门唯一标识 |
| parentId | string | 父部门 id,根节点为 null |
| name | string | 部门名称 |
| level | number | 层级,根为 0 |
| expanded | boolean | 是否展开 |
| visible | boolean | 当前是否可见(由祖先展开状态决定) |
| hasChildren | boolean | 是否有子节点 |
| path | string[] | 从根到当前节点的 id 路径 |
其中visible是关键。扁平化之后,渲染层只关心visible === true的节点,展开/折叠只需要批量修改子孙节点的visible,不用递归组件。
2.3 扁平化转换的代码实现
/** * 将嵌套部门树拍平为一维数组 * @param {Array} tree - 后端返回的嵌套树 * @param {string|null} parentId - 父节点 id * @param {number} level - 当前层级 * @param {Array} path - 祖先路径 * @returns {Array} 扁平节点数组 */ function flattenDeptTree(tree, parentId = null, level = 0, path = []) { const result = []; for (const node of tree) { const currentPath = [...path, node.id]; const flatNode = { id: node.id, parentId, name: node.name, level, expanded: false, // 默认全部折叠 visible: level === 0, // 只有根节点默认可见 hasChildren: Array.isArray(node.children) && node.children.length > 0, path: currentPath, }; result.push(flatNode); if (flatNode.hasChildren) { // 递归处理子节点,层级 +1 result.push(...flattenDeptTree(node.children, node.id, level + 1, currentPath)); } } return result; }逻辑说明:这个函数用深度优先遍历,把每个节点转成扁平对象。visible初始只有根节点为 true,意味着默认全部折叠,用户看到的是第一层部门。path记录了从根到当前节点的完整路径,后面做搜索定位和面包屑导航时会用到。
参数说明:tree是原始嵌套数组;parentId在递归时传入当前节点 id;level控制缩进层级;path用于记录祖先链。注意这里没有用reduce或map的奇技淫巧,因为递归加展开运算符在节点数上千时性能更可控,也更好调试。
2.4 展开与折叠的状态更新
扁平化之后,展开一个节点要做的事很明确:找到它的所有子孙节点,把visible设为 true。折叠则相反。但这里有个坑——不能无脑遍历整个数组,否则每次点击都 O(n)。我的做法是预先建立parentId -> childrenIds的索引。
/** * 构建父子索引,加速展开/折叠 * @param {Array} flatList - 扁平节点数组 * @returns {Map} parentId 到直接子节点 id 数组的映射 */ function buildChildrenIndex(flatList) { const index = new Map(); for (const node of flatList) { if (node.parentId !== null) { if (!index.has(node.parentId)) { index.set(node.parentId, []); } index.get(node.parentId).push(node.id); } } return index; } /** * 切换节点展开状态,返回需要更新的节点 id 集合 * @param {string} nodeId - 被点击的节点 id * @param {Array} flatList - 扁平数组 * @param {Map} childrenIndex - 父子索引 * @returns {Set} 需要切换 visible 的节点 id 集合 */ function toggleExpand(nodeId, flatList, childrenIndex) { const node = flatList.find(n => n.id === nodeId); if (!node || !node.hasChildren) return new Set(); const targetVisible = !node.expanded; const affected = new Set([nodeId]); const stack = [nodeId]; // 广度优先收集所有子孙节点 while (stack.length) { const current = stack.pop(); const children = childrenIndex.get(current) || []; for (const childId of children) { affected.add(childId); stack.push(childId); } } return affected; }逻辑说明:buildChildrenIndex在数据初始化时执行一次,把每个父节点和它的直接子节点 id 关联起来。toggleExpand用栈做广度优先遍历,收集所有子孙节点 id。实际更新状态时,只需要遍历这个 Set,把对应节点的visible和根节点的expanded改掉,避免全量扫描。
参数说明:childrenIndex是 Map 结构,key 是父节点 id,value 是子节点 id 数组。affected集合包含被点击节点及其所有后代,更新时只操作这些节点。注意这里没有直接修改原数组,而是返回受影响集合,方便在 React/Vue 中做不可变更新。
3. 渲染方案选型:虚拟列表 + 缩进布局怎么落地
3.1 为什么移动端必须上虚拟列表
部门树在移动端最常见的翻车场景是:节点数超过 500,用户展开几个大部门后,DOM 节点数飙升到两三千,滚动直接掉帧。移动端浏览器对 DOM 数量的容忍度远低于 PC,超过 1500 个节点就会明显卡顿。虚拟列表的核心思路是:只渲染视口内可见的节点,上下用占位元素撑起滚动高度。
但虚拟列表用在树形结构上有个特殊要求——节点高度必须固定,或者至少可预测。如果每个节点高度不一样,滚动位置计算会非常麻烦。我的建议是:移动端部门树节点统一高度,比如 48px 或 56px,名称过长用省略号,不要换行。
3.2 基于固定行高的虚拟列表实现
/** * 计算虚拟列表当前应渲染的节点范围 * @param {number} scrollTop - 滚动容器当前滚动距离 * @param {number} containerHeight - 滚动容器可视高度 * @param {number} itemHeight - 单行高度 * @param {number} totalCount - 可见节点总数 * @param {number} buffer - 上下缓冲行数 * @returns {Object} { startIndex, endIndex, offsetY } */ function calcVisibleRange(scrollTop, containerHeight, itemHeight, totalCount, buffer = 5) { const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer); const visibleCount = Math.ceil(containerHeight / itemHeight); const endIndex = Math.min(totalCount - 1, startIndex + visibleCount + buffer * 2); const offsetY = startIndex * itemHeight; return { startIndex, endIndex, offsetY }; }逻辑说明:scrollTop是滚动容器当前的滚动距离,除以itemHeight得到当前视口起始索引。减去buffer是为了在快速滚动时提前渲染,避免白屏。endIndex加上两倍 buffer 保证上下都有缓冲。offsetY是渲染区域的偏移量,用于设置内部容器的transform: translateY。
参数说明:itemHeight必须和 CSS 中实际行高一致,否则会出现滚动错位。buffer一般取 3 到 8,太小会白屏,太大会增加渲染量。totalCount是当前visible === true的节点数量,不是总节点数。
3.3 缩进与连接线的 CSS 处理
移动端屏幕窄,缩进不能太大。我的经验是每层缩进 16px,最多支持 6 层,超过 6 层后缩进不再增加,改用层级标签显示。连接线用伪元素画,不要用图片,减少请求。
.dept-tree-node { height: 48px; display: flex; align-items: center; padding-right: 12px; box-sizing: border-box; position: relative; } /* 缩进通过 padding-left 控制,由 JS 计算后内联设置 */ .dept-tree-node__indent { flex-shrink: 0; } /* 展开/折叠箭头 */ .dept-tree-node__arrow { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; transition: transform 0.2s; } .dept-tree-node__arrow--expanded { transform: rotate(90deg); } /* 连接线:用伪元素画竖线 */ .dept-tree-node__line { position: absolute; left: 0; top: 0; bottom: 0; width: 1px; background: #e5e5e5; }逻辑说明:节点高度固定 48px,保证虚拟列表计算准确。缩进不用 margin 而用 padding-left,因为 padding 不会影响盒模型宽度计算。箭头旋转用 CSS transform,比切换图标性能好。连接线用绝对定位的伪元素,每层一条,通过 JS 计算 left 值。
参数说明:padding-right: 12px给右侧留出点击区域。flex-shrink: 0防止缩进元素被压缩。箭头旋转角度 90 度表示展开,0 度表示折叠。连接线颜色用浅灰,避免视觉干扰。
3.4 点击热区与触控优化
移动端手指点击精度远低于鼠标,部门树节点的点击热区至少要 44x44px。但节点高度只有 48px,宽度又受缩进影响,怎么办?我的做法是:整个节点行都是可点击区域,展开箭头单独绑定事件并阻止冒泡。选中部门用右侧的复选框或整行点击,根据业务需求定。
// 事件委托:在滚动容器上监听点击,而不是每个节点绑定 container.addEventListener('click', (e) => { const arrow = e.target.closest('.dept-tree-node__arrow'); if (arrow) { e.stopPropagation(); const nodeId = arrow.dataset.id; handleToggle(nodeId); return; } const node = e.target.closest('.dept-tree-node'); if (node) { const nodeId = node.dataset.id; handleSelect(nodeId); } });逻辑说明:用事件委托代替逐节点绑定,减少内存占用和事件监听器数量。closest方法向上查找最近的匹配元素,兼容性在移动端没问题。箭头点击阻止冒泡,避免触发选中。
参数说明:dataset.id从 DOM 上读取节点 id,避免闭包引用。handleToggle和handleSelect是业务回调。注意滚动容器要设置-webkit-tap-highlight-color: transparent去掉点击高亮。
4. 搜索、定位与大数据量下的性能排查:几个必调的参数
4.1 搜索过滤后如何保持树形上下文
部门树搜索有个经典问题:用户搜「市场」,匹配到第三层的「市场部」,但它的父节点和祖先节点默认是折叠的,直接显示匹配节点会让人不知道它在哪。正确做法是:搜索时自动展开匹配节点的所有祖先,并把匹配节点高亮。
/** * 搜索部门,返回匹配节点 id 集合和需要展开的祖先 id 集合 * @param {string} keyword - 搜索关键词 * @param {Array} flatList - 扁平节点数组 * @returns {Object} { matchedIds, ancestorIds } */ function searchDept(keyword, flatList) { const matchedIds = new Set(); const ancestorIds = new Set(); const lowerKeyword = keyword.toLowerCase(); for (const node of flatList) { if (node.name.toLowerCase().includes(lowerKeyword)) { matchedIds.add(node.id); // 把路径上所有祖先加入待展开集合 for (const ancestorId of node.path) { if (ancestorId !== node.id) { ancestorIds.add(ancestorId); } } } } return { matchedIds, ancestorIds }; }逻辑说明:遍历扁平数组,用includes做模糊匹配。匹配到节点后,把它的path中除自身外的所有祖先 id 加入ancestorIds。渲染时先展开这些祖先,再高亮matchedIds中的节点。
参数说明:keyword要做 trim 和转小写处理。path字段在扁平化时已经生成,直接复用。注意搜索不要用正则,避免特殊字符导致报错。
4.2 大数据量下的性能参数表
当部门节点超过 2000 个时,下面这几个参数必须调:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 虚拟列表 buffer | 5 | 太小快速滚动白屏,太大渲染压力大 |
| 节点高度 | 48px | 低于 44px 触控不准,高于 56px 一屏显示太少 |
| 展开动画时长 | 0ms | 移动端树形展开不要做动画,直接切换 |
| 搜索防抖 | 300ms | 输入过程中不要实时搜索 |
| 初始展开层级 | 1 | 默认只展开根节点,避免首屏渲染过多 |
| 最大缩进层级 | 6 | 超过 6 层不再增加缩进,改用层级标签 |
4.3 排查渲染卡顿的常用手段
如果页面还是卡,按这个顺序排查:第一,打开 Chrome DevTools 的 Performance 面板,录制一次展开操作,看是 JS 执行时间长还是渲染时间长。第二,检查visible节点数量,如果超过 200 但虚拟列表没生效,说明虚拟列表配置有问题。第三,看是否有频繁的 layout thrashing,比如在循环里读取offsetHeight。第四,检查事件监听器数量,如果每个节点都绑了事件,改成事件委托。
提示:移动端调试尽量用真机,模拟器的性能表现和真机差距很大,尤其是低端安卓机。
5. 避坑指南:部门树在 H5 上最容易翻车的 5 个地方
5.1 展开后滚动位置跳动
现象:用户展开一个位于屏幕下方的部门,展开后页面自动滚到了顶部,用户找不到刚才点的节点。
原因:虚拟列表在节点数量变化后重新计算了总高度,但没有保持滚动锚点。或者展开时触发了组件重新渲染,滚动容器被重置。
解决:展开前记录当前滚动位置和点击节点的索引,展开后根据新增节点数量调整scrollTop。具体做法是:展开后,计算被点击节点在可见列表中的新索引,用index * itemHeight设置滚动位置。
5.2 快速点击导致状态错乱
现象:用户快速连续点击多个展开箭头,树形结构显示错乱,有的节点展开了但子节点没显示,有的节点折叠了但子节点还在。
原因:状态更新是异步的,连续点击时每次更新都基于旧的flatList,导致visible计算错误。
解决:用函数式更新,每次更新都基于最新的状态。在 React 中用setState(prev => ...),在 Vue 中用this.$nextTick或直接操作响应式数组。另外可以在展开/折叠时加一个 200ms 的锁,防止过快操作。
5.3 弱网下接口返回慢导致白屏
现象:用户打开部门树页面,接口还没返回,页面一片空白,用户以为卡死了。
原因:没有做加载态和骨架屏。
解决:接口请求发出后立即显示骨架屏,至少渲染 5 到 8 个灰色占位节点。接口返回后替换为真实数据。如果接口超过 3 秒没返回,显示「加载中,请稍候」的提示。另外部门数据可以缓存到 localStorage,第二次打开时先显示缓存,再后台刷新。
5.4 节点名称过长导致布局错乱
现象:部门名称有 20 多个字,在 375px 屏幕上换行,节点高度变成 96px,虚拟列表计算全部错位。
原因:没有限制名称显示长度,或者用了white-space: normal。
解决:节点名称强制单行,用text-overflow: ellipsis加省略号。如果业务必须显示全名,点击节点后在详情面板或弹窗中显示完整名称。CSS 这样写:
.dept-tree-node__name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 14px; color: #333; }5.5 选中态与展开态事件冲突
现象:用户想展开部门,结果触发了选中;或者想选中部门,结果把树折叠了。
原因:展开箭头和节点选中区域没有做事件隔离。
解决:展开箭头用独立的点击区域,并stopPropagation。节点选中用整行点击,但排除箭头区域。如果业务需要多选,在节点右侧加复选框,复选框的点击也stopPropagation。事件委托时用closest判断点击目标,优先级是:箭头 > 复选框 > 整行。
6. 一个可复用的部门树 Hook 与真机验证方法
6.1 把核心逻辑抽成 useDeptTree
前面讲了扁平化、展开折叠、搜索、虚拟列表计算,这些逻辑在每个部门树页面都要用。我习惯把它们抽成一个自定义 Hook,React 和 Vue 都能用类似思路。
/** * 部门树核心逻辑 Hook * @param {Array} rawTree - 后端返回的嵌套树 * @returns {Object} 状态和操作方法 */ function useDeptTree(rawTree) { const [flatList, setFlatList] = useState([]); const [childrenIndex, setChildrenIndex] = useState(new Map()); const [matchedIds, setMatchedIds] = useState(new Set()); // 初始化:扁平化 + 建索引 useEffect(() => { if (!rawTree || !rawTree.length) return; const flat = flattenDeptTree(rawTree); setFlatList(flat); setChildrenIndex(buildChildrenIndex(flat)); }, [rawTree]); // 展开/折叠 const toggle = useCallback((nodeId) => { setFlatList(prev => { const node = prev.find(n => n.id === nodeId); if (!node || !node.hasChildren) return prev; const targetVisible = !node.expanded; const affected = toggleExpand(nodeId, prev, childrenIndex); return prev.map(item => { if (item.id === nodeId) { return { ...item, expanded: targetVisible }; } if (affected.has(item.id)) { return { ...item, visible: targetVisible }; } return item; }); }); }, [childrenIndex]); // 搜索 const search = useCallback((keyword) => { if (!keyword.trim()) { setMatchedIds(new Set()); return; } const { matchedIds: matched, ancestorIds } = searchDept(keyword, flatList); setMatchedIds(matched); // 展开所有祖先 setFlatList(prev => prev.map(item => { if (ancestorIds.has(item.id)) { return { ...item, expanded: true, visible: true }; } if (matched.has(item.id)) { return { ...item, visible: true }; } return item; })); }, [flatList]); // 当前可见节点 const visibleNodes = useMemo( () => flatList.filter(n => n.visible), [flatList] ); return { flatList, visibleNodes, matchedIds, toggle, search }; }逻辑说明:这个 Hook 把扁平化、索引构建、展开折叠、搜索全部封装。useEffect在原始树变化时重新初始化。toggle用函数式更新保证状态最新。search先计算匹配和祖先集合,再批量更新visible和expanded。visibleNodes用useMemo缓存,避免每次渲染都过滤。
参数说明:rawTree是后端返回的嵌套结构。childrenIndex是 Map,在初始化时构建一次。matchedIds用于高亮搜索命中节点。注意toggle依赖childrenIndex,如果索引变化需要重新创建函数。
6.2 真机验证的 3 个关键指标
写完代码只是开始,真机验证才是关键。我一般会盯三个指标:
第一,首屏渲染时间。从页面加载到部门树第一层显示出来,中低端安卓机控制在 1.5 秒以内。超过这个数,用户会觉得慢。优化手段包括:接口数据缓存、骨架屏、初始只展开根节点。
第二,展开操作的响应延迟。点击展开箭头到子节点显示,控制在 100ms 以内。如果超过 200ms,用户会感觉卡顿。优化手段包括:虚拟列表、事件委托、避免展开动画。
第三,滚动帧率。快速滚动部门列表时,用 Chrome DevTools 的 FPS 面板看,稳定在 50fps 以上算合格。如果掉到 30fps 以下,检查虚拟列表 buffer 是否太小,或者节点内是否有复杂 DOM。
6.3 一个容易被忽略的细节:滚动容器的选择
虚拟列表的滚动容器用window还是独立的div?我的经验是:如果部门树是页面主体,用window滚动,这样移动端原生的滚动惯性更好。如果部门树是弹窗或半屏面板,用独立的div滚动,但要加-webkit-overflow-scrolling: touch开启硬件加速。
.dept-tree-container { height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; /* 防止滚动穿透 */ }逻辑说明:-webkit-overflow-scrolling: touch在 iOS 上开启惯性滚动,安卓新版本默认支持。overscroll-behavior: contain防止滚动到边界时触发父容器滚动,这在弹窗场景很重要。
参数说明:容器高度必须明确,不能是auto,否则虚拟列表无法计算可视区域。overscroll-behavior在安卓低版本可能不支持,需要加touchmove事件兜底。
6.4 我踩过的最深的一个坑
最后说一个血泪教训。早期做部门树时,我为了省事,把展开状态直接存在了每个节点的expanded字段上,然后递归渲染组件。节点少的时候没问题,节点一多,每次展开都触发整棵树重新渲染,低端机上直接卡死。后来改成扁平化加虚拟列表,同样的数据量,流畅得像换了个应用。
这件事让我明白一个道理:移动端 H5 的部门树,数据结构比渲染技巧更重要。先把树拍平,把状态管理理清楚,再考虑虚拟列表和性能优化。顺序反了,后面全是补丁。希望帮到你。
本文还有配套的精品资源,点击获取