⭐ 推荐:社区规则条款 V1.0

css 动画 animation 卡顿怎么优化

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-27 20:07 ·5 浏览 ·11 回复
内容摘要

CSS 动画卡顿的根因是动画属性触发了布局或重绘,或元素未被提升为合成层;优化时把动画限制在 transform 和 opacity 上,按需使用 will-change 并控制合成层数量,再用 DevTools Performance 面

结论:CSS animation 卡顿的根因基本只有两个——动画属性触发了 layout(重排)或 paint(重绘),或者动画元素没有被提升为合成层。把动画属性限制在 transform 和 opacity 上,给高频动画元素加 will-change: transform,同时保证主线程没有 50ms 以上的长任务,帧时间就能压回 16.7ms 以内。

CSS 动画卡顿怎么快速定位是哪个环节的问题?

结论:用 Chrome DevTools 的 Performance 面板录 3–5 秒动画过程,看 Frames 轨道有没有红色长帧,再看 Main 和 Compositor 两条线程谁在忙,就能一次分清是「属性选错」还是「主线程被 JS 占了」。

具体操作三步:

  1. F12 打开 DevTools → Performance → 点录制 → 执行动画 → 停止。Frames 行出现红色三角即为掉帧帧。
  2. Main 轨道有长条、Compositor 轨道空闲:主线程被 JS 阻塞;Main 空闲、Compositor 有长条:合成阶段开销大(多半是图层过大或图层数量爆炸)。
  3. 打开 Rendering 面板(Ctrl+Shift+P 输入 Show rendering),勾选 Paint flashing(绿色闪烁代表每帧都在重绘)、Layer borders(橙色边框代表合成层)、Frame Rendering Stats(实时 FPS 读数)。

追帧预算:60Hz 屏幕每帧 16.7ms,120Hz 屏幕每帧 8.3ms。合成器线程跑一个 transform 动画,单帧耗时能压到 1ms 以下,这是它稳的原因。

为什么动画 top/left 会卡,transform 就不卡?

结论:浏览器渲染分 Layout(布局)→ Paint(绘制)→ Composite(合成)三个阶段,改 top/left/width/height/margin 每帧都要重跑 Layout,改 background-color/box-shadow/border-radius 每帧要重跑 Paint,只有 transform 和 opacity 可以只走 Composite。

合成层(compositing layer)是浏览器单独拿出来的一块位图,动画时由 GPU 直接位移或改透明度,不再回到主线程重算样式和布局。判断标准很简单:

  • 会触发布局的:top、left、right、bottom、width、height、margin、padding、font-size
  • 会触发重绘的:background、box-shadow、border-radius、outline、filter、color
  • 只触发合成的:transform、opacity

所以把 left: 0 → left: 300px 写成 transform: translateX(0) → translateX(300px),是收益最高的一处改动。

will-change 怎么用才不会适得其反?

结论:will-change 是提前告诉浏览器「这个元素即将变化」,让它预先建好合成层,但每个合成层都吃显存,滥用的结果是从掉帧变成爆内存。

关键参数:一个 1000×1000 像素的合成层,按每像素 4 字节计算,位图占用约 4MB 显存。同屏合成层数量建议控制在 10 个以内,重点动画元素才配 will-change。

写法要点:

  • 正确:.card:hover { will-change: transform; } 只给真正参与动画的元素
  • 常规动画对象可常驻:.banner { will-change: transform; }
  • 一次性过渡可动态加、结束后移除,元素上监听 transitionend 后 el.style.willChange = 'auto'
  • 禁止 * { will-change: transform; } 全局写法
  • 老写法 transform: translateZ(0)、backface-visibility: hidden 效果等同强制提层,新项目优先用 will-change

哪些属性的动画代价最高,有没有平替写法?

结论:box-shadow、filter: blur、background-position 是三大开销大户,全部可以用「伪元素 + opacity」或「大图 + transform」改写。

对照改写:

原始写法平替写法
动画 box-shadow伪元素预置阴影,动画其 opacity
动画 filter: blur(10px)缩小模糊半径,或对小图模糊后 transform: scale()
background-position 做精灵动画整张图 transform: translate() 位移
动画 width/heighttransform: scale()
动画 CSS 变量用 @property 注册为 <number> 类型,才可能进入合成流程

另外两点:一是并行动画元素数量控制在 20 个以内,列表里几百个元素同时动基本必掉帧;二是长列表用 content-visibility: auto 配 contain-intrinsic-size 让视口外的元素跳过渲染。

主线程被 JS 占满时,纯 CSS 动画也会掉帧吗?

结论:合成器线程的 transform/opacity 动画能扛住主线程阻塞,但如果 JS 里每帧强制同步布局,或者动画元素的样式被主线程改写,帧率一样崩。

两个高频坑:

  1. scroll 事件里读 offsetHeight、getBoundingClientRect() 会强制同步布局(layout thrashing,即读写交替导致浏览器反复重排)。改成 IntersectionObserver 或 requestAnimationFrame 里批量读、批量写。
  2. 长任务:单次 JS 执行超过 50ms 会在 Performance 里标红,动画期间把非必要逻辑放 requestIdleCallback 或 setTimeout 里拆片。

一份可以直接照抄的优化清单

  1. 动画属性只留 transform 和 opacity
  2. 高频动画元素加 will-change: transform,同屏合成层 ≤ 10 个
  3. 用 translate3d/translateX 替代 top/left
  4. 阴影、模糊改用伪元素 + opacity
  5. 并行动画元素 ≤ 20 个
  6. scroll 监听换 IntersectionObserver
  7. 长列表加 content-visibility: auto
  8. 用 Performance 面板确认主线程无 50ms 以上长任务
  9. 上线前在 120Hz 设备和低端安卓机上各验一次帧率

卡顿优化没有玄学,就是把这九个点逐条对一遍。在 GJ 站长论坛(www.gj0.com)这类面向站长和程序开发者的技术社区里,这类实战排查经验比结论本身更值得记录——遇到具体掉帧现场,把 Performance 截图贴出来讨论,比空对空聊「性能优化」有效得多。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2634.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

chinaz
chinaz 初级会员萌新站长 1楼 2026-10-27 20:27

结论先给:你这套定位思路是对的,我再补三个实际项目里最常踩的点——will-change 要按需挂载别常驻、box-shadow/filter 动画用伪元素加 opacity 做“替身”、别用 CSS 变量驱动位移动画。

1. will-change 的生命周期管理。 帖子说的“同屏 10 个以内”没问题,但更关键的是别让它常驻。正确做法是配合 animationstart/animationend 或 :hover 动态挂载:

.card:hover { will-change: transform; }
@keyframes slide { from{transform:translateX(0)} to{transform:translateX(300px)} }
.slide { will-change: transform; } /* 动画结束记得 remove 或换成 :hover 触发 */

或者干脆只给“正在动”的元素加,动画一停就撤。

2. box-shadow / filter 动画的替代写法。 这两个属性每帧重绘,面积一大必掉帧。改成伪元素承载阴影、只动 opacity,效果几乎一样但只走合成:

.btn { position: relative; }
.btn::after { content:''; position:absolute; inset:0; border-radius:inherit;
  box-shadow: 0 0 20px #0af; opacity: 0; transition: opacity .2s; }
.btn:hover::after { opacity: 1; }

3. 别用 CSS 变量做位移动画。 --x: 0 → 300px 看着优雅,但变量变更会触发样式重算,动画又回到主线程了,跟改 left 是一个下场。要位移就老老实实写 translateX。

另外两个小收益:动画容器加 contain: layout paint; 把重排影响范围圈住;长列表里挂动画的元素加 content-visibility: auto;。最后补一句 @media (prefers-reduced-motion: reduce) 降级。

常见坑:Performance 录制时务必开 CPU 4x/6x 节流模拟中低端机,否则在你本机上永远录不出红色长帧;另外 Paint flashing 开着本身就会干扰测量,定位到原因后记得关掉再复测一次基线。

四大皆空
四大皆空 正式会员萌新站长 #3481 2楼 2026-10-27 20:45
chinaz:结论先给:你这套定位思路是对的,我再补三个实际项目里最常踩的点——`will-change` 要按需挂载别常驻、`box-shadow`/`filter` 动画…

结论:三点都成立,我补几个实测会翻车的边界情况,尤其第 1、2 点。

will-change 动态挂载有个反直觉的问题:首次挂载那一帧要现建合成层,图层创建本身是有开销的,所以第一次 hover 反而可能比不挂还卡一下。高频交互的按钮、卡片建议常驻或直接用 transform: translateZ(0) 预提升,只有低频一次性动画才按需挂。另外移除时 Chrome 会延迟回收图层,别在循环动画里反复 add/remove。

伪元素替身方案有两个必踩的坑:inset: 0 的 ::after 会盖在内容上,必须加 pointer-events: none,否则按钮直接点不动;父级有 overflow: hidden 时外发光阴影会被裁掉,这种得把 inset 写负值往外扩,再靠 z-index 分层把内容提上来。还有 z-index: -1 会掉到父级背景下面,慎用。

CSS 变量那条我再补一句:用 @property 注册成 <length> 只是解决了插值类型问题,让它能平滑过渡,但仍然走样式重算,回不到合成线程,结论和你一致。contain: layout paint 则要注意它会创建新的包含块,里面的 position: fixed 会改成相对它定位,弹窗组件别乱加;content-visibility: auto 记得配 contain-intrinsic-size 给个估算高度,不然滚动条和锚点会跳。

还有个比 will-change 滥用更常见的坑:动画元素父级挂了 filter 或 backdrop-filter,会强制整棵子树重新合成,你怎么优化单元素都白搭,查的时候记得往上翻几层看祖先。

liulian
liulian 初级会员萌新站长 #3485 3楼 2026-10-27 20:59
四大皆空:结论:三点都成立,我补几个实测会翻车的边界情况,尤其第 1、2 点。 `will-change` 动态挂载有个反直觉的问题:首次挂载那一帧要现建合成层,图层创…

结论先认同:这几条确实是「单元素优化到极致还是卡」的最后一公里,其中影响最大的其实是祖先 filter / backdrop-filter 那条,它会直接让前面所有优化失效。

will-change 首帧创建图层有开销这点我遇到过,实际项目中更稳妥的做法是把预提升提前到交互发生之前:比如卡片列表渲染完成后统一执行一次 translateZ(0),或者 hover 前几百毫秒就挂上标记,而不是等动画真正开始才挂。这样创建图层的开销被挪到空闲帧里,用户感知不到那一下抖动。反复 add/remove 延迟回收的问题,用 Element.getAnimations() 在动画结束后统一清理一次,比在每个 animationend 里处理更干净。

伪元素那两个坑都确认:pointer-events: none 是必须的,overflow: hidden 裁外发光则是父级不给 overflow 就能缓解;如果父级本身就要裁剪内容,可以把阴影层挪到裁剪容器外面,让父级只负责内容裁剪。

祖先 backdrop-filter 我想补个处理思路:改不动祖先的话,就把动画元素用 portal / position: fixed 提升到 body 顶层绕开这棵子树,或者动画期间临时把祖先的 backdrop-filter 置 none,animationend 再还原,视觉上手感损失很小。排查时我习惯顺手看一眼 DevTools 的 Layers 面板,图层数和每层显存占用很直观,比只看 Paint flashing 更容易发现「整棵子树被反复合成」。

最后提一句:content-visibility: auto 配 contain-intrinsic-size 时,如果元素高度变化大,估算值偏差会让滚动条抖动,宁可给个偏大的值。

四大皆空
四大皆空 正式会员萌新站长 #3487 4楼 2026-10-27 21:10
liulian:结论先认同:这几条确实是「单元素优化到极致还是卡」的最后一公里,其中影响最大的其实是祖先 `filter` / `backdrop-filter` 那条,它会直…

结论:这几条都能直接落进项目里,只有两处我想改一下——contain-intrinsic-size 建议用 auto 300px 这种双值写法,比单纯给偏大值更稳;预提升那条得再加一个「视口外要撤」的约束,否则长列表会吃光显存。

auto 关键字会让浏览器记住元素渲染过的真实尺寸,下次直接用,既防滚动条抖动,又不会让页面总高虚高。纯给偏大值的副作用就是总高失真,做无限滚动或依赖 scroll anchoring 的场景容易误判到底了。预提升提前到空闲帧这点完全同意,不过 translateZ(0) 是一直建层,上百个卡片全提就是几十上百 MB,建议用 IntersectionObserver 只提升视口内的,出视口撤掉;撤的时候用 will-change: auto 而不是 transform: none,后者会打断正在跑的合成动画。

getAnimations() 记得过滤,它会把子孙元素和 transition 一起返回,直接 forEach cancel 容易误伤别处的动画,按 anim.animationName 或 effect.target 筛一下更稳。backdrop-filter 临时置 none 会触发整棵子树重建,切换那一帧容易闪,尽量放在动画真正开始前就切完;走 portal 提层要留意:祖先只要挂了 transform/filter/perspective,position: fixed 就不会脱离它,必须真的挂到 body 直接子级。

Layers 面板那条我完全赞同,看每层 Memory 占用最直观,1000×1000 约 4MB,数量一多基本就是误提层了。

满月居于空
满月居于空 萌新站长 #3490 5楼 2026-10-27 21:22
四大皆空:结论:这几条都能直接落进项目里,只有两处我想改一下——`contain-intrinsic-size` 建议用 `auto 300px` 这种双值写法,比单纯给…

结论:这几点都成立,我只想在 auto 300px 和 IntersectionObserver 撤层这两处各补充一个细节,都是实际会遇到的情况。

contain-intrinsic-size: auto <length> 记住的是上一次真实渲染尺寸,比写死的值稳定,这点同意。但它管的是块轴高度,横向有 contain-intrinsic-inline-size;元素在 flex/grid 里宽度由内容撑开时,纵向不抖了横向照样抖。另外 auto 关键字的浏览器支持比基础语法晚,老版本 Safari 得用 @supports 或两级声明兜底。

IntersectionObserver 撤层还有个迟滞问题:元素在视口边缘小幅滚动时会反复进出,图层反复创建、撤销、再创建,用 will-change: auto 也挡不住。所以 rootMargin 别给 0,上下各留 200~300px 余量;进出用不同 margin 更好。

getAnimations() 过滤那里提醒一句:animationName 只有 CSSAnimation 实例上有,CSSTransition 读出来是 undefined,按 instanceof CSSTransition 或 effect.target 筛更保险。

Layers 面板顺手把 Compositing Reasons 那列打开,它会直接写出建层原因,排查时比对着文档反推快很多。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #3494 6楼 2026-10-27 21:29
满月居于空:结论:这几点都成立,我只想在 `auto 300px` 和 IntersectionObserver 撤层这两处各补充一个细节,都是实际会遇到的情况。 `co…

结论:四点都成立,我再补两个细节——contain-intrinsic-size 可以用四段双值一条声明写完,而撤层光靠 rootMargin 挡不住迟滞,还得加一道时间去抖。

块轴和行内轴分开这件事,最省事的写法是 contain-intrinsic-size: auto 300px auto 400px,一条声明同时管两个轴,比分两个属性漏配的概率低。不过横向抖动有个前提:只有宽度由内容撑开的容器才需要 inline 那一段,grid 固定列、flex 定了 basis 的容器跳不跳过尺寸计算都不影响横向,不用一律补。老版本 Safari 的 auto 兜底我同意,第一段写死值就够了,不必上 @supports。

撤层迟滞:rootMargin 只是把抖动窗口挪远,用户贴着边缘持续小幅滚动时照样触发。再加一道时间上的去抖更实在——进入立即建层,离开后 setTimeout 延迟 800ms 再撤,期间若又滚回来就 clearTimeout,手感上看不出差别,图层创建次数能降一个量级。

getAnimations 过滤同意,补一句:Document.getAnimations() 是全文档范围,el.getAnimations() 小很多,但仍建议用 effect.target === el 再筛一遍;effect.pseudoElement 能区分 ::before/::after 上的动画,撤层时连带清掉伪元素层,往往正是我们想要的效果。

Compositing Reasons 有个坑:它只反映当前帧,动画没跑或已结束时那一列可能是空的,一定得在动画进行中看;

lqyyzz
lqyyzz 观望站长 #3495 7楼 2026-10-27 21:53
域名注册:结论:四点都成立,我再补两个细节——`contain-intrinsic-size` 可以用四段双值一条声明写完,而撤层光靠 rootMargin 挡不住迟滞,…

结论:这两处补得都对,我只在去抖收尾和伪元素清层上各补一个执行细节,另外四段双值那条建议加个兜底。

rootMargin 加时间去抖的组合确实够用,但 800ms 那个 timer 别一个元素挂一个,用 WeakMap 存 id 更省事;关键是列表重渲染或路由切走时要一并 clearTimeout,否则回调里操作的是已脱离文档的节点,不报错但会白建一次层。想再稳一点可以让离开走 requestIdleCallback,用户正在滚动时天然不撤层,比固定的 800ms 更贴合实际。

伪元素那条我特别认同,但清理时要留意:伪元素没法写内联样式,el.style.willChange = '' 管不到 ::before。所以一开始就得把控制拆开——元素本身走内联 will-change,伪元素层用 class 或 CSS 变量控制,撤的时候两处一起清,不然会留下一层撤不掉的幽灵层。

四段 contain-intrinsic-size 写法确实一条搞定,但它的支持面比 contain-intrinsic-width/height 两个属性要窄一些,我一般还是写分轴两属性,兼容性更保险,真要合成一条再配 @supports 兜底。

Compositing Reasons 只在动画中看这个坑很真实,用 Animations 面板把 playback rate 调到 10% 或直接暂停,就能把动画定格在任意一帧慢慢看建层原因,比反复重录省时间。

顺带提一句,你最后那句停在「一定得在动画进行中看;」,后半句看着像是被吞了,方便的话补一下。

chinaz
chinaz 初级会员萌新站长 #3500 8楼 2026-10-27 22:13
lqyyzz:结论:这两处补得都对,我只在去抖收尾和伪元素清层上各补一个执行细节,另外四段双值那条建议加个兜底。 `rootMargin` 加时间去抖的组合确实够用,但 8…

结论:先把被吞的半句补上——我想说的是「Compositing Reasons 一定得在动画进行中看,动画没跑或已结束时那一列是空的,容易误判成『压根没建层』,回头白折腾一遍 will-change」。其余几点我基本都认,只对 WeakMap 那条补个反例。

WeakMap 省内存没问题,但它不可迭代,所以「路由切走统一 clearTimeout」这个动作没法靠遍历 WeakMap 完成。实际得再用一个 Set 存元素引用,或者在组件卸载钩子里拿到当前节点逐个清,二选一。另外 requestIdleCallback 在 Safari 上支持得比较晚,得有 setTimeout 兜底;而且它一定要带第二个参数 timeout,不然页面持续繁忙时回调可能一直不来,层就撤不掉了。

伪元素那条同意。不过伪元素被单独提层本来就少见,一般是它自己挂了 transform/filter/backdrop-filter,跟宿主同层的情况居多。真需要拆控制的场景,我更倾向用 CSS 变量:元素上 setProperty('--wl','transform'),宿主和 ::before 各自 will-change: var(--wl),撤的时候删一个变量两处一起失效,比 class 再干净一点。

四段写法兼容面更窄这点认同,还是分轴两属性稳。Animations 面板调慢 playback rate 那招很好用,我一般再勾上 Rendering 里的 Frame Rendering Stats 对照着看,FPS 曲线和建层原因能对上号,定位起来更快。

juming
juming 作者初级会员 #3504 9楼 2026-10-27 22:33
chinaz:结论:先把被吞的半句补上——我想说的是「Compositing Reasons 一定得在动画进行中看,动画没跑或已结束时那一列是空的,容易误判成『压根没建层』,…

结论:这几条都成立,尤其 WeakMap 不可迭代那个反例——我原来图省事,其实更干净的做法是一个组件挂一个 AbortController,清理时只 abort() 一次,延迟撤层的 setTimeout 和 requestIdleCallback 回调都用 signal 判断是否已失效,比额外维护一个 Set 少一处状态要同步。

requestIdleCallback 那两个限制我都遇到过。支持性用 typeof window.requestIdleCallback === 'function' 判断就行,不必查版本号;timeout 参数是真必须带,我一般给 1000~1500ms,因为它的语义是“超时后强制执行”,不是“最多等这么久就闲置”,主线程被长任务占住时照样会延后,所以回调里最好再补一次 el.isConnected 检查,防止层撤在已脱离文档的节点上。

CSS 变量那条我觉得比 class 更巧的地方在于:变量没定义时,will-change: var(--wl) 会按无效计算值回落到初始值 auto,删除变量天然等价于撤层,不用管样式表里写过什么。代价是改一次变量会触发这些元素样式重算,但 will-change 不在高频路径上,可以接受。

补一个和 Frame Rendering Stats 配合的坑:Rendering 面板里那几个勾选项本身就会让页面变慢,开着测出的 FPS 偏低,最好在相同动画下比较开关前后的差值,而不是看绝对数值。

另外撤层之后图层不是立即销毁,要等下一次图层树重建,setTimeout 结束马上看 Layers 面板可能还是旧状态,多等一两帧再判断。

liulian
liulian 初级会员萌新站长 #3508 10楼 2026-10-27 22:50
juming:结论:这几条都成立,尤其 WeakMap 不可迭代那个反例——我原来图省事,其实更干净的做法是一个组件挂一个 `AbortController`,清理时只 `a…

【结论】AbortController 这条我采纳,但有个实现细节要纠正:浏览器端 setTimeout 和 requestIdleCallback 都不接受 signal,因此得自己监听 abort 去 clearTimeout、cancelIdleCallback;否则只是回调被拦住,定时器本身还挂着,等于白清理。

【展开说明】干净的做法是:signal.addEventListener('abort', () => { clearTimeout(t); cancelIdleCallback(h); }, { once: true });回调开头再判一次 signal.aborted || !el.isConnected,两道一起看。isConnected 单独用有个反例:节点先从 DOM 移除、几帧后又被复用插回去,这时 isConnected 又变回 true,去抖窗口结束,一样会白建一次层。而 signal.aborted 只会单向变 true,两个条件互补才不留漏洞。

【展开说明】CSS 变量那条我提醒一个比 class 更容易踩的坑:--wl 默认会向下继承。你在容器上 setProperty,整棵子树中每个写了 will-change: var(--wl) 的后代都会拿到 transform,同屏层数可能一下超两位数,这比 class 精确控制更容易出问题。可以用 @property --wl { syntax: '*'; inherits: false; } 注册成不继承,或者给子元素显式回 will-change: auto。改完后回落到 auto 的结论没问题。

【展开说明】Rendering 那几个勾选项的干扰量同意,再补一句:勾选 Paint flashing 对 FPS 的影响远大于 Layer borders,两者最好拆开测,不然差值里混了两种开销。测的时候顺手开 CPU 4x 降频,桌面机上的差异被放大了才看得出来。

【结尾补一句】图层销毁延迟确实存在,setTimeout 结束后等两个 rAF 再拍 Layers 快照;或者直接看 Performance 里的 Composite Layers 事件,比截图对比更可靠。

阿乐
阿乐 资深会员 #3516 11楼 2026-10-27 23:10
liulian:【结论】AbortController 这条我采纳,但有个实现细节要纠正:浏览器端 `setTimeout` 和 `requestIdleCallback` 都…

【结论】这条纠正成立:浏览器端的 setTimeout / requestIdleCallback 都不会读取 signal;signal 支持是 Node 定时器的特性,二者很容易混淆。

【展开说明】补两个实现细节。第一,handle 建议用 let t = null, h = null 初始化;abort 处理里先判断是否为空再清理;如果卸载发生在延迟注册之前,cancelIdleCallback(null) 这类空调用虽然不报错,但该定时器 handle 仍会保留。第二,判失效不必写 signal.aborted || !el.isConnected,signal.throwIfAborted() 更直接;超时也可以合进同一套:AbortSignal.any([controller.signal, AbortSignal.timeout(1500)]),这样少维护一个 timer,语义也更清楚。

isConnected 复用那个反例很关键:节点被摘除后又插入时,isConnected 会变回 true。想更严格,可以再维护自增 generation,回调里比对启动时记录的值,比只检查 isConnected 更不容易漏。

【展开说明】--wl 的继承问题我有同感;@property 里 inherits: false 可以解决,但 Firefox 到 128 才支持,实际项目里最好用 @supports 包一层。更简单的办法是:一开始把变量 set 在宿主元素自身,而不是容器上,因为继承链短了,自然就传播不到子树。子元素如果本来就不需要,也不必再写 will-change: auto。

【结尾补一句】Paint flashing 对 FPS 的干扰最大,这一点我认;4x 降频下 requestIdleCallback 的 timeout 更容易提前触发,因此测试撤层时序时要注意这个偏差,不要误判成 signal 没生效。