前端登录菜单加载性能优化总结

16 阅读3分钟

前端登录菜单加载性能优化总结

问题现象

登录后页面出现明显卡顿,接口返回约 900 条菜单权限数据,数据量不大但前端处理耗时明显,主线程被阻塞。

瓶颈分析

整个调用链路:获取菜单接口 → 扁平数组转树形结构 → 存储菜单状态 → 解析路由和权限

排查后发现有两处性能问题:

瓶颈一:扁平数组转树形结构 — O(N²) 复杂度

问题:内层递归函数对每个节点都遍历整个剩余数组查找子节点,加上 Array.splice() 从数组中间删除元素,整体时间复杂度为 O(N²)。

// 原实现(简化)
function arrayToTree(parent, level) {
  var k = list.length - 1;
  while (k >= 0) {
    if (parent.id === list[k].pid) {
      children.push(list[k]);
      list.splice(k, 1);        // O(N) 删除
      arrayToTree(list[k], level + 1); // 递归后又从头遍历
    }
    k--;
  }
}

优化:一次遍历建立 pid → children[] 的 Map 索引,递归时通过 Map 直接查找子节点,时间复杂度降为 O(N)。

// 优化后
var childrenMap = {};
items.forEach(function (item) {
  var pid = item[option.pid].toString();
  if (!childrenMap[pid]) childrenMap[pid] = [];
  childrenMap[pid].push(item);
});

function buildTree(parent, level) {
  var children = childrenMap[parent[option.id].toString()];
  if (!children) return;
  // 直接通过 Map 拿到子节点,无需遍历整个数组
}

瓶颈二:循环内反复触发框架级 API(动态路由注册 + 状态更新)

问题:这是最主要的瓶颈。递归遍历 900 个节点时,每遇到符合条件的节点就同步调用:

  1. router.addRoute() — 每次调用都会触发路由匹配器重新编译,N 次调用相当于 O(N²) 的编译开销
  2. store.commit() — 每次提交都用扩展运算符 [...oldArr, ...newArr] 创建新数组,随着累积数组越来越大,开销递增
// 原实现(简化)
data.reduce((pre, cur) => {
  // 每个节点都触发一次框架 API
  router.addRoute('layout', { ... });     // 重建路由匹配表
  store.commit('UPDATE_MENU', { ... });   // 展开合并数组
  if (cur.children.length) traverse(cur.children, ...);
}, []);

优化:先收集,后批量操作。遍历过程中只往普通数组/对象中 push 数据,遍历完成后统一执行副作用。

// 优化后
var pendingRoutes = [];
var pendingMenus = {};

function collect(data, ...) {
  // 遍历中只收集数据,不调用框架 API
  pendingRoutes.push({ ... });
  pendingMenus[key] = (pendingMenus[key] || []).concat(items);
}

collect(data, ...);

// 遍历完成后批量添加路由
pendingRoutes.forEach(route => router.addRoute('layout', route));

// 一次性合并菜单数据到状态树
Object.keys(pendingMenus).forEach(key => { ... });

优化效果

环节优化前优化后
数组转树O(N²),含 splice 删除O(N),Map 索引查找
动态路由注册每个节点调用一次,反复重建匹配表收集后批量添加
状态更新每个节点 commit 一次,反复展开数组收集后一次性合并

经验教训

  1. 数据量不大 ≠ 不会慢:几百条数据看似不多,但 O(N²) 算法 + 循环内触发框架重计算,开销会被成倍放大
  2. 注意框架 API 的隐性成本router.addRoute 不是简单的数组 push,每次调用都有路由匹配器重编译的开销;store.commit 如果涉及数组展开合并,高频调用同样代价不小。应避免在循环中高频调用这类 API
  3. 先收集后批量是处理这类问题的通用模式:把"遍历"和"副作用"分离,遍历阶段只做纯数据收集,副作用(路由注册、状态提交、DOM 操作等)统一在最后批量执行