手把手实现高性能可访问性菜单组件的完整开发过程
优化前:卡得不行
上周上线一个新后台,菜单用了 React + Ant Design 的 Sider + Menu,数据量不大——就 80 条左右的菜单项,分了 4 层嵌套。结果一打开左侧菜单,Chrome 直接卡住 3 秒多,鼠标悬停、收起展开全在掉帧,控制台还疯狂报 Warning: Cannot update a component while rendering a different component。用户反馈说“点一下菜单要等呼吸两次”,我信了。
更离谱的是,切换到移动端(模拟 iPhone 12),第一次展开二级菜单,整个页面白屏接近 2 秒——不是加载,是渲染阻塞。我当场把本地 dev server 关了,泡了杯咖啡,决定不修完不下班。
找到瘼颈了!
先开 Chrome DevTools → Performance 面板,录一段“点击一级菜单展开二级”的操作。跑完一看,主线程里 65% 时间花在 React.createElement 和 render 上,其中 40% 是重复创建子菜单节点。再切到 Memory 标签,拍个堆快照,发现每次展开都新增几百个 MenuItem 实例,但其实 90% 的二级、三级菜单根本没被展开过,却全被提前渲染好了。
又顺手开了 React DevTools 的 Profiler,勾选 “Highlight updates when components render”,点开菜单那一瞬间——整棵树红得像番茄酱。尤其是每个 SubMenu 下面的 MenuItem,哪怕只是 hover,也触发完整重 render。查了下 antd 的源码(v5.12.3),发现它默认对所有子项做 React.cloneElement + props.children 透传,没有做任何惰性处理,key 还写死成 index……我默默关掉了文档,开始自己动手。
优化后:流畅多了
核心思路就一条:没展开的子菜单,别 render;没 hover 的项,别挂事件;别让 React 去算那些永远用不到的 VDOM。下面这三步,是我试了几种方案后,效果最好、改动最小、上线零事故的组合:
- 第一步:用
memo+shouldRender控制子菜单渲染时机
原代码里,每个 SubMenu 都直接展开 children:
// ❌ 优化前:无脑渲染所有子项
const SubMenu = ({ title, children }) => (
<div className="submenu">
<span>{title}</span>
<div className="submenu-content">{children}</div>
</div>
);
改成只在 open 为 true 时才渲染 children,并且用 React.memo 包一层防止父组件重 render 带崩它:
// ✅ 优化后:按需渲染 + memo
const LazySubMenu = React.memo(({ title, children, open }) => {
return (
<div className="submenu">
<span>{title}</span>
{open && <div className="submenu-content">{children}</div>}
</div>
);
}, (prev, next) => prev.open === next.open && prev.title === next.title);
- 第二步:菜单项事件委托 + 节流 hover
原来每个 MenuItem 都绑了 onMouseEnter,80 个节点就是 80 个监听器,而且 hover 时还触发状态更新+动画 class 切换。我直接干掉所有 inline event,换成 onMouseMove 委托到父容器,配合 requestIdleCallback 节流:
// ✅ 优化后:事件委托 + 节流
const MenuContainer = ({ items }) => {
const [hoveredKey, setHoveredKey] = useState(null);
const handleMouseMove = useCallback((e) => {
const target = e.target;
const key = target?.dataset?.menuKey;
if (key && key !== hoveredKey) {
requestIdleCallback(() => setHoveredKey(key), { timeout: 100 });
}
}, [hoveredKey]);
return (
<div className="menu-container" onMouseMove={handleMouseMove}>
{items.map(item => (
<MenuItem
key={item.key}
data-menu-key={item.key}
className={hoveredKey === item.key ? 'active' : ''}
>
{item.label}
</MenuItem>
))}
</div>
);
};
- 第三步:菜单结构扁平化 + 动态 import 子模块(关键!)
原来菜单配置是全量 JSON 加载的,比如:
{
"menu": [
{ "key": "dashboard", "label": "首页", "path": "/dashboard" },
{ "key": "user", "label": "用户管理", "children": [ /* 30+ 条 */ ] }
]
}
我改成了「路由驱动菜单」:只加载当前权限内的一级菜单,二级菜单由路由懒加载触发,用 React.lazy + Suspense 包住子组件,菜单项只存 key 和 label,真正的子菜单结构从路由匹配中动态读取。这样首次加载菜单配置体积从 120KB 直降到 8KB。
最后还加了个小技巧:给所有 MenuItem 加上 will-change: transform,让 hover 动画走 GPU 合成层——这个提升很小,但 iOS Safari 上确实少了一次 layout 抖动。
性能数据对比
测的是「首次加载菜单 + 点击展开二级菜单」这一整套流程,在 MacBook Pro M1 + Chrome 124 上实测(关闭所有插件,禁用缓存):
- 优化前:首屏菜单渲染耗时 4.7s,展开二级菜单平均响应延迟 1.2s,FPS 跌到 12~18
- 优化后:首屏菜单渲染耗时 780ms,展开二级菜单平均响应延迟 90ms,FPS 稳定在 58~60
内存占用也降得明显:优化前堆内存峰值 180MB,优化后压到 65MB 左右。最关键的是——那个烦人的 Warning 消失了,因为再也没出现“render 期间 setState”。
踩坑提醒:这三点一定注意
第一,React.memo 不要乱用。我一开始给整个 Menu 组件包 memo,结果发现 props 里有个函数每次都新生成,等于白 memo。后来改用自定义 compare 函数,只比关键字段,才真正生效。
第二,requestIdleCallback 在 Safari 上兼容性差,iOS 15.4 以下根本不支持。我 fallback 用了 setTimeout(..., 0),虽然不够精准,但至少不会挂。
第三,别迷信“用 CSS 替代 JS”。有同事说“hover 效果全用 :hover 就行”,试了下,发现当菜单嵌套深、层级多时,CSS 的层叠计算反而更耗 CPU,特别是配合 transform + opacity 动画时,某些安卓 WebView 会直接掉帧。JS 控制还是更可控。
另外提一句:antd 官方其实在 v5.13 开始加了 forceSubmenuRender 属性,但默认是 true,文档里藏得特别深,搜了半小时才翻到。我们项目没升版本,所以还是自己搞了一套。
以上是我个人对这个 Menu 性能优化的完整实践,有更优的实现方式欢迎评论区交流
这个方案不是最完美的(比如没上虚拟滚动,毕竟菜单深度有限),但足够简单、可维护、上线即见效。如果你也在搞后台菜单,尤其是带权限动态生成的那种,建议先抓个 Performance 录一下,八成问题出在“过度渲染”上。
后续可能会写写「如何给超长树形菜单加虚拟滚动」或者「菜单权限校验的性能陷阱」,都是我最近刚踩过的坑。先去喝口凉掉的咖啡,这周总算没加班到凌晨两点。

暂无评论