设计开发一个多级联动选择器

设计开发一个多级联动选择器

Tags
前端
开发记录
Published
August 9, 2020
Author
LIAOKUN

开发背景

管理后台需求新增多级联动选择器,功能类似于ElementUI的
,但产品希望在交互上能直接展示选项而不是需要依靠选择器来触发。因此考虑自己设计开发一套多级联动的选择器,最终效果如图:
notion image

功能分析

在开发前先梳理控件的功能点:
  • 控件是多级结构的,每级的每一节点提供 点击 和 选择 两个功能
  • 当触发某一节点的 点击 事件时展开该项的子节点
  • 当触发某一节点的 选择 事件时,切换该项的勾选框
  • 当触发某一节点的 勾选 事件时, 展开子节点并递归勾选所有子节点
  • 当触发某一节点的 勾选 事件时, 递归遍历父节点切换父节点的 勾选 / 半选 状态
  • 当触发某一节点的 **取消勾选 **事件时, 递归取消勾选所有子节点的勾选
  • 当触发某一节点的 **取消勾选 **事件时, 递归遍历父节点切换父节点的 勾选 / 半选/ 取消勾选 状态
  • 提供搜索功能,检索包含关键词的节点并显示,显示筛选结果的同时需要带出节点的勾选状态
  • 提供重置搜索按钮,显示全量数据,需要包含全量数据的勾选状态

后端数据结构分析

后端会返回一份树状的数据,类似于如下的数据结构
interface responseData { "code":"0", "desc":"查询成功", "data":[ { "levelName":"adx", // 节点所在列的名字 "itemValue":"01", // 节点的value "itemName":"爱奇艺", // 节点的名字 "isCheck":false, // 节点的勾选状态 "sonData":[ // 节点的子节点 { "levelName":"频道", "itemValue":"1", "itemName":"电影", "isCheck":false, "sonData":[ { "levelName":"关键词", "itemValue":"美国", "itemName":"美国", "isCheck":false, "sonData":[ ] } ] }, { "levelName":"频道", "itemValue":"9", "itemName":"旅游", "isCheck":false, "sonData":[ { "levelName":"关键词", "itemValue":"美国", "itemName":"美国", "isCheck":false, "sonData":[ ] } ] }, { "levelName":"频道", "itemValue":"24", "itemName":"财经", "isCheck":false, "sonData":[ { "levelName":"关键词", "itemValue":"美国", "itemName":"美国", "isCheck":false, "sonData":[ ] } ] }, { "levelName":"频道", "itemValue":"28", "itemName":"军事", "isCheck":false, "sonData":[ { "levelName":"关键词", "itemValue":"美国", "itemName":"美国", "isCheck":false, "sonData":[ ] } ] } ] } ] }
如果仅仅为了展示视图那么递归树结构渲染元素就可以了,但因为功能点中包含搜索节点保留节点勾选状态;在点击勾选框时数据是不会实时同步到后端的,因此需要在前端维护一份 全量的节点数据 用于暂存节点勾选状态。 同时这份数据中也缺少了几个比较关键的字段:节点唯一id(itemValue不唯一,对于后端来说只保存itemValue,唯一id没有业务含义就没加上),节点的父节点id(当发生勾选状态切换时需要根据父节点id去递归父节点切换状态),当前节点所在的层级(递归渲染用不到,下文会提到用处)

前端数据结构设计

在切换节点状态时需要与当前节点的所有父节点和子节点进行联动,而在树结构下访问一个节点的父节点/祖先节点并不是很方便,加之后端返回的数据中并没有提供当前节点的父节点id,每发生一次勾选状态切换都会对树进行一次深度优先的遍历。 而在进行筛选的时候就更麻烦了,后端返回不带最新(指用户在浏览器中操作但没有通过保存等操作同步到后端)勾选状态的数据,前端需要把后端返回的树形结构数据与上文中提到的 维护的全量节点数据 进行一次对比之后再进行视图的渲染。 因此在前端采用Map的结构来进行数据的重新组合,一方面因为Map结构能提供 key-value 的访问支持,在解决祖先/子节点联动和筛选时的数据合并有更直观的操作;另一方面正如 MDN 中对于Map对象的介绍:
在频繁增删键值对的场景下表现更好。
基于这个设计思想,我们将根据后端返回的数据组合出如下三个Map结构的变量:
// 以uniqueItemValue(即上文中提到的节点唯一id)为key生成的Map 用于记录选择的结果 @observable adxPkgRecord = new Map(); /** * 用于驱动视图使用的两个Map,记录的均是将后端返回的嵌套结构拍平之后的数据 * Map中的每条数据在构建的时候都删除了所有的子节点避免占用过多内存 */ @observable adxDriveRenderData = { groupByLevelAdxPkg: new Map(), // 以节点所处层级为key建立的Map,用于快速构建处在第一级的节点 groupByParentValueAdxPkg: new Map() // 以父节点id为key建立的Map,用于触发勾选事件时快速切换父组件的勾选状态 };
为了便于叙述声明后端返回的数据为 const adxPkgData:responseData;  所有Map的构建都可以在一次对树的遍历中完成,为了逻辑更清晰拆分成两步进行Map的构建:
formatAdxPkgWithLevel 方法为adxPkgData添加level(节点所处层级),uniqueItemValue(节点唯一的id)parentValue(节点对应的父节点的uniqueItemValue)属性并删除每个节点的子节点字段(减小内存开销),并将递归嵌套的树形结构拍平组合成单层结构的Array。
/** * 改造adxPkg数据,使之带上level和parentValue属性 */ @action formatAdxPkgWithLevel = () => { let level = 0; let flattenDeepData = []; // 当使用map结构进行索引时就不用额外记录子节点只需要基本信息 const dataWithoutSonData = data => { let cloneData = cloneDeep(data); delete cloneData.sonData; return cloneData; }; // 递归函数:递归树形结构的数据添加树形并整理出单级结构的数组 const traverseSetLevelProperty = levelData => { let queue = []; levelData.forEach(item => { // 子节点的数量 item.sonDataCount = item.sonData.length; // 已勾选子节点的数量,和sonDataCount判断便可快速知道当前节点的取消勾选/半选/全选状态 item.checkedSonDataCount = 0; // 当前节点所处的层级 item.level = level; // 单个节点的itemValue不唯一,但父节点itemValue+节点itemValue的组合是唯一的可以作为唯一id item.uniqueItemValue = `${item.parentValue || ''}${item.itemValue}`; const parentValue = item.uniqueItemValue; // 处理子节点的parentValue属性和当前节点的checkedSonDataCount值 item.sonData.forEach(son => { son.parentValue = parentValue; son.isCheck && item.checkedSonDataCount++; }); // 直接push到数组中,以Map结构重新构造数据无所谓数据结构中的层级 flattenDeepData.push(dataWithoutSonData(item)); queue.push(...item.sonData); }); level++; // 递归出口:递归子节点做相同的操作 if (queue.length > 0) { traverseSetLevelProperty(queue); } }; traverseSetLevelProperty(adxPkgData); // 记录格式化之后的数据提供给buildAdxDriveRenderData使用 this.adxPkgWithLevel = flattenDeepData; }
buildAdxDriveRenderData 方法构建渲染视图时用到的几个Map数据:
/** * 构建以level parentValue为key的对象,同时初始化adxPkgRecord * @param {boolean} skipInitAdxPkgRecord 当搜索的时候不用初始化adxPkgRecord */ @action buildAdxDriveRenderData = (skipInitAdxPkgRecord = false) => { // 以level为key构建的Map let groupByLevelAdxPkg = new Map(); // 以parentValue为key构建的Map let groupByParentValueAdxPkg = new Map(); // 以uniqueItemValue为key生成的Map,即在前端维护的全量节点数据 let adxPkgRecord = new Map(); // 遍历formatAdxPkgWithLevel中处理好的数据构建Map this.adxPkgWithLevel.forEach(item => { const itemLevelData = groupByLevelAdxPkg.get(item.level); const itemParentValueData = groupByParentValueAdxPkg.get(item.parentValue); // 以level为key的Map对应的value是各个节点组成的数组 groupByLevelAdxPkg.set(item.level, [...(itemLevelData || []), item]); // 以parentValue为key的Map对应的value是各个节点组成的数组 groupByParentValueAdxPkg.set(item.parentValue, [...(itemParentValueData || []), item]); // adxPkgRecord数据直接set即可 adxPkgRecord.set(item.uniqueItemValue, toJS(item)); }); this.adxDriveRenderData = { // 以level为key的数据,用于构建视图第一列 groupByLevelAdxPkg, // 以父节点value为key的数据,用于点击节点时展开下一级目录 groupByParentValueAdxPkg }; // 仅在第一次的时候需要维护全量节点数据,搜索的时候从这份全量数据中取出相应的值 !skipInitAdxPkgRecord && (this.setValue('adxPkgRecord', adxPkgRecord)); }

页面事件

  • 搜索 handleSearch
  • 节点点击 handleNodeClick ,点击时触发子节点的展开 syncSonColsData
  • 勾选框的点击 handleCheckboxClick ,点击时会触发checkbox的切换 handleNodeCheck 和子节点的展开 syncSonColsData
  • 递归遍历子节点更新状态和checkedSonDataCount traverseSetSonDataStatus
  • 递归遍历父节点更新checkedSonDataCount和check traverseSetParentDataStatus

页面渲染

有了上文准备的数据和事件就可以非常方便地完成页面渲染和页面中的交互了:
  • 固定显示第一列的数据,默认渲染第一列第一行节点的子节点:从 groupByLevelAdxPkg 中可以直接取出第一级的数据渲染,触发第一列第一行节点的handleNodeClick 即可展开子节点。
  • 发生节点点击时触发handleNodeClick 事件,该事件从 itemParentValueData 中直接取出当前节点的子节点作为参数传给 syncSonColsData 渲染子节点。
  • 发生勾选时触发handleCheckboxClick 事件,切换当前节点的勾选状态,根据当前节点的 uniqueItemValue 和 parentValue 可以在itemParentValueDataadxPkgRecord 中递归取到所有的子孙和组件节点同步勾选状态。
  • 当发生搜索事件时同页面初始化流程一样先请求后端数据再调用formatAdxPkgWithLevelbuildAdxDriveRenderData 格式化返回的数据并渲染视图。