前端登录菜单加载性能优化总结
问题现象
登录后页面出现明显卡顿,接口返回约 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 个节点时,每遇到符合条件的节点就同步调用:
router.addRoute()— 每次调用都会触发路由匹配器重新编译,N 次调用相当于 O(N²) 的编译开销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 一次,反复展开数组 | 收集后一次性合并 |
经验教训
- 数据量不大 ≠ 不会慢:几百条数据看似不多,但 O(N²) 算法 + 循环内触发框架重计算,开销会被成倍放大
- 注意框架 API 的隐性成本:
router.addRoute不是简单的数组 push,每次调用都有路由匹配器重编译的开销;store.commit如果涉及数组展开合并,高频调用同样代价不小。应避免在循环中高频调用这类 API - 先收集后批量是处理这类问题的通用模式:把"遍历"和"副作用"分离,遍历阶段只做纯数据收集,副作用(路由注册、状态提交、DOM 操作等)统一在最后批量执行