☰
Vue3+Vant4移动端部门树实战:递归组件与懒加载性能优化解析
2026/10/15 20:14:50 网站建设 项目流程

简介:面向移动端开发者的H5部门树展示示例包,定位在企业内部管理系统、协作平台等场景下的组织架构与员工层级呈现,帮助前端工程师快速实现跨手机、平板的树形列表交互。压缩包共含十一个文件,其中包括一个HTML入口、两个JavaScript脚本、一份CSS样式、一份JSON模拟数据以及六张展开、折叠、编辑等状态图标,整体仅七十六KB,体积小巧,便于替换数据后嵌入现有项目。资源已有四千一百六十三人学习,契合常见开发需求。具体内容包括可运行的树形列表页面,节点展开与折叠、选中态切换、层级样式均由CSS与JS协同完成;JSON数据采用父子节点嵌套结构,可配合递归遍历理解DOM生成过程,还涉及响应式布局、触摸事件优化、懒加载与离线存储等移动端要点,适合初中级前端学习思路或作为基础模板快速改造。

1. 这个H5移动端部门树项目,解决的是组织架构在手机上的展示难题

部门树在PC端是再常见不过的交互,一个el-tree组件点开就能用。但挪到移动端H5里,事情就变得麻烦:屏幕宽度只有375px,塞不下横向展开的树;手指点击的精度和鼠标没法比;再加上移动端网络环境不稳定,一次性加载几千个部门节点直接卡死页面。我拆这个项目时第一反应是——这不就是一个树形组件的移动端适配吗?看完代码才发现,它不只是把桌面端的树搬到手机上,而是从数据加载方式到交互手势都重新设计了一遍。

这份资源是一个完整的H5移动端部门树展示Demo,核心解决三个问题:一是移动端下部门树的递归展示和多级展开收起,二是大数据量下的懒加载性能优化,三是和现有业务系统对接的数据结构适配。适合两类人看:一类是把企业OA、HR系统往移动端迁移的前端开发,另一类是接到“手机上看组织架构”这种需求但不知道从哪下手的新手。

2. 技术选型与整体架构:为什么不用现成树组件,而是自己维护一棵递归树

2.1 技术栈:Vue 3 + Vant 4 + 递归组件,选型的理由

整个Demo基于Vue 3 + Vant 4搭建,树形展示部分没有直接引桌面端的el-tree,而是自己写了一个递归组件来渲染部门层级。这个选择看起来绕了一圈,实际上是移动端场景下的必然——Vant虽然有TreeSelect组件,但它的设计是“左侧分类、右侧列表”的联动选择器,适合商品分类这种固定两级的场景,放到部门树上就露馅了:部门层级不确定,可能三级也可能七级,TreeSelect写死两级没法展示深层嵌套;而且部门树需要随时展开和收起任意节点,TreeSelect的交互模型对不上。

自己写递归组件的好处有三个:第一是层级深度不设限,递归函数天然适配任意层级的树形数据;第二是交互可以定制,展开收起的动画、节点缩进间距、选中态样式全部自己控制;第三是数据结构不用迁就组件,后端返回什么格式直接递归渲染。

这段是核心的递归组件代码,一个高度浓缩的部门节点:

<!-- DeptTreeNode.vue --> <template> <div class="dept-node"> <div class="dept-node__row" :style="{ paddingLeft: depth * 16 + 'px' }" @click="toggleExpand"> <span class="dept-node__arrow" :class="{ expanded: isExpanded }">▸</span> <span class="dept-node__label">{{ node.deptName }}</span> <span class="dept-node__count" v-if="node.children?.length">({{ node.children.length }})</span> </div> <div class="dept-node__children" v-if="isExpanded && node.children?.length"> <DeptTreeNode v-for="child in node.children" :key="child.deptId" :node="child" :depth="depth + 1" /> </div> </div> </template> <script setup> import { ref, computed } from 'vue' const props = defineProps({ node: { type: Object, required: true }, depth: { type: Number, default: 0 } }) const isExpanded = ref(false) const hasChildren = computed(() => props.node.children?.length > 0) function toggleExpand() { if (!hasChildren.value) return isExpanded.value = !isExpanded.value } </script>

这段代码的逻辑核心是组件自我递归——DeptTreeNode在模板里又引用了自己,父节点循环渲染子节点时,每个子节点继续递归渲染它的后代。depth用来控制缩进,每深入一层多缩进16px,这是移动端触摸目标的最小舒适间距。isExpanded控制当前节点的展开状态,展开时渲染children,收起时整个子树销毁,这个“销毁-重建”机制保证只渲染当前可见节点。

2.2 数据流设计:三次握手式的加载链路

整个部门树的数据流可以分成三段:页面初始化时加载顶层部门、用户点击展开某个部门时懒加载它的子部门、加载完成后把子部门挂到对应节点下面。这三段对应三种不同状态,处理不好就会出现“点了没反应”或者“数据重复”的玄学问题。

这段逻辑很关键,是懒加载的核心实现:

// deptTree.js —— 树数据管理模块 const state = reactive({ treeData: [], // 根节点数据 loadingMap: new Map(), // 记录哪些节点正在加载中 loadedMap: new Set() // 记录哪些节点已经加载过子部门 }) async function loadChildren(node) { // 如果是叶子节点或已经加载过,直接跳过 if (node.leaf || state.loadedMap.has(node.deptId)) return // 防止重复请求:同一个节点在加载中时再次点击 if (state.loadingMap.get(node.deptId)) return state.loadingMap.set(node.deptId, true) try { const res = await fetch(`/api/dept/children?parentId=${node.deptId}`) const list = await res.json() // 初始化子节点的完整结构 node.children = (list || []).map(item => ({ ...item, children: [], leaf: !item.hasChildren, // 后端告知是否有下级 _isLoaded: false })) state.loadedMap.add(node.deptId) } catch (err) { console.error('加载子部门失败:', node.deptId, err) } finally { state.loadingMap.delete(node.deptId) } }

这段代码最值得学习的地方是loadingMap和loadedMap的双重保护。移动端网络慢,用户可能连续点击一个节点三四次,没有loadingMap就会出现一个部门重复加载三四遍的翻车现场;loadedMap则是防止重复展开时重复请求。两个Map一个管“正在请求中”,一个管“已经请求完”,各司其职。

2.3 后端返回的数据结构适配:扁平列表转树的工具函数

实际开发中后端返回的数据大多数不是现成的树形结构,而是平铺的列表——每条数据带一个parentId,靠这个字段关联父子关系。如果直接拿这个列表给递归组件渲染,根本没法用,因为递归组件要求每个节点自带children字段。

项目里提供了一个把扁平列表转换成树形结构的工具函数,这是和真实业务对接时最常用到的模块:

/** * 扁平部门列表转树形结构 * @param {Array} flatList - [{ deptId, deptName, parentId }] * @param {String} rootParentId - 根节点的父ID,通常传 '0' 或 null * @returns {Array} 树形结构数组 */ export function buildTree(flatList, rootParentId = '0') { const nodeMap = {} const result = [] // 第一遍:把所有节点按 id 存到 map 中,同时补上 children 字段 flatList.forEach(item => { nodeMap[item.deptId] = { ...item, children: [] } }) // 第二遍:根据 parentId 挂载父子关系 flatList.forEach(item => { const node = nodeMap[item.deptId] if (item.parentId === rootParentId) { result.push(node) } else { const parent = nodeMap[item.parentId] if (parent) { parent.children.push(node) } else { // 找不到父节点的数据,按根节点处理 console.warn(`部门 ${item.deptId} 找不到父节点 ${item.parentId},已挂到根`) result.push(node) } } }) return result }

这个函数的时间复杂度是O(n),两遍遍历完成建树。第一遍把扁平数组变成Map,第二遍利用Map的O(1)查找来挂载父子关系。写这个函数时最容易踩的坑是直接使用Array.find来查找父节点,那会让时间复杂度变成O(n²),部门数量到几千条时页面明显卡顿。用Map是负责任的做法。

3. 核心模块拆解:一个部门从点击到展开的完整链路

3.1 节点展开的交互细节:箭头旋转动画与缩进策略

移动端手感和PC端差别很大。PC端hover有悬停效果,点击有明确的视觉反馈;手机上手指按下去没有悬停,只有点击的瞬间反馈。所以这个项目的节点行都加了active状态,触摸时背景色变化,配合一个简单的箭头旋转动画。

这个CSS是关键交互的视觉反馈:

.dept-node__row { display: flex; align-items: center; height: 44px; padding-right: 16px; font-size: 14px; background: #fff; transition: background 0.15s; border-bottom: 1px solid #f5f5f5; -webkit-tap-highlight-color: transparent; } .dept-node__row:active { background: #f5f7fa; } .dept-node__arrow { display: inline-block; width: 20px; text-align: center; color: #999; transition: transform 0.2s ease; font-size: 12px; } .dept-node__arrow.expanded { transform: rotate(90deg); }

高度44px不是随便定的,这是移动端触摸目标的最小可点击区域规范。字体大小14px在手机上阅读比较舒服,-webkit-tap-highlight-color设置为transparent是为了去掉安卓设备上点击时的灰色高亮框——那个默认高亮在iOS和安卓上表现不统一,影响整体观感。展开时箭头旋转90度,从小三角变成朝下的状态,用户一眼就能看出这个节点是展开还是收起。

3.2 搜索与筛选:关键字过滤时的扁平化处理

部门树展开状态下的搜索过滤,是很多开发者会踩坑的地方。最容易出问题的做法是直接在树形数据结构上递归过滤,这样过滤完之后整棵树的父子关系全乱了——比如搜索“研发部”,它的子部门“研发一组”也命中了,但中间层级的“研发中心”没命中,递归过滤直接把“研发一组”挂到了根上,原本的层级关系就丢失了。

正确做法是先递归收集所有命中的节点ID,再走一遍树形展开逻辑。

// searchDept.js export function searchAndExpand(treeData, keyword) { const matchedIds = new Set() // 第一遍:递归收集所有命中的节点 function collectMatched(nodes) { for (const node of nodes) { if (node.deptName.includes(keyword)) { matchedIds.add(node.deptId) } if (node.children?.length) { collectMatched(node.children) } } } collectMatched(treeData) // 第二遍:收集所有命中节点的祖先节点,保证路径展开 const expandIds = new Set(matchedIds) function collectAncestors(nodes, path) { for (const node of nodes) { const newPath = [...path, node.deptId] if (matchedIds.has(node.deptId)) { newPath.forEach(id => expandIds.add(id)) } if (node.children?.length) { collectAncestors(node.children, newPath) } } } collectAncestors(treeData, []) return { expandIds: [...expandIds], matchedIds: [...matchedIds] } }

这个函数的处理逻辑分两层:第一遍收集所有匹配的节点ID,第二遍从根节点往下走,把每个匹配节点的所有祖先节点也收集到待展开集合中。这样页面拿到结果后,把这些expandIds对应的节点全部置为展开状态,再给命中的节点加高亮样式,既能保留树形层级,又能准确展示搜索结果。

3.3 移动端适配的三个细节:安全区、横竖屏、滚动容器

移动端H5适配绕不开三个细节,项目里都处理了。第一个是iPhone的刘海屏安全区,部门树的滚动容器底部如果不加safe-area-inset-bottom,最后的节点会被底部小黑条挡住,看得到但点不到,这是很影响体验的细节。第二个是滚动容器必须放在div上而不是body上,部门树的滚动要限制在树形区域内部,不能带动整个页面滚动。第三个是横竖屏切换时的页面重渲染,部门树展开状态需要保留。

.dept-tree-container { height: calc(100vh - 48px - env(safe-area-inset-bottom)); overflow-y: auto; -webkit-overflow-scrolling: touch; padding-bottom: env(safe-area-inset-bottom); scroll-behavior: smooth; }

这里的100vh减去顶部导航栏的48px,再减去底部安全区的高度,得出的就是树形区域的精确可视高度。-webkit-overflow-scrolling: touch是iOS上的惯性滚动开关,不加的话在iPhone上滚动会一顿一顿的,加了之后有原生滚动手感。env(safe-area-inset-bottom)是CSS环境变量,iPhone X之后的全系机型都能适配。

3.4 选中与回显:从“点击选中”到“值带出”的完整闭环

部门树除了看之外,还要承担“选中某个部门然后把deptId传出去”的职责。项目里这个模块做了一个很实用的设计——选中节点高亮,同时把当前节点的完整路径带上,回传给页面其他组件使用。

// selectedDept.js const selectedDept = ref(null) const selectedPath = ref([]) function selectNode(node, path) { // path 是从根到当前节点的部门ID数组,由递归组件逐层传递 selectedDept.value = node selectedPath.value = path // 通知外部:页面拿到选中部门信息后,可以回填表单、跳转页面或发起查询 if (typeof onDeptChange === 'function') { onDeptChange({ deptId: node.deptId, deptName: node.deptName, path: path }) } }

这里的path参数是从根节点到当前节点的完整部门ID链,在递归组件里逐层传递下来。这个设计在“编辑场景”里特别有用——比如编辑一个员工信息,需要回显他所属的部门,这时候拿到deptId还不够,还需要知道这个部门在树上的完整路径。有了路径数组,页面可以一次性把所有相关节点全部展开,然后定位到选中节点,不用一层层手动展开去找。

4. 运行这个项目:从环境准备到手机预览的完整过程

4.1 环境准备与依赖安装

这个项目使用Vite作为构建工具,Node.js版本建议16以上。拿到源码包后,先确认本机安装了Node和npm,然后进到项目根目录安装依赖。

# 进入项目根目录 cd h5-dept-tree # 安装依赖 npm install # 启动开发服务器 npm run dev

如果npm install速度慢,可以换成cnpm install或使用pnpm,命令是一样的。开发服务器默认跑在5173端口(Vite的默认端口),启动后终端会打印一个本地访问地址,通常是http://localhost:5173。这里有个小坑:Vite默认只监听本机回环地址,如果手机要通过局域网访问,需要在vite.config.js里配置host: true才能监听所有网卡。

4.2 手机预览:局域网真机调试的配置方法

H5项目最理想的调试方式是手机真机预览,因为浏览器模拟器的触摸行为和真实手机差异很大。要让手机能访问电脑上的开发服务器,需要做两步配置。

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { host: true, // 监听所有网卡,允许局域网访问 port: 5173, open: false // 不在电脑上自动打开浏览器 } })

配置好host: true之后,重启开发服务器,终端会打印出局域网地址,形如http://192.168.x.x:5173。让手机和电脑连同一个WiFi,在手机浏览器里输入这个地址就能访问。如果手机打不开,优先检查电脑防火墙是否放行了5173端口,其次是确认手机和电脑不在同一个网段。

4.3 mock接口:没有后端时如何模拟接口返回

项目内置了一个轻量级的mock数据模块,用代码模拟接口响应,方便脱离后端独立跑起来。这个设计在实际开发中很实用——前端先定好接口数据结构,后端并行开发,两边联调时再切换真实接口。

// mock/deptMock.js const deptList = [ { deptId: 'D001', deptName: '总公司', parentId: '0', hasChildren: true }, { deptId: 'D002', deptName: '技术中心', parentId: 'D001', hasChildren: true }, { deptId: 'D003', deptName: '前端组', parentId: 'D002', hasChildren: false }, { deptId: 'D004', deptName: '后端组', parentId: 'D002', hasChildren: false }, { deptId: 'D005', deptName: '产品部', parentId: 'D001', hasChildren: false } ] export function fetchChildren(parentId) { return new Promise((resolve) => { setTimeout(() => { const children = deptList.filter(item => item.parentId === parentId) resolve(children) }, 300) // 模拟300ms网络延迟 }) }

mock数据里的hasChildren字段很关键,它告诉前端这个节点到底还有没有下级部门。没有这个字段,前端只能用“点击后返回空数组”来判断是不是叶子节点,用户体验就会变成——点了一下没反应,再点一下才知道没有下级,反馈是滞后的。有了hasChildren,前端就能在没有子节点时隐藏箭头图标,从视觉上直接告诉用户这是最终节点。

4.4 常见运行报错与处理方式

跑这个项目时最常遇到的三类报错:第一类是npm install的时候依赖版本冲突,通常是Node版本过旧导致Vite无法正常工作,报错信息形如Node.js version must be >= 16,解决办法是升级Node到LTS版本。第二类是启动时报Port 5173 is already in use,说明5173端口被占用了,改一下vite.config.js里的port配置就行。第三类是手机访问不了局域网地址,原因多半是防火墙拦截,直接把防火墙关闭再试一次就能定位问题。

5. 避坑指南:移动端部门树实战中踩过的五个坑

5.1 递归组件无限递归导致栈溢出

现象:页面一加载就直接白屏,控制台报Maximum call stack size exceeded。

原因:后端返回的数据存在循环引用——某个节点的parentId指向了自己的子节点,或者同级节点互相指向。递归组件在渲染时停不下来,最终栈溢出。这个问题在真实业务数据中偶尔会出现,比如把一个部门误设成自己的父部门。

解决:在buildTree里增加循环引用检测,发现节点的父节点ID等于自己或已经出现在自己的祖先链条中时,打日志并把该节点的parentId重置为根节点。更稳妥的做法是在递归组件渲染时设置一个最大深度限制,比如depth > 15时不再递归渲染,防止出现极端数据时整个页面崩溃。

5.2 懒加载节点无限触发请求

现象:展开某个节点后,接口请求不断重复发出,一个子部门接口被请求几十次。

原因:懒加载逻辑中,点击展开时先判断没有加载过就发请求,但请求发出后没有再设置“加载中”标记。用户快速连续点击同一个节点,每次点击都触发一次请求。在低端机上响应慢,这个问题特别明显。

解决:用loadingMap在请求发出前设置标记,请求完成后删除标记。点击事件处理函数里先检查标记,存在则直接返回。这个方案在前面的核心代码里已经体现,属于一个请求同时只能有一个实例的互斥保护。

5.3 大数据量下展开全部节点时页面卡死

现象:部门数量在3000以上时,如果执行“展开全部”操作,页面直接卡死,CPU占用飙到100%。

原因:一次性把整棵树所有节点全部渲染到DOM上,以每节点10个DOM元素计算,3000个节点产生3万个DOM元素,移动端浏览器的渲染能力根本扛不住。

解决:一是虚拟滚动,只渲染可视区域内的节点,这是最彻底的方案。二是做“展开层级限制”,默认只展开到第三级,更深层级需要用户手动逐级展开。三是用display: none隐藏不可见分支的DOM节点,虽然不如虚拟滚动高效,但实现起来简单得多。实际项目中我用的是第二种+第三种组合方案,满足业务需求的同时开发成本可控。

5.4 搜索时树形结构被打乱

现象:输入“技术”关键字后,搜索结果中出现了多个“技术部”散落在根节点下,原本的层级关系全丢了。

原因:直接在原树数据上递归过滤,把子节点往上提了。比如“技术中心”没有命中关键字(实际命中“技术”,但“中心”两个字导致整体名称不匹配),而它的子节点“技术研发部”命中了,递归过滤后“技术研发部”就没有了父节点,被当成了根节点。

解决:使用前文提到的searchAndExpand方案——先收集所有命中节点的ID,再向上收集这些节点的所有祖先ID,最终以“命中节点保持原层级”的方式在原树结构上做高亮和展开,而不是重新生成一棵过滤后的树。

5.5 输入框被手机键盘遮挡

现象:在苹果手机上,点击搜索框弹出键盘后,输入框被键盘挡住,看不到正在输入的内容。

原因:页面使用了100vh作为滚动容器高度,弹出键盘时100vh不会被压缩,而键盘是浮在页面之上的,自然遮住了底部的内容。

解决:改用window.innerHeight动态计算可视区高度,监听resize事件更新容器高度。还有一种做法是用visualViewportAPI来获取真实的视觉视口高度,但兼容性差一些,需要在项目里做降级处理。

6. 二次开发实战:把单选部门树改成多选部门树

多选是部门树最常遇到的一个改造需求,下面我直接给出这个改造的核心思路。在开始之前先想清楚一个问题:多选和单选的本质差异不是勾选框,而是数据收集方式——单选只需要一个deptId,多选则要维护一个选中的节点集合,并且要考虑父子联动的逻辑。

先把选中集合和父子联动逻辑实现出来:

// multiSelect.js const selectedSet = new Set() function toggleNode(node) { if (selectedSet.has(node.deptId)) { selectedSet.delete(node.deptId) } else { selectedSet.add(node.deptId) } } // 计算节点的选中状态,用于视图渲染 function getCheckStatus(node) { const children = node.children || [] if (!children.length) { return selectedSet.has(node.deptId) ? 'checked' : 'unchecked' } let checkedCount = 0 children.forEach(child => { const status = getCheckStatus(child) if (status === 'checked' || status === 'indeterminate') checkedCount++ }) if (checkedCount === children.length) return 'checked' if (checkedCount > 0) return 'indeterminate' return 'unchecked' }

这里有个父子联动的细节:勾选父节点时,如果子节点没有全部选中,父节点显示半选状态;子节点全部选中时,父节点才显示全选。getCheckStatus用递归方式计算每个节点的状态,父子状态天然联动,不需要额外维护关系。提交数据时再把选中的节点ID数组收集起来:

function collectCheckedIds(nodes, path, result) { for (const node of nodes) { const currentPath = [...path, node.deptId] if (selectedSet.has(node.deptId)) { result.push({ deptId: node.deptId, deptName: node.deptName, path: currentPath }) } if (node.children?.length) { collectCheckedIds(node.children, currentPath, result) } } return result }

收集选中的节点时顺手把从根到当前节点的完整路径也带上,这样提交到后端之后,后端不需要再去查部门表就能知道这个部门属于哪个上级。改造的核心点在于:原有的单选逻辑只保留高亮显示,新增加勾选状态管理模块,两者互不干扰。提交的数据从单个deptId变成了数组结构,接口联调时数据结构变化需要提前和后端约定。

从那以后我每次改树形组件,都会强制走一遍“数据格式确认 → 递归渲染 → 交互状态 → 数据回传”的流程,把每一种状态变化画清楚再动手。多选这种需求如果能先把父子联动关系想透彻,写代码只是水到渠成的事。改造过程中最大的收获不是代码本身,而是逐渐摸透了树形组件“数据驱动视图”的精髓——在树形世界里,保持数据结构的清晰比什么都重要。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询