GPU 合成层炸了,页面白屏------从 will-change 滥用聊到层爆炸的治理
上个月接手一个项目,列表页在低端安卓机上打开直接白屏。Chrome DevTools 的 Layers 面板一开,绿色块铺满屏幕,合成层数量:1400+。罪魁祸首长这样:
css
/* 某个"性能优化高手"留下的遗产 */
.card {
will-change: transform;
}
.card-title {
will-change: opacity;
}
.card-img {
will-change: transform;
}
.card-btn {
will-change: transform, opacity;
}
/* 一个卡片组件,四个元素全部提升为合成层 */
/* 列表页一次渲染 200 张卡片 → 800 个合成层 */
/* 恭喜,GPU 内存直接起飞 */
四个元素,每个都挂着 will-change,乘以 200 张卡片就是 800 个合成层。
隐式合成------先讲这个,因为它才是大头
多数层爆炸的文章把隐式合成放在后面讲,但实际项目里它才是最大的杀伤来源。那个 1400 层的项目,800 个是 will-change 造成的,剩下 600 多个全是隐式合成的产物。
规则本身一句话就说清楚:**一个普通元素如果在 z 轴方向上叠在合成层上方,浏览器为了保证绘制顺序,会把它也强制提升为合成层。
也就是说你只要在一个列表底部放一个带动画的背景元素,上面的所有列表项都会被"传染"。你写了一行 CSS,浏览器帮你创建了几百个合成层。这就是为什么治理层爆炸的第一步不是删 will-change,而是先查 overlap------它的数量往往比你主动创建的层多得多。
合成层的代价:一笔显存账
浏览器渲染管线的最后一步是 Composite(合成),在这一步之前的 DOM 解析、样式计算、布局、绘制全在 CPU 上整完,只有合成阶段交给 GPU。被单独提取出来交给 GPU 处理的元素就是合成层。
正常页面的合成层很少------根层、fixed 导航栏、正在跑动画的元素,加起来可能不到 10 个。GPU 把这几层按顺序叠起来,开销可以忽略。但每个合成层都要在 GPU 显存里分配一块位图缓存,大小 = 宽 × 高 × 4 字节(RGBA)。算一下 1400 个层是什么概念:
js
// 简单算笔账
const avgWidth = 300
const avgHeight = 150
const bytesPerPixel = 4 // RGBA
const layerCount = 1400
const totalMemory = avgWidth * avgHeight * bytesPerPixel * layerCount
console.log(`${(totalMemory / 1024 / 1024).toFixed(0)} MB`)
// → 240 MB
// 低端安卓机总共就 512MB GPU 内存,直接 GG
240MB,还没算纹理上传和上下文切换的开销。低端安卓机的 GPU 内存可能只有 512MB,被一个网页吃掉一半,系统选择自保------直接杀掉渲染进程,用户看到的就是白屏。
will-change 只该在动画前一刻加上
will-change 的设计意图是提前一帧通知浏览器"这个元素即将发生变化",让浏览器有时间创建合成层、分配纹理,避免动画首帧卡顿。它是一个时序控制工具,不是性能开关。
css
/* ✅ 正确用法:hover 时才告诉浏览器"我要动了" */
.card {
transition: transform 0.3s;
}
.card:hover {
will-change: transform;
}
/* ❌ 错误用法:写在默认样式里,页面一加载就提升 */
/* 相当于跟浏览器说"我随时可能动"------然后一辈子没动过 */
.card {
will-change: transform;
}
写在默认样式里的 will-change 等于把"马上"变成了"永远"。合成层创建了就不会释放,显存占了就不会还。更离谱的是有人把它当成 translateZ(0) 的替代品------以前用 translate3d(0,0,0) hack GPU 加速,后来发现 will-change 也能触发层提升,就当成了新一代 hack。工具用错了场景,优化就变成了负担。
触发层提升的完整清单
不只是 will-change 会创建合成层,以下条件都会触发:
less
有 3D 变换的(translate3d, rotate3d...)
有 will-change: transform/opacity/filter 的
有 position: fixed 的
有 CSS 动画或过渡正在运行的(仅限 transform/opacity)
有 <video>、<canvas>、<iframe> 的
有 CSS filter 的
有 backdrop-filter 的
有 mix-blend-mode 的(不是 normal)
有 clip-path 动画的
有 mask 的(某些情况)
这里面 backdrop-filter 和 mix-blend-mode 是最容易被忽略的两个。设计稿里一个毛玻璃卡片看着好看,backdrop-filter: blur(10px) 一加,每张卡片就多一个合成层。200 张卡片,200 个合成层,设计评审时没人会想到这一层。
实操:用 Chrome Layers 面板做层治理
怎么打开 Layers 面板
DevTools → Ctrl+Shift+P → 输入 "layers" → 回车。面板打开后是一个 3D 视图,页面的合成层像切片面包一样展开。正常页面 5-10 片,看到几百片密密麻麻堆在一起就说明出问题了。
第一步:按内存排序,看提升原因
面板右侧列出了所有合成层的大小、内存占用和提升原因(compositing reason)。先按内存排序找到最大的几个,再看提升原因这一栏:
arduino
// 打开 Layers 面板后右侧每个层的 "Compositing Reasons" 字段
// 常见的几种:
"willChange" // 你主动写了 will-change → 你的锅
"transform3D" // 用了 translate3d/rotate3d → 大概率也是你的锅
"overlap" // 隐式合成!被别的合成层"传染"的 → 重点排查对象
"activeAnimation" // 正在跑动画 → 可能合理,看动画结束后是否回收
"backdropFilter" // 毛玻璃效果 → 确认是否真的需要
"video" // <video> 元素 → 正常,别管
"iFrame" // <iframe> → 正常
"positionFixed" // fixed 定位 → 正常,但数量要控制
overlap 数量多就说明层级结构有问题,这是排查的第一优先级。
第二步:用决策树判断每个层该不该留
这是我在项目里反复用的排查路径:
css
这个元素需要合成层吗?
│
├─ 它有正在运行的 transform/opacity 动画?
│ ├─ 是 → 保留,动画结束后确认层是否回收
│ └─ 否 → 往下
│
├─ 它用了 will-change?
│ ├─ 是 → 这个 will-change 是动态加的还是写死在样式里的?
│ │ ├─ 写死的 → 99% 该删掉
│ │ └─ 动态的(hover/交互时加,结束后移除)→ 保留
│ └─ 否 → 往下
│
├─ 它的提升原因是 overlap?
│ ├─ 是 → 找到"传染源",调整 z-index 或 DOM 顺序
│ └─ 否 → 往下
│
├─ 它是 fixed/video/canvas/iframe?
│ ├─ 是 → 正常,确认数量可控
│ └─ 否 → 检查是否有 filter/backdrop-filter/mix-blend-mode
│ ├─ 有 → 评估是否可以去掉或限制范围
│ └─ 没有 → 那它为什么被提升了?再看看 compositing reason
第三步:治 overlap
overlap 是层爆炸的主要来源,治理思路就是调整层叠顺序------让合成层位于 z 轴最顶端,它上面没有普通元素,自然就不会触发隐式提升。
html
<!-- 场景还原:一个绝对定位的动画元素,盖在列表下面 -->
<div class="container">
<!-- 这个有动画,被提升为合成层 -->
<div class="animated-bg" style="
position: absolute;
z-index: 1;
animation: pulse 2s infinite;
"></div>
<!-- 列表项 z-index 比 animated-bg 高 → 全部被隐式提升 -->
<div class="list" style="position: relative; z-index: 2;">
<div class="item">1</div> <!-- overlap → 合成层 -->
<div class="item">2</div> <!-- overlap → 合成层 -->
<div class="item">3</div> <!-- overlap → 合成层 -->
<!-- ...200 个 item,200 个合成层 -->
</div>
</div>
把动画元素的 z-index 调到列表上方,问题就消失了:
html
<div class="container">
<div class="list" style="position: relative; z-index: 1;">
<div class="item">1</div> <!-- 普通层,不提升 -->
<div class="item">2</div> <!-- 普通层 -->
<div class="item">3</div> <!-- 普通层 -->
</div>
<!-- 动画元素放在上面,z-index 更高 -->
<!-- 它是合成层没问题,但它上面没有别的元素了,不会传染 -->
<div class="animated-bg" style="
position: absolute;
z-index: 2;
animation: pulse 2s infinite;
pointer-events: none;
"></div>
</div>
200 个隐式合成层,改两行代码就没了。实际项目中,我碰到过更隐蔽的情况:一个第三方轮播组件内部用了 translate3d,层级又低于页面内容区域,导致整个页面主体被隐式提升。排查花了半天,修复只花了一分钟------给那个组件的容器加一个足够高的 z-index。所以 overlap 问题的难点从来不在修,在于找到那个"传染源"。
第四步:管好 will-change 的生命周期
will-change 应该像开关一样用------需要时打开,用完就关。
js
// ✅ 用 JS 管理 will-change 的生命周期
const card = document.querySelector('.card')
card.addEventListener('mouseenter', () => {
card.style.willChange = 'transform' // 鼠标进来,告诉浏览器"我要动了"
})
card.addEventListener('transitionend', () => {
card.style.willChange = 'auto' // 动画结束,释放合成层
})
// Vue/React 里同理
// ❌ <div :style="{ willChange: 'transform' }"> 永远挂着
// ✅ <div :style="{ willChange: isHover ? 'transform' : 'auto' }">
说实话,大多数场景根本不需要手动加 will-change。现代浏览器在检测到 transition 或 animation 声明时会自动做层提升。will-change 真正有价值的场景只有一个:动画首帧出现明显卡顿------因为层提升本身需要时间,提前声明可以把这个开销从动画第一帧挪到之前的空闲时段。如果你的动画没有首帧卡顿问题,删掉 will-change 不会有任何区别。
第五步:加个自动化卡点,防止回退
修完一轮,下个迭代又有人加 backdrop-filter 炸了------这事我经历过两次。所以层治理必须有持续的监控手段:
js
// 写个简单的合成层数量监控(仅开发环境)
// Chrome 没有直接的 API 拿合成层数量,但可以用 Performance API 间接监控
function checkLayerHealth() {
// 方法一:用 Performance 面板的 rendering 指标
// 开启 DevTools → Rendering → Layer borders
// 绿色边框 = 合成层,肉眼扫一下
// 方法二:写个 CI 脚本用 Puppeteer 抓
// page.tracing 可以拿到 cc::LayerTreeHost 的数据
const puppeteer = require('puppeteer')
async function auditLayers(url) {
const browser = await puppeteer.launch()
const page = await browser.newPage()
// 开启 tracing,收集合成信息
await page.tracing.start({ categories: ['cc', 'viz'] })
await page.goto(url, { waitUntil: 'networkidle0' })
const trace = JSON.parse(
(await page.tracing.stop()).toString()
)
// 在 trace 事件里找 PictureLayer 相关的数据
const layerEvents = trace.traceEvents.filter(
e => e.name === 'cc::LayerTreeHost::FinishCommitOnImplThread'
)
// 拿最后一帧的层数量
const lastFrame = layerEvents[layerEvents.length - 1]
const layerCount = lastFrame?.args?.numLayers ?? 'N/A'
console.log(`合成层数量: ${layerCount}`)
if (layerCount > 50) {
console.warn('⚠️ 合成层数量超过 50,建议排查')
}
await browser.close()
}
}
阈值参考:移动端 30 个以上就该看看,PC 端 50 个以内一般没事,超过 100 个基本都有问题。把这个脚本挂到 CI 的 Lighthouse 阶段,设成 warning 而不是 error------因为合成层数量和页面复杂度有关,硬卡阈值会产生误报,但趋势上涨一定值得关注。
那个项目最后怎么样了
1400 多个合成层,最后治到 23 个。做了三件事:
- 删掉所有写死的
will-change(干掉了 600 多个层) - 修了两处 z-index 导致的 overlap 传染(干掉了 700 多个层)
- 把
backdrop-filter限制在视口内可见的元素上,滚出去的用 IntersectionObserver 动态移除(干掉了几十个)
低端机从白屏变成流畅滚动,帧率从 8fps 到 55fps。
层爆炸的本质是资源分配失控------你以为在优化,其实在给 GPU 堆负担。不测量就优化,跟闭着眼睛调参数没区别。花三分钟打开 Layers 面板看一眼你的页面有多少个合成层,比读十篇性能优化的文章都管用。