前言
有需要在一个页面内维护多层级菜单的需求,而之前的对菜单数据的处理是,用多层嵌套的for循环去构建一个菜单树,然后目标节点的获取也是多重for循环外加一堆中间变量;导致代码很难维护(看都看不懂)也很难扩展,出现一堆bug根本修不完。遂决定进行重构。
重构思路
1、利用菜单项的pid(指向父节点的id),与id属性,构建一个菜单森林。
2、遍历菜单森林,为每个菜单节点计算出path属性,用于标识该节点在每一层的索引值,如path为1-2-1的节点则是第二颗菜单树a,a的第三个子节点b,b下的第二个子节点c(以0开始)。
3、每次菜单点亮(激活)时,构建一个当前的点亮路径(由目标节点到根节点)。其中旧的、需要失活的节点与新的、需要激活的节点,则通过对比新旧两条路径,计算出第一个id不同的节点的下标,就能得出旧路径该下标以后的节点为需要失活节点,新路径该下标以后的节点为需要激活节点。
如: (为表达方便,下方用节点索引值表示节点,如0则为该层第一个节点,3为该层第四个节点,[2, 3, 1]表示第一层的第三个节点a,和a下面第四个节点b,和b下面第二个节点c)
新路径:[2,3,1,4,1],
旧路径:[2,3,3,2],
计算出的索引值就是2,所以1、4、1为需要激活的节点;3、2为需要失活节点。
源码
// 该文件提供菜单类及操作菜单的相关方法
// menuPathArr(菜单路径数组):指由根到目标菜单构成的数组如[grandParent, parent, child]
// path的分隔符
const SEPARATOR = '-';
// 定义菜单类,规范菜单数据结构,同时其他人方便了解菜单数据结构
export class Menu {
constructor({
code = '',
id = '',
name = '',
orderField = -1,
pid = null,
path = '',
parent = null,
children = [],
isActive = false,
isUnfold = false,
}) {
this.code = code;
this.id = id;
this.name = name;
this.orderField = orderField;
this.pid = pid;
// 以上为后端返回的数据,以下为自定义类型
// 目标在每层索引值构成的字符串,如 0-3-2
this.path = path;
this.parent = parent;
this.children = children;
this.isActive = isActive;
this.isUnfold = isUnfold;
}
get hasChild() {
return this.children.length > 0;
}
}
// 根据传入节点得到由传入节点到根节点构成的路径数组
export function getMenuPathArr(menu = new Menu()) {
const result = [menu];
while (menu && menu.parent) {
menu = menu.parent;
result.unshift(menu);
}
return result;
}
// 根据pid构建菜单树,并返回由一级节点构成的数组
function buildTree(arr) {
arr.forEach((item) => {
if (item.pid) {
const parent = arr.find(i => i.id === item.pid);
parent.children.push(item);
item.parent = parent;
}
});
// 过滤出第一层元素
return arr.filter(item => !item.pid);
}
// 深度优先遍历菜单树,计算出path,如:3-1-0
function addPath(arr, path = '') {
for (let i = 0, len = arr.length; i < len; i++) {
const item = arr[i];
item.path = path ? path + SEPARATOR + i : i + '';
item.hasChild && addPath(item.children, item.path);
}
}
// 将节点转换为menu
export function normalizeMenuList(list = []) {
return list.map((item) => {
if (item instanceof Menu) {
return item;
}
return new Menu(item);
});
}
// 生成带path、可使用的菜单树
export function buildMenuTree(list = []) {
// 将数据转换为菜单列表
let menus = normalizeMenuList(list);
// 构建父子关系
menus = buildTree(menus);
// 生成path
addPath(menus);
return menus;
}
// 激活目标菜单,并使已激活但应失活的菜单失活
export function highlightMenu(menus, preActiveList = []) {
const currentArrList = Array.isArray(menus) ? menus : getMenuPathArr(menus);
if (!currentArrList.length > 0) {
return;
}
const active = []; // 存放应激活菜单
const deactive = []; // 存放应失活菜单
// 返回小的数组,防止越界
const arr = currentArrList.length > preActiveList.length ? preActiveList : currentArrList;
// 找到两个数组不相同的位置
let index = 0;
const len = arr.length;
while (index < len) {
const cI = currentArrList[index];
const pI = preActiveList[index];
if (cI.id !== pI.id) break;
index++;
}
// 生成需失活菜单列表
deactive.push(...preActiveList.slice(index));
// 生成需激活菜单列表
active.push(...currentArrList.slice(index));
// 使列表菜单失活
deactive.forEach((item) => {
item.isActive = false;
});
// 使列表菜单激活
active.forEach((item) => {
item.isActive = true;
});
// 重置外部的preActiveList,供下次计算使用,
preActiveList.splice(index, deactive.length, ...active);
}
// 返回一个菜单路径数组
export function findMenuByPath(menus = [], path = '') {
let pathArr = path.split(SEPARATOR);
// 过滤空值
pathArr = pathArr.filter(item => item);
const result = [];
pathArr.reduce((arr, index) => {
result.push(arr[index]);
return arr[index].children;
}, menus);
return result;
}
// 按照传入的key值获取目标菜单,该方法返回一个菜单路径数组
// 深度优先遍历,找到第一个匹配元素
export function findMenuBy(menus = [], key = '', val = '') {
let currentMenu = null;
const traversal = (arr) => {
for (const item of arr) {
// 这里注意区分数字跟字符串
if (item[key] === val) {
currentMenu = item;
break;
} else if (Array.isArray(item.children) && item.children.length > 0) {
traversal(item.children);
if (currentMenu) break;
}
}
}
traversal(menus, key, val);
return getMenuPathArr(currentMenu);
}
// 折叠与展开逻辑不一样,所以分开写
export function unfoldMenu(menu = new Menu()) {
const menuPathArr = getMenuPathArr(menu);
menuPathArr.forEach((item) => {
!item.isUnfold && (item.isUnfold = true);
});
}
export function foldMenu(menu = new Menu()) {
menu.isUnfold && (menu.isUnfold = false);
}
export function toggleMenuFold(menu = new Menu()) {
const fn = menu.isUnfold ? foldMenu : unfoldMenu;
fn(menu);
}
上面的实现很多算法是参考的vue的,如菜单的激活/失活操作(highlightMenu)是参考vue-router的路由失活/激活,目标节点的获取(findMenuByPath)是参考vuex中带命名空间的模块的获取。
重新阅读代码发现其实菜单树也是可以抽象为一个继承Array的类的,这样菜单操作的相关方法都可以聚合到这个类下而不用显示的import了,有空再优化吧。