拓冰建站拓冰建站
首页 / 资讯中心 / 正文

部门树组件设计与实践:从业务重复造轮到通用组件沉淀

前前后后各种管理系统做了不下十几套我发现只要业务稍微沾点组织架构就跑不掉一个需求部门树。不管是做人员归属、数据权限范围、审批流里的部门选择还是单纯的行政组织架构展示最后都会被产品经理指着一张原型图说——“就做成那种左边一棵树、右边列表的经典布局”。所以当我看到Ease UI在2026-04-08这版更新里把xly-dept-tree部门树组件从业务代码中提炼成通用组件时我心里其实挺有感触的。因为这种组件太容易被低估了表面上看不就是el-tree套个皮吗实际做进去才知道轮子自己在项目里造和在组件库里沉淀成标准件完全是两码事。这篇文章就想跟你聊聊这次新增的xly-dept-tree到底解决了我平时开发中的哪些问题它的核心设计思路是什么以及我实际接入时的一些配置过程和踩坑心得。如果你所在的团队也在自研组件库或者正头疼怎么把散落在各个业务项目里的“部门树”抽成统一组件这篇内容应该能给你一些参考。1. 为什么需要一个独立的部门树组件1.1 部门树在业务系统中的高频场景先说说部门树这东西在真实系统里到底有多高频。我粗略回想了一下手头这几个项目凡是涉及内部管理系统十个里有八个组织架构是硬需求。最常见的两种形态一种是组织管理页面左边整棵部门树右边是部门详情或成员列表另一种是各类弹窗里选部门比如新建用户时指定所属部门、发起审批时选择审批部门、分配数据权限时勾选可见部门范围。这两种形态的诉求其实还有差别。管理页面讲究的是树的结构完整、状态同步、折叠记忆弹窗选部门则更看重搜索定位、父子级勾选策略、单选多选切换。如果这些场景每次都从头写一遍代码量其实是小事真正麻烦的是各写各的有的用递归组件实现、有的拿第三方库里现成的树改造、还有的直接从后端拿组织结构图在前端硬拼等做到第四个系统时你就会发现同样的一个“展开-选择-回填”逻辑在三个系统里长得完全不一样。Ease UI这次把xly-dept-tree做成独立组件本质上就是在替大家认领这个重复劳动。它把部门树相关的交互细化、接口约定、数据格式都统一收口业务侧只需要关心数据从哪来、选中结果怎么用剩下的树形展示、节点操作、搜索过滤、状态回显这些通用能力组件内部直接消化掉。1.2 直接使用通用Tree组件和部门树专组件的差距可能有人会问组件库里本来就有通用的Tree为什么还要再包一层这其实就是通用组件和领域组件之间的经典权衡。通用Tree确实什么都能做但恰恰因为什么都能做它天然不会为了组织架构这个特定场景做太多内置约束。举个例子通用Tree的节点数据通常就是label、children这类抽象字段但部门树的数据一般长这样deptId、deptName、parentId、sortNo、status。如果直接用通用Tree每次接接口我都得写一层mapper状态过滤、排序、空节点清理也得业务侧自己循环处理这些代码零散且重复。而xly-dept-tree直接把部门数据字段约定好甚至支持字段名映射接后端接口时少写很多胶水代码。另一个差距在交互层面。部门树的搜索定位和普通树搜索是不一样的部门名往往有层级前缀比如“技术中心/前端组”和“技术中心/后台组”这种同时按部门状态过滤启用、停用也是业务里很常见的诉求。通用Tree不会默认帮你考虑这些而这恰恰是xly-dept-tree这类领域组件的价值所在。1.3 这次更新重点解决的几个历史痛点从我使用Ease UI的版本节奏来看之前的Tree组件在部门场景下有几个比较头疼的问题。其一虚拟滚动和树节点的展开动画一直配合得不太好当部门数据量上到几千个节点时全量渲染明显卡顿其二勾选父子联动时缺少“只选叶子节点”和“取消关联勾选”这类业务常用的细化策略其三弹窗场景下回显部门名称时往往需要额外调接口拼接口没有统一的数据回显方案。这次xly-dept-tree的发布说明里组件对这几个方向都做了专门优化。它把大数据量下的渲染优化作为基础能力内置而不是留给使用方自己去做虚拟滚动适配。同时对勾选模式、搜索过滤、远程数据加载、回显逻辑都做了部门业务场景下的默认约定。说白了它不是一个简单包了层预设样式的el-tree而是真正从组织架构业务中提炼出来的专用组件。2. 核心特性与设计思路拆解2.1 简洁的数据约定与灵活字段映射先看数据层面。xly-dept-tree约定的基础数据结构是// 标准结构示例 const deptTree [ { id: dept-root, name: 总公司, parentId: null, sortNo: 1, status: active, children: [ { id: dept-tech, name: 技术中心, parentId: dept-root, sortNo: 11, status: active, children: [ { id: dept-fe, name: 前端组, parentId: dept-tech, sortNo: 111, status: active } ] } ] } ]如果你后端用的字段名不是这套比如你拿到的是departmentId、departmentName也没关系组件提供了props字段映射可以像Element的tree-props一样配置别名xly-dept-tree :datadeptData :props{ label: deptName, children: childNodes, id: departmentId } /这种设计实际上是在可预测性和灵活度之间取了一个中间值。一方面组件内部能按约定好的字段快速处理排序、过滤、状态判断等逻辑另一方面不同后端返回的字段各异的现实也留了后门。集成成本低又不至于让你为了用个组件还得先改后端接口。2.2 勾选策略与状态回显的精细控制部门树在选人的场景里勾选逻辑是重头戏。常见的情况新增用户时只能选末级部门不能选父级分配数据权限时要能勾选父级并联动所有子级还有些场景要求父子完全独立勾选。这三种策略xly-dept-tree都支持通过check-strictly和only-leaf-checkable两个属性组合实现。这四个模式基本覆盖了我在真实项目里的所有勾选诉求业务场景check-strictlyonly-leaf-checkable效果用户归属部门falsetrue只能勾选叶子部门父级自动半选权限范围分配falsefalse勾选父级联动子级符合权限继承独立角色菜单truefalse父子互不影响按需勾选跨部门临时访问truetrue仅叶子可选且互不关联回显方面组件接收v-model绑定选中节点的id数组同时提供了一个expose方法getCheckedNodes用来获取完整的节点数据。这个设计我很喜欢因为很多场景下我不但要拿到id提交给后端还要拿部门名称做展示只绑一个id数组往往不够。组件内置了从id反查节点信息的逻辑回显名称时不用再单独拼接口。2.3 树节点的高性能渲染与懒加载设计部门树数据量大是常态。尤其是那种集团型公司几千个部门、五六层的层级关系很常见。把整棵树一次性展开渲染DOM节点数量开销极大。xly-dept-tree这次采用了按需渲染策略。默认只渲染当前展开节点对应的直接子级节点展开时才去渲染下一层配合内部维护的渲染状态树解决了全量展开时的性能问题。实测中面对单层超过500个兄弟节点的数据展开和折叠操作能保持在流畅体验范围内。懒加载也是配套提供的。如果你的部门数据量实在太大或者接口设计上就是父子级分开查询的可以不开data改用:load函数在节点展开时动态加载子级xly-dept-tree lazy :loadloadDeptChildren :props{ label: name, isLeaf: leaf } /async function loadDeptChildren(node, resolve) { const parentId node.data?.id ?? 0 const res await fetchDeptByParent(parentId) resolve(formatDeptList(res.data)) }懒加载模式下每个节点首次展开前才触发对应父级的数据请求性能天然可控。而且组件内部对这个过程做了缓存处理同一个节点二次展开不会重复发请求这个细节对用户体验来说非常重要。2.4 多种选择模式适配业务弹窗之前的树组件默认都是单选或复选二选一但真实业务中部门选择经常面临左右横跳的情况。比如你做一个用户编辑弹窗如果系统规定一个用户只能归属一个部门那这里是单选如果允许多部门兼职那这里又变成多选。为了这两个场景业务侧往往维护着两套不同写法的Tree用法。xly-dept-tree把这种差异收敛成一个multiple属性。默认false时是单选点击节点直接选中并触发确认设置multiple后才展示复选勾选框。单选模式下还有一个很好的细节父节点选中后不会自动联动子级选中的就是当前这个部门这个逻辑更贴合“人员归属部门”这类实际业务语义。3. 实际接入与基础配置流程3.1 安装和快速接入接入第一步比较常规在项目根目录安装最新版本npm install ease-uilatest如果你用的是Vue 3 Vite项目一般在入口文件中全量注册或者按需引入// main.js 全量引入示例 import { createApp } from vue import EaseUI from ease-ui import ease-ui/dist/index.css import App from ./App.vue createApp(App).use(EaseUI).mount(#app)Ease UI也支持unplugin-vue-components之类的按需自动导入这个看团队基建情况自行配置。组件更新后记得在package.json里确认版本号已经指向包含xly-dept-tree的版本。3.2 基础场景三步跑通用一个最简场景帮你测试组件是否正常接口请求部门列表渲染成树勾选后弹出选中id。第一步在页面中引入组件template div classdept-select-page xly-dept-tree v-modelselectedDeptIds :datadeptList node-keyid :default-expanded-keysexpandedKeys show-checkbox / p当前选中ID{{ selectedDeptIds }}/p /div /template第二步在script setup中定义数据import { ref, onMounted } from vue const selectedDeptIds ref([]) const deptList ref([]) const expandedKeys ref([]) async function fetchDeptTree() { const res await fetch(/api/dept/tree) deptList.value res.data // 默认展开第一层 expandedKeys.value res.data.map(item item.id).slice(0, 1) } onMounted(fetchDeptTree)第三步后端返回的数据经过简单的字段校准保证包含id、name、children等基础字段树就能正常渲染。这个流程跑通后组件的基础工作就算正常。实际开发中我建议先在这个三层结构上加一个接口loading状态尤其当部门数据接口比较慢时组件的空数据状态和加载状态会让页面稳定很多。3.3 基本配置项全览与推荐值这次更新的xly-dept-tree在Props设计上基本和Ease UI其他表单类组件保持一致上手成本不高。配置项主要有这几类分类属性名类型默认值建议数据data / lazy / loadArray / Boolean / Function-数据量小用data超过1000节点建议lazy展示show-checkbox / multipleBooleanfalse单选场景务必关掉复选节点node-key / propsString / Objectid键名以后端稳定字段为准过滤filterable / filter-methodBoolean / Functionfalse弹窗选部门强烈建议开启状态default-expand-all / expanded-keysBoolean / Arrayfalse层级深的树不建议全展开勾选check-strictly / only-leaf-checkableBooleanfalse根据业务语义精细控制数据回显model-value / v-modelArray-传id数组即可这些配置整体上倾向于让使用方少写代码。比如默认的勾选关联模式就是父子联动但同时又提供了关闭联动的开关不同业务的差异化配置都能覆盖到。3.4 表单场景的完整接入模板表单里用部门树核心需求有三个弹窗内展示树、选择后回填、再次打开时正确回显。这里我贴一个在实际项目中改动后稳定使用的模板。template el-dialog v-modelvisible title选择部门 width560px xly-dept-tree refdeptTreeRef v-modeldeptIdList :datadeptTreeData node-keyid show-checkbox filterable :only-leaf-checkabletrue :default-expanded-keysdefaultExpandList checkhandleDeptCheck / template #footer el-button clickvisible false取消/el-button el-button typeprimary clickhandleSubmit 确定 /el-button /template /el-dialog /templateconst visible ref(false) const deptTreeRef ref(null) const deptIdList ref([]) function openDialog(initIds []) { deptIdList.value initIds ? [...initIds] : [] visible.value true // 等树渲染完成后主动回显勾选 nextTick(() { deptTreeRef.value?.setCheckedKeys(deptIdList.value) }) } function handleSubmit() { // 提交时直接提交id数组同时通过getCheckedNodes补充名称字段 const checkedNodes deptTreeRef.value.getCheckedNodes() const submitPayload checkedNodes.map(node ({ id: node.id, name: node.name })) console.log(提交的数据, submitPayload) visible.value false }这里有两个容易踩的坑。一个是v-model和setCheckedKeys的同步时序问题首次打开弹窗时树可能还没渲染完就调用了setCheckedKeys导致回显失败所以一定要放在nextTick里执行。另一个是半选问题如果提交时只用getCheckedKeys父级半选节点也会被带出来而很多后端接口不接受这种父级id所以补一个only-leaf-checkable或者提交前自己过滤一次叶子节点。4. 完整实操从接口到页面一次通4.1 模拟一套真实的部门数据接口为了演示完整流程我自己模拟一个比较常见的后端返回结构{ code: 0, data: { list: [ { deptId: 1, deptName: 苍穹科技, parentId: 0, orderNum: 1, status: 0 }, { deptId: 2, deptName: 研发中心, parentId: 1, orderNum: 1, status: 0 }, { deptId: 3, deptName: 前端组, parentId: 2, orderNum: 1, status: 0 }, { deptId: 4, deptName: 后端组, parentId: 2, orderNum: 2, status: 0 }, { deptId: 5, deptName: 测试组, parentId: 2, orderNum: 3, status: 0 }, { deptId: 6, deptName: 产品部, parentId: 1, orderNum: 2, status: 0 }, { deptId: 7, deptName: 已停用部门, parentId: 1, orderNum: 3, status: 1 } ] } }这里我特意让字段名不是组件默认值来演示字段映射的配置提交流程。4.2 前端列表转树的两种常见姿势拿到扁平列表后常规做法是前端把它转成树形结构。转换函数网上有很多版本我自己常用这个function listToTree(list, rootParentId 0) { const map new Map() list.forEach(item { map.set(item.deptId, { ...item, children: [] }) }) const tree [] map.forEach(node { const parentId node.parentId ?? rootParentId if (parentId rootParentId || !map.has(parentId)) { tree.push(node) } else { const parentNode map.get(parentId) parentNode.children.push(node) } }) // 按orderNum排序 const sortTree (nodes) { nodes.sort((a, b) a.orderNum - b.orderNum) nodes.forEach(n n.children?.length sortTree(n.children)) } sortTree(tree) return tree }如果你觉得前端转换太占内存也可以让后端直接返回树形结构这个视接口能力而定。4.3 页面集成完整代码演示数据准备好之后组件的使用就非常纯粹了。完整集成代码如下template div classdept-manage-container div classdept-tree-panel xly-dept-tree v-modelselectedIdList :datatreeData node-keydeptId :propstreeProps show-checkbox check-strictly default-expand-all node-clickhandleNodeClick / /div div classselected-info 选中部门ID{{ selectedIdList }} /div /div /template script setup import { ref, onMounted } from vue const treeProps { id: deptId, label: deptName, children: children } const treeData ref([]) const selectedIdList ref([]) onMounted(async () { const res await fetch(/api/dept/list).then(r r.json()) const list res.data.list treeData.value listToTree(list) }) function handleNodeClick(data) { console.log(点击了节点, data) } /script这里我把check-strictly设为true因为示例业务是给一个角色分配可见部门父级和子级可以独立勾选互不干扰比较贴近权限范围配置的场景。如果业务是选择用户归属部门这个配置就要改成false并加上only-leaf-checkable。4.4 搜索过滤让大树变矮的关键部门树一大最头疼的就是定位。xly-dept-tree的过滤功能是组件内部自己实现的不依赖外部输入框状态xly-dept-tree refdeptTreeRef :datatreeData node-keydeptId :propstreeProps filterable :filter-methodfilterDeptByName placeholder输入部门名称搜索 /function filterDeptByName(keyword, nodeData) { if (!keyword) return true return nodeData.deptName.includes(keyword) }组件内部会在输入关键词后自动展开所有命中的节点路径并高亮保留匹配节点同时折叠无关分支。这里有个实现细节值得给Ease UI点赞过滤后节点的原始展开状态会被缓存清空关键词后能恢复到过滤前的状态而不是整个树被重置成初始折叠状态。这个细节在真实使用中非常提升体验。5. 高级玩法与二次开发建议5.1 懒加载模式对接真实后端接口当部门数据超过5000条或者部门层级深且数据不在同一次接口返回时全量加载方案就会遇到性能瓶颈。这时lazy模式更合适。xly-dept-tree refdeptTreeRef lazy :loadloadDeptChildren :propslazyProps placeholder展开加载子部门 /const lazyProps { id: deptId, label: deptName, children: children, isLeaf: leaf } async function loadDeptChildren(node, resolve) { const parentId node.level 0 ? 0 : node.data.deptId const res await fetch(/api/dept/list?parentId${parentId}).then(r r.json()) const list res.data.list const formatted list.map(item ({ ...item, // 通过没有children字段或者leaf字段判断是否为叶子节点 leaf: !item.isParent })) resolve(formatted) }注意resolve一定要被调用不然节点会一直处于加载状态。同时确定好leaf字段的值否则组件无法判断叶子节点会导致所有没有子级的节点也渲染出展开箭头。5.2 自定义节点内容加数字角标或操作按钮树组件不可能满足所有展示需求比如某个部门下在职人数超过100人产品要求在部门名旁边显示一个红色的数字角标。xly-dept-tree提供了默认插槽可以自定义节点内容xly-dept-tree :datatreeData node-keydeptId :propstreeProps template #default{ node, data } span classcustom-node-wrapper span classdept-name{{ data.deptName }}/span span v-ifdata.memberCount 100 classdept-hot-tag {{ data.memberCount }} /span /span /template /xly-dept-tree插槽参数里能拿到当前节点对象和原始数据自由度很高。我之前在项目里就基于这个插槽做了行内操作按钮实现悬浮某个部门时出现“新增子部门”和“编辑”两个小图标交互效果很接近甲方经常对标的那种商业管理系统。5.3 节点状态控制与禁用逻辑部门树不是所有节点都能随意勾选的。比如某些部门已经停用或者当前用户对某个部门只有只读权限这时就需要禁用对应节点的选择能力。组件提供了disabled字段控制const treeProps { label: deptName, disabled: (data) data.status ! 0 || data.readonly }disabled既可以是字符串字段名也可以是函数函数形式可以基于节点数据动态判断。这样配合搜索过滤停用部门能正常看到但不允许选择从交互源头规避了用户选了不该选的部门导致后续流程报错的问题。5.4 手写一个部门树弹窗选择器前面几节把组件能力都过了一遍这里组合成一个完整的高频业务组件部门弹窗选择器。这个组件的核心功能点击输入框弹出树形选择器支持搜索选中部门后回显名称再次打开能正确勾选。我封装成一个小组件后续项目中几乎零成本复用。template el-popover triggerclick placementbottom-start width360px template #reference el-input :model-valuedisplayNames.join(、) placeholder请选择部门 readonly suffix-iconarrow-down clearhandleClear / /template xly-dept-tree refpopoverTreeRef :datadeptTreeData node-keydeptId :propstreeProps show-checkbox filterable only-leaf-checkable checkhandleTreeCheck / /el-popover /template script setup import { ref, computed, watch, nextTick } from vue const props defineProps({ modelValue: { type: Array, default: () [] }, deptTreeData: { type: Array, default: () [] } }) const emit defineEmits([update:modelValue]) const popoverTreeRef ref(null) const checkedNodeMap ref({}) const displayNames computed(() { return Object.values(checkedNodeMap.value).map(n n.deptName) }) watch(() props.modelValue, (ids) { if (!ids || ids.length 0) return nextTick(() { popoverTreeRef.value?.setCheckedKeys(ids) }) }, { immediate: true }) function handleTreeCheck(checkedNodes) { const map {} checkedNodes.forEach(node { map[node.deptId] node }) checkedNodeMap.value map emit(update:modelValue, Object.keys(map)) } /script这个封装模式在实际业务里很吃香。输入框只读展示名称弹层负责选择数据流用v-model打通业务侧完全不用关心树内部的勾选细节。6. 常见问题与排查技巧实录6.1 树渲染出来了但勾选没反应这个问题多半出在node-key配置上。node-key字段在组件内部是节点唯一标识如果你页面上的字段是deptId却忘了在props里映射组件默认按id找自然找不到。排查方法很简单给组件加一个check事件看事件回调里拿到的节点数据deptId是否为空。6.2 懒加载模式下二级节点不显示展开箭头前面提过懒加载时判断叶子节点依赖isLeaf字段。如果接口没有返回类似isParent的标记组件会认为每个节点都有子级导致叶子节点一直显示展开箭头点击后请求接口返回空数组看着像Bug。解法是在load函数里对返回结果做一次判断没有子级的节点显式加上leaf: true。6.3 回显时部分节点勾选状态丢失回显的setCheckedKeys和父子联动勾选之间有一个经典冲突。如果组件开着父子联动你往setCheckedKeys里塞一个父级id组件会联动勾选所有子级但你只塞了部分子级id父级又是半选状态看起来就像“有些节点没勾上”。我的经验是回显前先明确当前场景的勾选策略。如果业务语义是选择叶子部门回显时只塞叶子id同时确保叶子节点在树中已加载完成。如果是懒加载回显前需要先展开到对应层级或者使用组件的load预先缓存路径否则节点没渲染setCheckedKeys找不到对象也会静默失败。6.4 大数据量下搜索仍然卡顿filterable开启后组件内部会对整棵树做遍历匹配。部门节点数量超过5000时每次输入都全量遍历确实会有卡顿感。这不是组件本身性能不行而是前端字符串匹配天然有这种损耗。我建议配合节流处理输入事件同时在数据量极大时把filter-method改成先查后端。也就是说前端只保留层级结构搜索时调接口拿匹配结果再通过setExpandedKeys和setCheckedKeys手动控制展示。这个方案能彻底解决前端大数据量搜索的瓶颈。7. 版本联动与配套升级建议7.1 这次更新对现有项目的影响评估xly-dept-tree作为新增组件对存量项目是纯增量更新理论上不会破坏现有页面。但有几个点我建议升级时顺手检查一下。第一组件库的全局样式是否和业务自定义样式冲突尤其是弹窗层级和树节点缩进样式第二如果业务里已经有大量基于通用Tree实现的部门选择代码可以保留现状不必为了新组件强行重构等对应模块要做迭代时再顺手切换风险更低。7.2 从业务代码沉淀到组件库的思考这次Ease UI把xly-dept-tree做成独立组件背后其实是一个很典型的组件提炼思路先从多个业务项目中找出同类结构抽象出公共数据模型和交互模式再通过配置项解决差异化诉求。对自研组件库的团队来说这个决策路径值得参考——不是把所有相似的东西都塞进通用组件也不是直接把某条业务线的代码原封不动提出来而是在“够通用”和“够专用”之间找到稳定的组件边界。拿部门树来说它和通用Tree的边界就在于是否默认处理了部门数据格式、是否内置了搜索过滤的部门语义、是否考虑了数据权限场景的勾选模式。越过这条边界组件就成了一个“半通用半专用”的模糊产物反而让人无从下手。7.3 踩过坑之后的最终使用建议基于我用下来的感受给你几条实在的建议。第一接口数据结构务必在联调前对齐。字段映射虽然能兜底但反复转换数据结构本身就是在增加复杂度。第二弹窗选择场景优先开启filterable和only-leaf-checkable。这两个配置组合能让用户体验从“在一棵大树里大海捞针”升级为“搜到即选、选完即走”。第三懒加载不一定比一次加载更快。如果部门数据只有几百条全量返回反而省事懒加载会带来接口请求次数增加和节点展开延迟的问题。第四勾选策略要和产品经理确认清楚尤其注意父子联动和叶子选择的边界场景。这是部门树组件里最影响用户感知的配置项错了用户会明显感觉到“勾了父级子级全被选上了”“想选父级却只能选叶子”的落差。8. 实际使用中的体会与收获这次xly-dept-tree上手用下来我最大的感受是组件设计是真的在认真解决业务问题而不是简单把通用Tree换个名字。它给部门这个高频业务场景定了一个相对标准的交互框架后续新项目接组织架构模块时我不需要再从头梳理选人逻辑、树展示逻辑、搜索逻辑和回显逻辑直接在这个组件基础上做业务适配就行。最后再分享一个小记忆点如果你在同一个页面里同时用了多棵xly-dept-tree比如一个人既要选归属部门又要选可管辖范围记得给每一棵树单独绑定ref和v-model不要复用一个数组。这个坑我当年踩过一次两个树的数据互相串了排查了大半天才定位到问题是数据引用没有隔离。这种细节文档里通常不会写但真正写业务代码时一旦碰到还挺耗时间的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门