标题
一次“点一下就卡”的前端性能排查:为什么右侧卡片越多,侧边栏展开越卡?(CSS 动画触发 Reflow)
背景与现象
在一个 Next.js + React 的页面里,左侧有一个侧边栏(展开/收起按钮),右侧是工具卡片列表。
- 当右侧只有少量卡片时:点击展开/收起非常流畅
- 当右侧有大量卡片(上千)时:点击展开/收起出现明显卡顿(INP 上升、主线程被占用)
表面看起来是“按钮点击导致卡顿”,但真正原因往往不在 React 逻辑,而在 CSS 布局动画。
先讲结论(最重要的一句话)
对 width/height/left/top 这类“布局属性”做 transition,会在动画每一帧触发布局计算(reflow/layout)。右侧 DOM 越多,每帧越贵,于是交互就卡。
为什么右侧内容越多越卡?
浏览器渲染大致分几步:
- 计算样式(Recalculate Style)
- 计算布局(Layout / Reflow)
- 绘制(Paint)
- 合成(Composite)
当你做这样的 CSS 动画:
.sidebar {
width: 76px;
transition: width 0.3s ease;
}
.sidebar.open {
width: 236px;
}
动画的 0.3s 不是“一次变化”,而是几十帧连续变化(接近 60fps 时约 18 帧)。每一帧 width 都在变 → Layout 每一帧都要重算。右侧卡片越多,意味着:
- DOM 节点更多
- 布局树更复杂
- 可能还有更多文字、图片、阴影等绘制成本
所以单次 Layout/Paint 的成本更高;当它被重复几十次,就会把主线程占满,导致点击响应变慢(INP 变差)。
如何快速确认“是不是布局动画导致的卡顿”?
用 Chrome DevTools:
1) Performance 录制
- 打开 DevTools → Performance
- 点击 Record
- 点击侧边栏展开/收起按钮
- 停止 Record
如果你看到时间轴中出现明显、连续的 Rendering(尤其 Layout)条段,并贯穿动画持续时间,那基本就坐实了。
2) 直接排查 CSS
搜索组件样式里是否存在:
- transition: width ... / transition: height ...
- 或 transition: all ...(最危险,可能把布局属性也动画化)
- left/top/margin/padding 等属性动画
解决原则:把“布局动画”换成“合成动画”
浏览器对 transform / opacity 的优化最好,很多时候能走合成层,不触发布局重排。
推荐做法
- 宽度切换:可以变,但不要做 transition(瞬间切换)
- 视觉动效交给:
- transform: translate/scale/rotate
- opacity
示例(侧边栏宽度瞬切 + 内容淡入/位移):
/* 不要对 width 做 transition */
.sidebar {
width: 4.75rem;
transition: none;
}
.sidebar.open {
width: 14.75rem;
}
/* 动效交给 transform/opacity(不会触发布局重排) */
.titleText {
opacity: 0;
transform: translateX(-0.5rem);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.sidebar.open .titleText {
opacity: 1;
transform: translateX(0);
}
/* 箭头旋转同理 */
.toggleIcon {
transition: transform 0.2s ease;
}
.sidebar.open .toggleIcon {
transform: rotate(180deg);
}
额外优化:尊重“减少动态效果”
对偏好减少动画的用户,也能避免一些设备掉帧:
@media (prefers-reduced-motion: reduce) {
.sidebar,
.titleText,
.toggleIcon {
transition: none !important;
animation: none !important;
}
}
你可能会踩的坑
- 误以为是 React 重新渲染导致卡:实际上 React 渲染可能很少,真正耗时在 Layout/Paint
- transition: all:很容易把布局属性也带进动画,导致“看似小改动,性能大翻车”
- 右侧少量卡片很流畅:只是没到临界点,并不代表实现没问题
验收标准(怎么判断修复成功)
修复后重新录制 Performance,你应该看到:
- 点击展开/收起时,Layout 不再连续爆发
- 主线程空闲时间增加
- INP 更稳定
- 即使右侧卡片很多,点击也更“跟手”
总结
这类问题的本质是:一次点击触发了“持续的布局动画”,页面越复杂,动画每帧越贵。解决思路不是“让 React 更快”,而是把动画从 layout 迁移到 compositor-friendly(transform/opacity)。如果你准备把这篇文章发到掘金/公众号,我建议再补两张图:
- Performance 录制的“修复前 vs 修复后”对比(Layout 条段变化非常直观)
- 对应 CSS diff(从 transition: width → transition: none + transform/opacity)