菜单中心——封装菜单树的构建与菜单操作

474 阅读4分钟

前言

有需要在一个页面内维护多层级菜单的需求,而之前的对菜单数据的处理是,用多层嵌套的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了,有空再优化吧。