简介:这份资源面向H5移动端开发者,尤其是需要在企业内部管理系统或协作平台中实现组织架构呈现的前端人员,解决移动端部门树列表的展示与交互问题。包内共11个文件,以6个png图标资源、2个js脚本、1个css样式表、1个json数据文件和1个html页面为主,压缩包约76KB,结构轻量,便于直接嵌入现有项目参考。核心脚本AM-treeList.js配合AM-treeList.css,实现了树形列表的渲染与样式控制,json文件提供节点数据示例,html页面则给出完整调用示范,图标资源覆盖展开、收起、编辑、增删等常见操作状态。内容涉及响应式布局、递归解析JSON构建DOM、展开折叠动画及触摸友好优化等要点,适合希望快速理解移动端树形控件实现思路、或需要轻量级方案替代大型组件库的开发者参考。目前已有4162人学习下载,可作为移动端部门树功能落地的实用起点。
1. 移动端部门树展示:为什么你的递归组件在 200 个节点就卡死了
很多团队在 PC 后台用 Element UI 的el-tree渲染部门结构,切到 H5 移动端时直接照搬,结果部门层级一深、节点一多,页面滑动就掉帧,展开一个三级部门要等半秒。问题不在框架,在于移动端没有鼠标悬停、没有大屏空间,用户是「点一下、滑一下、再点一下」的节奏,树形结构的渲染策略和交互模型都得重做。这份资源给的就是一套面向 H5 的部门树展示方案,核心解决三件事:大数据量下的懒加载与虚拟滚动、移动端手势友好的展开收起、以及部门节点与人员数据的联动查询。适合正在做企业通讯录、OA 审批选人、组织架构看板的移动端前端,也适合需要把 PC 树组件迁移到 H5 的开发者。下面按「结构怎么设计 → 数据怎么加载 → 交互怎么调 → 坑在哪」的顺序拆开讲。
2. 部门树的数据结构与渲染选型:扁平化还是嵌套
2.1 嵌套结构在移动端的三个硬伤
部门树最直观的数据格式是嵌套 JSON,每个节点带children数组。PC 端用递归组件渲染没问题,因为屏幕大、节点少、鼠标操作不频繁。但移动端有三个硬伤:第一,递归组件在展开深层节点时会触发整棵子树重新渲染,Vue 的响应式追踪在嵌套对象上开销随层级指数上升;第二,嵌套结构做搜索时得递归遍历,2000 个节点搜一次要几十毫秒,输入框每敲一个字都卡;第三,移动端需要「定位到某个部门并高亮路径」,嵌套结构找父链要额外维护parent引用,容易和children不同步。
常见做法是先把嵌套结构扁平化成Map,用id和parentId建立索引。这样搜索、定位、展开状态管理都变成 O(1) 查表,渲染时只取当前可见节点列表。
// 把嵌套部门树拍平成 { id: node } 的 Map,同时记录 parentId function flattenDeptTree(tree, parentId = null, map = new Map()) { tree.forEach(node => { const { children, ...rest } = node; // 关键:把 parentId 写进节点,后续找父链不用递归 map.set(node.id, { ...rest, parentId, hasChildren: !!(children && children.length) }); if (children && children.length) { flattenDeptTree(children, node.id, map); } }); return map; } // 根据当前展开的 id 集合,算出要渲染的可见节点列表 function getVisibleNodes(flatMap, expandedIds, rootId = 'root') { const result = []; const walk = (pid, depth) => { for (const [id, node] of flatMap) { if (node.parentId !== pid) continue; result.push({ ...node, depth }); // 只有展开的节点才继续往下走 if (expandedIds.has(id)) walk(id, depth + 1); } }; walk(rootId, 0); return result; }flattenDeptTree的parentId参数在递归时传入当前节点 id,这样每个扁平节点都知道自己的父级。hasChildren用来决定是否显示展开箭头,避免渲染时再查子节点。getVisibleNodes的expandedIds是一个Set,存当前展开的部门 id,只有展开的节点才递归子级,收起状态下的子树完全不参与计算。这套结构下,2000 个节点的搜索和展开都是毫秒级。
2.2 虚拟滚动在部门树里的适配要点
扁平化之后,可见节点列表就是一个一维数组,可以直接套虚拟滚动。但部门树和普通列表不同:节点有缩进层级,缩进用padding-left还是margin-left会影响虚拟滚动的测量。我一般用padding-left: depth * 16px,因为 padding 不影响元素外部尺寸,虚拟滚动计算行高时不会因为缩进产生偏差。
另一个点是展开动画。移动端如果给每个节点加height过渡,虚拟滚动会因为行高变化频繁重算,反而卡顿。常见做法是去掉展开动画,或者只对当前点击的节点做一次transform: scale的微反馈,视觉上够用,性能开销几乎为零。
// 虚拟滚动核心:根据 scrollTop 算可见区间 function getRenderRange(scrollTop, itemHeight, viewportHeight, total) { const start = Math.floor(scrollTop / itemHeight); const end = Math.min(start + Math.ceil(viewportHeight / itemHeight) + 2, total); return { start, end }; }itemHeight是固定行高,部门树每行高度一致,所以不需要动态测量。+2是缓冲行,防止快速滑动时出现白屏。viewportHeight取容器高度,移动端要注意键盘弹起时视口变化,建议用visualViewport监听而不是resize。
3. 移动端交互与懒加载:点一下到底该发生什么
3.1 展开收起的三种触发方式与选择
PC 端树组件通常点击箭头展开、点击文字选中。移动端没有 hover,手指点击热区小,如果箭头和文字分开响应,用户很容易点错。我一般把整行做成点击展开,选中操作放到二级页面或者长按菜单里。如果业务必须区分,就把箭头热区扩大到 44px 以上,文字区域点击做选中,但要在视觉上明确区分。
还有一种场景是「只允许选叶子部门」,那展开和选中可以合并:点击非叶子节点展开,点击叶子节点选中并回填。这种交互在移动端最顺手,因为用户不需要精确点箭头。
// 行点击处理:根据节点类型决定展开还是选中 function handleNodeClick(node, expandedIds, onSelect) { if (node.hasChildren) { // 有子节点:切换展开状态 if (expandedIds.has(node.id)) { expandedIds.delete(node.id); } else { expandedIds.add(node.id); } return { type: 'expand', expandedIds }; } // 叶子节点:触发选中 onSelect(node); return { type: 'select', node }; }hasChildren在扁平化时已经算好,这里直接判断,不用再查子节点。expandedIds用Set而不是数组,因为增删和查找都是 O(1),移动端频繁点击时不会有性能问题。
3.2 懒加载部门数据的接口设计与缓存
部门树数据量大时不能一次性拉全量,常见做法是点击展开时请求子部门。接口设计上,GET /dept/children?parentId=xxx返回直接子级,前端收到后合并进扁平 Map,并更新父节点的hasChildren。这里有个坑:如果用户快速展开又收起,可能发出多个重复请求。我一般用一个pendingMap记录正在请求的 parentId,请求回来之前不重复发。
const pendingMap = new Map(); async function loadChildren(parentId, flatMap, expandedIds) { if (pendingMap.has(parentId)) return; pendingMap.set(parentId, true); try { const res = await fetch(`/api/dept/children?parentId=${parentId}`); const children = await res.json(); children.forEach(child => { flatMap.set(child.id, { ...child, parentId, hasChildren: child.childCount > 0 }); }); expandedIds.add(parentId); } finally { pendingMap.delete(parentId); } }pendingMap用Map而不是Set,因为后续如果要加超时清理,Map可以存时间戳。childCount是接口返回的子节点数量,用来判断hasChildren,比前端自己查更可靠。请求回来后把parentId加入expandedIds,触发重新计算可见节点。
缓存策略上,已经加载过的部门子级不需要重复请求,flatMap里已经有数据。但要注意部门数据可能变化,我一般加一个lastFetchTime,超过 5 分钟才重新拉。
4. 避坑与排查:部门树在真机上的五个翻车现场
4.1 展开后列表跳动,滚动位置错乱
现象:点击展开一个部门,列表突然跳到顶部或者某个奇怪位置。原因:虚拟滚动的scrollTop在数据变化后没有重新校准,浏览器保留了旧的滚动偏移。解决:在expandedIds变化后,用nextTick重新计算可见节点,然后手动设置scrollTop为当前第一个可见节点的偏移。如果用的是scroll-view,要调用scrollIntoView或者直接设scroll-top。
4.2 快速滑动时白屏,松手才渲染
现象:手指快速滑动部门树,中间一段是空白,停下来才出现内容。原因:虚拟滚动的渲染区间计算依赖scroll事件,移动端scroll触发频率低于touchmove,快速滑动时事件跟不上。解决:监听touchmove做预渲染,或者把缓冲行从 2 增加到 5。另外可以用requestAnimationFrame节流渲染,避免每帧都 setData。
4.3 搜索定位后高亮路径不展开
现象:搜索到某个部门,点击定位,结果只高亮了节点但没有展开它的父级路径。原因:定位逻辑只设置了scrollTop,没有把父链上的所有部门 id 加入expandedIds。解决:从扁平 Map 里沿parentId往上找,把所有祖先 id 加入展开集合,再计算可见节点并滚动到目标位置。
function expandToNode(nodeId, flatMap, expandedIds) { let current = flatMap.get(nodeId); while (current && current.parentId) { expandedIds.add(current.parentId); current = flatMap.get(current.parentId); } }while循环沿parentId上溯,直到根节点。因为扁平化时每个节点都有parentId,这里不需要递归,也不会栈溢出。
4.4 部门名称过长导致缩进错位
现象:深层部门名称太长,换行后第二行没有缩进,和父级对齐了。原因:缩进加在容器上,换行后文本从容器左边缘开始。解决:把缩进加在文本元素上,或者用text-indent配合padding-left。我一般用padding-left加在行容器,文本用flex: 1并设置word-break: break-all,这样换行后仍在缩进范围内。
4.5 安卓低端机上展开卡顿超过 500ms
现象:在中低端安卓机上,展开一个有 50 个子部门的节点,卡顿明显。原因:一次性渲染 50 个节点,每个节点都有点击事件和样式计算。解决:虚拟滚动本身能缓解,但如果展开后可见节点突增,还是要做分批渲染。常见做法是展开后先渲染前 20 个,用requestIdleCallback或setTimeout分批追加剩余节点。另外检查每个节点是否有复杂的box-shadow或gradient,这些在低端机上开销很大,能去掉就去掉。
5. 进阶:用 IntersectionObserver 做曝光加载与性能验证
部门树在移动端还有一个隐藏需求:当用户滑动到某个部门时,才加载该部门下的人员数量或头像。用IntersectionObserver监听节点进入视口,比在scroll事件里手动计算getBoundingClientRect性能好得多,因为浏览器原生实现,不阻塞主线程。
// 监听部门节点曝光,触发人员数据加载 const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const deptId = entry.target.dataset.deptId; loadDeptMemberCount(deptId); // 请求该部门人数 observer.unobserve(entry.target); // 加载过就不再监听 } }); }, { rootMargin: '50px' }); // 在虚拟滚动渲染后,对可见节点注册观察 function observeNodes(container) { container.querySelectorAll('[data-dept-id]').forEach(el => observer.observe(el)); }rootMargin: '50px'表示提前 50px 触发,用户滑到之前就开始加载,体验更顺。unobserve在加载后立即调用,避免重复请求。注意虚拟滚动会复用 DOM,节点被回收后style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />