手把手实现高性能可访问性菜单组件的完整开发过程

上官夏沫 组件 阅读 1,585
赞 24 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个新后台,菜单用了 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.createElementrender 上,其中 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 录一下,八成问题出在“过度渲染”上。

后续可能会写写「如何给超长树形菜单加虚拟滚动」或者「菜单权限校验的性能陷阱」,都是我最近刚踩过的坑。先去喝口凉掉的咖啡,这周总算没加班到凌晨两点。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论