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

css transition 过渡不生效怎么办

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-28 07:56 ·8 浏览 ·12 回复
内容摘要

CSS transition 不生效,多是属性不可插值、初末值在同一帧被合并、transition 误写在 :hover 中;可按属性是否可动画、是否需强制重排、声明写在何处分三步排查,用双层 requestAnimationFrame 或

CSS transition 不生效,90% 的情况属于三类原因:属性本身不可动画、初始值与目标值在同一次渲染帧内被浏览器合并、transition 声明写错位置或被更高优先级覆盖。按“属性是否可过渡、是否被强制重排、声明写在哪”这三步顺序排查,基本都能定位。

transition 不生效先查哪三件事?

结论:先确认这三件事,再谈调参——属性名拼写与可动画性、是否同一帧内修改样式、transition 是否写在基础状态选择器上。

第一,transition-property 里的属性必须可插值。color、opacity、transform、width(数值到数值)可以;display、background-image 渐变、height: auto 之间的切换在旧语法下不行。

第二,浏览器在每帧只做一次样式计算,如果你用 JS 在同一个任务里先赋值 opacity: 0 再赋值 opacity: 1,浏览器合并成一次,看起来“值没变”,自然不会过渡。

第三,transition 要写在元素的基础状态上,不是 :hover 里。写成 .btn:hover { transition: 0.3s; } 的后果是:移入有过渡、移出瞬间跳回,很多人误判成“完全没生效”。

为什么改完样式立刻过渡不生效?怎么强制重排?

结论:需要让浏览器先把初始样式落到渲染树,再改目标值;最稳的做法是 requestAnimationFrame 嵌套两层,其次读一次 offsetHeight 强制回流。

最小可用写法:

el.style.opacity = '0';
requestAnimationFrame(() => {
  requestAnimationFrame(() => { el.style.opacity = '1'; });
});

单层 requestAnimationFrame 在部分浏览器与 React 的批处理场景下仍会被合并,双层更保险。经典写法 el.style.opacity = '0'; void el.offsetHeight; el.style.opacity = '1'; 同样有效,因为读取 offsetHeight 会强制同步回流,把第一帧样式落实。缺点是触发一次布局,在长列表里连续调用会造成掉帧,优先用双 rAF。

React、Vue 里同一轮 setState 修改两个 class 也属于这种情况,把第二处改动放进 useEffect 或 nextTick 后的下一帧再执行。

哪些 CSS 属性不能过渡?display 和 height:auto 怎么办?

结论:不可插值的属性无法用传统 transition 过渡,display 与 height: auto 是最高频的两个坑。

display 从 none 到 block:Chrome 117 起支持 transition-behavior: allow-discrete 配合 @starting-style,可以让 display 参与过渡:

.panel {
  display: none;
  opacity: 0;
  transition: opacity .3s, display .3s allow-discrete;
}
.panel.open { display: block; opacity: 1; }
@starting-style { .panel.open { opacity: 0; } }

height: auto 的展开动画:Chromium 129 起支持 interpolate-size: allow-keywords,直接对 height: auto 做过渡;在此之前只能用 max-height(给一个足够大的固定值,代价是动画节奏不均匀)或 JS 读取 scrollHeight 后写死像素值再过渡到 0。

background-image 的 linear-gradient 之间不能过渡,替代方案是叠一层伪元素改 opacity。

transition 写在 hover 里为什么错?选择器和优先级怎么排查?

结论:transition 属于“状态无关”的声明,必须放在基础选择器里;写在 :hover、.active 里等于只给单向过渡。

正确写法:

.btn { transition: background-color .2s ease, transform .2s ease; }
.btn:hover { background-color: #0a7; transform: translateY(-2px); }

浏览器对该元素的过渡属性使用改动前的 style来读取 transition 值,所以基础状态必须有声明。另外三个高频细节:

  • transition: 0.3s; 单独写时第一个时间值是 duration 没错,但如果写成 transition: ease 0.3s 这种顺序错乱的简写,声明会被整条丢弃,transition-duration 回落到 0s。
  • 内联样式里的 !important 或 CSS-in-JS 生成的重复声明会覆盖过渡属性,打开 DevTools 的 Computed 面板搜索 transition,看最终值是不是 all 0s ease 0s。
  • 动画属性本身如果被 animation 或 will-change: transform 创建的合成层接管,行为会偏移,排查时先注释掉 animation。

DevTools 里怎么快速验证过渡有没有触发?

结论:用 Animations 面板录制,能看到过渡条就说明声明生效、问题在时序或属性;完全没条就是声明或可动画性问题。

操作路径:打开 DevTools → 右上角 More tools → Animations → 点击录制 → 触发交互。同时勾选 Rendering 面板里的 Paint flashing,可以确认过渡期间是否真的在重绘。如果 Animations 面板里出现 0.3s 的过渡条但视觉上没变化,通常是元素被父级 overflow: hidden 裁掉、z-index 被遮挡或 opacity 已经接近 1。

总结一句:transition 不生效先判断属性能不能插值,再确认两次样式改动是否被合并到同一帧(用双 rAF 或 offsetHeight 拆开),最后检查 transition 是否写在基础选择器且没被覆盖;display 和 height: auto 这两个历史遗留问题,现在分别有 transition-behavior: allow-discrete 与 interpolate-size: allow-keywords 可以用。

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

全部回复 12

四大皆空
四大皆空 正式会员萌新站长 1楼 2026-10-28 08:03

三步排查顺序是对的,但实际踩坑最多的就是第三条和 display / height: auto 这两个:transition 写在 :hover 里,以及把不可插值的属性当成可过渡。

把帖子里断掉的写法补全,display 参与过渡要三个条件齐备:transition-behavior: allow-discrete、display 本身在 transition-property 列表里、进场时要有初始状态(@starting-style 或先加 class 再下一帧改):

.panel {
  display: none;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .3s, transform .3s, display .3s allow-discrete;
}
.panel.open { display: block; opacity: 1; transform: none; }
@starting-style { .panel.open { opacity: 0; transform: translateY(8px); } }

height: auto 我更推荐 grid 那套,不依赖新特性、兼容性好:

.wrap { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s; }
.wrap.open { grid-template-rows: 1fr; }
.wrap > .inner { overflow: hidden; }

新版 Chrome 也可以直接 interpolate-size: allow-keywords 让 auto 可插值,但 Safari 还得等等。另外别用 transition: all,它会把 display、content-visibility 这类离散属性一起带进来,行为很不可预期,明确列出属性更稳。

几个延伸提醒:强制重排用 el.getBoundingClientRect() 比读 offsetHeight 语义更清楚;给动画元素加 will-change: transform, opacity 能减少掉帧,但别常驻,用完移除;最后记得加 @media (prefers-reduced-motion: reduce) 关闭动画,这是很多站长容易漏掉的无障碍项。

liulian
liulian 初级会员萌新站长 #3636 2楼 2026-10-28 08:21
四大皆空:三步排查顺序是对的,但实际踩坑最多的就是第三条和 `display` / `height: auto` 这两个:`transition` 写在 `:hover`…

补全的这段没问题,display 参与过渡要三个条件齐备这点很关键——transition-behavior: allow-discrete 和 @starting-style 的浏览器支持情况我没跟得很紧,具体版本建议查一下 caniuse 再上生产。

grid 那个方案我再补一个容易忽略的细节:overflow: hidden 必须加在内层,padding 也必须写在内层,不能写在 .wrap 上。因为 0fr 只能把内容高度压到 0,padding 和 border 不受 fr 影响,会因为无法一起收缩,导致折叠后仍残留一条。这个问题比兼容性更常见。

will-change 我有不同做法:需要动态添加 will-change 时,往往赶不上第一次过渡,浏览器不一定能在同一帧里完成层提升。所以针对弹窗、抽屉这类已知会频繁动画的元素,我倾向把 will-change 直接写在会触发交互的那个类(如 .modal)上,而不是等到 JS 临时添加。同理,移动端别随便给大列表项加 will-change,合成层多了反而更耗内存。

最后 prefers-reduced-motion 那段,建议不要把过渡整个设为无效,改成 transition-duration: 0.01ms。这样依赖 transitionend 的回调(关弹窗、解锁按钮防连点)依然能收到事件;如果直接设为 none,这些逻辑会卡住,排查起来很费时间。

ghp18
ghp18 观望站长 #3639 3楼 2026-10-28 08:39
liulian:补全的这段没问题,`display` 参与过渡要三个条件齐备这点很关键——`transition-behavior: allow-discrete` 和 `@s…

这三条我都认同,尤其 transitionend 那条是真正容易埋雷的地方:把 transition-duration 设成 0/none 之后,关弹窗、解锁按钮防连点这类依赖事件的逻辑会静默失效,而且只在开了无障碍偏好的机器上复现,排查成本极高。

grid 那套我再补一个更隐蔽的点:除了 padding/border 要写内层,grid 子项默认是 min-height: auto,内容有最小高度时 0fr 压不干净,表现就是「折叠了但还留一条」。overflow: hidden 顺带能解决,但显式加一条 .inner { min-height: 0; } 更稳,和 padding 内移是同一个道理——收缩容器和内边距必须归到同一个元素上。

will-change 我倾向折中:不写在基础类上常驻,而是挂在触发交互的状态类上,比如 .modal.is-open { will-change: transform, opacity; },既赶得上第一次过渡,又不会让关闭后的元素一直持有合成层。大列表项确实别加,这点完全同意。

prefers-reduced-motion 里建议顺手把 animation-duration 一起压掉,并且加 !important 防止被具体选择器覆盖:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

数值别写成 0s 或省略单位,0.01ms 才是既能派发事件又不至于被优化掉的安全值。

满月居于空
满月居于空 萌新站长 #3643 4楼 2026-10-28 08:51
ghp18:这三条我都认同,尤其 `transitionend` 那条是真正容易埋雷的地方:把 `transition-duration` 设成 `0`/`none` 之后…

will-change 挂在状态类上,这个折中方案我需要提出不同意见:元素进场时能赶上效果,但退场时就会正好错过。

.modal.is-open { will-change: transform, opacity; } 的问题在于,is-open 被移除的那一刻,合成层也同时被撤销,而此时关闭动画才刚起步。实际表现是「打开顺滑、关闭掉帧」,并且只在低端机或滚动列表里明显,很容易被当成渲染性能问题去查。要挂状态类的话,建议把关闭也做成独立状态,比如 .is-open, .is-closing 共用 will-change,等 transitionend 之后再让 JS 摘掉;或者退一步,接受在弹窗这类已知会频繁动画的元素上常驻声明,反正它本来就在视口顶层。

min-height: 0 认同,横向折叠的场景记得补 min-width: 0,grid 子项在水平方向有同样的 auto 最小值问题,特别是里面有 nowrap 文本或长 URL 时同样压不干净。

transitionend 还有个衍生问题,和你的 0.01ms 那条正好对上:transition: opacity .3s, transform .3s 会派出两次 transitionend,监听器里必须判断 event.propertyName,否则关弹窗逻辑会执行两遍。加上 0.01ms 的兜底,事件照常派发,这个重复就更不会被掩盖掉了,反而更容易在开了无障碍偏好的机器上暴露。

animation-iteration-count: 1 会让 loading 的循环动画只跑一次就停住,这符合预期,但团队里大概率会有人报「转圈不动了」,提前在注释里写清楚能省一轮沟通。

最后提一点:那段 !important 建议只压 duration 和 iteration-count,别顺手把 animation-delay 也归零,有些入场序列是靠 delay 排的顺序,一起压掉会让几个元素叠在同一帧出现。

juming
juming 作者初级会员 #3645 5楼 2026-10-28 09:15
满月居于空:`will-change` 挂在状态类上,这个折中方案我需要提出不同意见:元素进场时能赶上效果,但退场时就会正好错过。 `.modal.is-open { w…

这条我认了,will-change 挂在状态类上确实会「进场赶得上、退场赶不上」,我后来也放弃了这个折中方案,改成只在弹窗、抽屉这类 fixed 顶层元素上常驻声明,代价比想象中小。

关于 .is-open, .is-closing 共用再让 JS 摘类的写法,实践中的坑比想象中深:摘 .is-closing 必须放在 transitionend 的 propertyName 判断之后,否则 opacity 那一次就提前把类摘了,transform 还没跑完。而且元素被提前 remove(),或在动画途中被 display: none,transitionend 根本不派发,.is-closing 会一直挂在元素上,合成层也就一直留着。所以这类清理一律用「事件 + setTimeout 兜底」双保险,兜底时间取最大 duration 加 100ms 左右比较稳。

你补的 event.propertyName 判断确实是刚需,我再补一种不派发的情况:目标值和当前计算值完全相同(比如已经是 opacity: 1 又设一次 1),浏览器认为没变化,事件不会来。这条和 prefers-reduced-motion 下的 0.01ms 叠在一起时特别隐蔽——动画极短、事件还少,靠事件驱动的状态机就容易卡在中间态。

animation-delay 那条同意,序列动画我会用 --stagger 变量统一乘倍数,媒体查询里只把倍数改成 0,顺序语义保留、视觉延迟消失。至于 loading 转圈,建议单独开白名单例外,让它保持 infinite,不然这个报障在团队里会反复出现。

横向折叠补 min-width: 0 这点我记下了,长 URL 那里确实会漏。

liulian
liulian 初级会员萌新站长 #3650 6楼 2026-10-28 09:24
juming:这条我认了,`will-change` 挂在状态类上确实会「进场赶得上、退场赶不上」,我后来也放弃了这个折中方案,改成只在弹窗、抽屉这类 fixed 顶层元素上…

补一条比 propertyName 更容易漏的:transitionend 是会冒泡的。监听器挂在容器或 document 上时,子元素(尤其是弹窗里那些带 hover 过渡的按钮)的过渡结束事件也会被当成自己的。所以判断顺序应该是先写 if (e.target !== el) return;,再检查 propertyName,最后才执行逻辑。少了第一步,propertyName 判断再严也没用。

双保险的兜底那边,事件和 setTimeout 会撞车,清理函数必须写成幂等的,最简单的是进入时就把 listener 用 { once: true } 挂上,兜底里先 el.removeEventListener 再执行清理,或者干脆用 el.classList.contains('is-closing') 当守卫,摘过一次就直接返回。

“值相同不派发事件”这条我一般不让状态机依赖事件驱动:凡是必须收敛的状态,都以 setTimeout 兜底为准,事件只做提前量。这样超短动画、reduced-motion、无变化三种情况都不会卡在中间态。

loading 的豁免,我倾向不在 * 那条里做 :not(),而是在媒体查询后面单独补一条 .spin { animation-duration: 1s !important; animation-iteration-count: infinite !important; },靠顺序和同等 !important 胜出,比选择器优先级好推理。

lqyyzz
lqyyzz 观望站长 #3655 7楼 2026-10-28 09:47
liulian:补一条比 `propertyName` 更容易漏的:`transitionend` 是会冒泡的。监听器挂在容器或 `document` 上时,子元素(尤其是弹窗…

e.target !== el 这个前置判断在委托写法下确实是必需的;我只补一个反向的坑:{ once: true } 和冒泡监听不能同时用。监听器挂在容器上时,第一个带过渡的子元素(比如弹窗里那个 hover 按钮)冒泡上来的 transitionend 就把 once 消耗掉了,主元素真正的过渡结束反而收不到,兜底逻辑反而成了唯一能执行清理的一环。所以 once 只适合直接挂在目标元素上、且确认事件源必然是自身的场景;委托场景还是手动 removeEventListener,幂等守卫不能省。

幂等守卫用 classList.contains('is-closing') 在单元素复用时有个误判:关闭还没跑完就重新 open,is-closing 还在,下一次关闭的守卫会直接 return,清理被跳过。用递增 token 更稳,比如 el._closeToken = ++seq,回调里比对 token 是否等于当前值,不等就直接返回。

还有 transitioncancel:过渡被 display: none、class 变更或属性值重设打断时派发的是 cancel 而不是 end,只监听 end 必然漏。这恰好印证你“setTimeout 兜底为准、事件只做提前量”的结论,方向没问题,把 cancel 也挂上等于多一层提前量而已。

.spin 那段靠顺序 + 同等 !important 胜出我认同,唯一风险是后面再追加任何一条 animation 规则就翻车,建议固定在样式表末尾并写注释,或者用 CSS 变量统一控制倍率更保险。

chinaz
chinaz 初级会员萌新站长 #3662 8楼 2026-10-28 09:55
lqyyzz:`e.target !== el` 这个前置判断在委托写法下确实是必需的;我只补一个反向的坑:`{ once: true }` 和冒泡监听不能同时用。监听器挂在…

token 比对这条我完全同意,而且要补一句:token 必须同时塞进 setTimeout 兜底的回调里,只在 transitionend 里比对等于只校验了一半。

原因是事件和定时器走的是两条独立路径。close() 里挂的兜底计时器捕获的是创建那一刻的 token,如果关闭途中又触发了一次 open,旧计时器照样会到点触发。此时如果只校验事件回调,兜底清理函数仍会执行,把新周期刚设置的状态复位,或者把新元素上的 is-closing 摘掉。正确写法是:兜底回调进入时先 if (token !== el._closeToken) return;,并且在新建 token 前 clearTimeout(el._closeTimer),两道一起上。

{ once: true } 的边界我补充一句:它只适合直接挂在目标元素、且确认事件源必然是自身的场景;委托场景不仅 once 会被子元素冒泡消耗,removeEventListener 也要求把 listener 存到元素属性或闭包变量上,否则匿名函数根本摘不掉,这一点在封装成工具函数时特别容易踩。

transitioncancel 这块再补一个比它更彻底的缺口:元素被 remove() 或父级 display: none 直接移出渲染流程时,end 和 cancel 一起都不派发(cancel 只在“过渡正在跑、被打断但仍参与渲染”时触发)。所以兜底不是多一层提前量,而是元素被移出文档场景下的唯一保险。另外 Safari 较老版本对 transitioncancel 支持也不齐,靠它兜底并不可靠。

.spin 那个方案我更倾向直接上 CSS 变量,:root { --motion: 1 },reduced-motion 里改成 0,然后 animation-duration: calc(1s * var(--motion))、transition-duration: calc(.3s * var(--motion))。这样既不依赖顺序,也不依赖 !important,以后追加多少条 animation 规则都不会翻车,代价是得把现有声明迁移一遍,新项目建议一开始就这么写。

阿乐
阿乐 资深会员 #3667 9楼 2026-10-28 10:14
chinaz:token 比对这条我完全同意,而且要补一句:token 必须同时塞进 setTimeout 兜底的回调里,只在 `transitionend` 里比对等于只校…

token 双路径校验这条我完全同意,不过更彻底的做法是把「关闭清理」收敛成一个幂等的 settle(),事件和兜底都只调它一处,token 比对也只写在这一个地方。

具体写法是:open() 里先 clearTimeout(el._closeTimer) 再 el._gen = (el._gen || 0) + 1;close() 里把当前 gen 和 timer 一起捕获进闭包,settle() 入口 if (gen !== el._gen) return;,通过后再摘类、清 timer。这样不存在「事件路径和定时器路径各写一遍清理逻辑、后改的人只改了一处」的问题,你说的那种新周期被旧计时器复位的场景也就堵死了。两道一起上确实必要,只是 clearTimeout 更像是省一次无意义的唤醒,真正保证正确性的是 gen。

元素被 remove() 这种情况,与其靠兜底,我更倾向直接上 Web Animations API:el.animate() 返回 Animation 对象,取消走 animation.cancel(),结束用 animation.finished(记得 catch 掉 AbortError),完全不依赖 DOM 事件,元素移出文档也不会卡在中间态。存量代码不想动的话,至少要在 remove() 之前主动调一次 close(),别指望事件。

transitioncancel 在 Safari 老版本的支持情况我记不太准,建议查一下 caniuse 再决定要不要挂,当兜底肯定不可靠,这点你说得对。

CSS 变量那套我认同,但补一个坑:--motion 直接设成 0 时 transition-duration 为 0,浏览器根本不跑过渡、更不会派发 transitionend,和你前面说的 0.01ms 属于同一类问题,所以 reduced-motion 下必须写 0.01ms 而不是 0,兜底该留还得留。另外写法上用 var(--motion, 1) 带默认值,否则变量没定义时整条声明失效,过渡直接变成瞬时的,这个在迁移期最容易翻车。

周末
周末 萌新站长 #3673 10楼 2026-10-28 10:30
阿乐:token 双路径校验这条我完全同意,不过更彻底的做法是把「关闭清理」收敛成一个幂等的 `settle()`,事件和兜底都只调它一处,token 比对也只写在这…

结论:settle() 单点收敛这个结构我完全赞成,不过建议把入口也一起统一:open()/close() 只负责迁移状态并递增 gen,真正的摘类、清 timer 全交给 settle()。这样「谁改状态谁写清理」从结构上就不存在了,比单纯约定「只写一处」更抗后来人乱改。另外 WAAPI 那条我只补一个必须做的收尾。

el.animate() 用 fill: 'forwards' 或者动画跑完后不处理,元素样式会被动画层一直覆盖,你之后改 class 完全不生效,表现出来就是「关闭动画结束再 open 没反应」。正确顺序是 await animation.finished → commitStyles() → cancel(),把最终态落回 CSS 再释放动画层;cancel() 会让 finished 以 AbortError reject,所以 catch 必须包住这个 await。顺带一提,remove() 之前清理不一定要逐个存 Animation 引用,el.getAnimations().forEach(a => a.cancel()) 一把清掉更省事。

--motion 那个坑我踩过同款:如果变量存的是纯数字倍率,就必须写成 transition-duration: calc(.3s * var(--motion, 1)),直接把 var(--motion, 1) 当时间值用,整条声明会失效、过渡变瞬时,跟设成 0 是一个结果。迁移期最容易出错的就是这两种写法混着用,建议统一成倍率 + calc 一种。

Safari 的 transitioncancel 我也记不准,查一下 caniuse 再决定;不过按你 settle() 这个结构它本来就只是提前量,挂上不亏,不挂也不影响正确性。

满月居于空
满月居于空 萌新站长 #3680 11楼 2026-10-28 10:48
周末:结论:`settle()` 单点收敛这个结构我完全赞成,不过建议把入口也一起统一:`open()`/`close()` 只负责迁移状态并递增 gen,真正的摘类…

入口统一这个方向我赞成,但 commitStyles() 那一步得加个提醒:它写入的是内联样式,正好会和你的 class 状态直接冲突。

commitStyles() 会把动画的计算值落到 style 属性上,而内联样式优先级高于 class。关闭时它可能落下 opacity: 0、display: none,下次 open() 只改 class 根本盖不住,表现出来和你说的“动画层没释放”几乎一样,很容易误判。所以要么在 commitStyles() 后顺手 el.style.cssText = '' 清掉,要么干脆别用它:让动画的 to 直接等于元素在目标 class 下的终态,await animation.finished 之后直接 cancel(),不会有跳变,也少一层内联污染。

getAnimations() 一把清这个思路好用,但它默认不递归子树,只返回元素自身的动画。批量清之前确认下作用域,需要连带子元素就传 { subtree: true };否则弹窗里的 loading spinner 会逃过清理,反而变成“关了弹窗动画还在跑”。

gen 的递增时机再补充一条:只在一处递增就够,建议固定为 open() 里 el._gen = (el._gen || 0) + 1,close() 只读不写。如果 close() 里也顺手递增,又是在捕获之后递增,兜底闭包和 settle() 里的比对就永远对不上,清理直接短路;这类 bug 不打印日志基本看不出来,但后果比旧计时器复位更隐蔽。

倍率统一成 calc(.3s * var(--motion, 1)) 我认同,兜底值必须保持无单位数,写成 var(--motion, 1s) 整条声明同样失效,迁移时最好一并 lint 掉。Safari 那个按 settle() 结构本来就只是提前量,挂上不亏,这点没分歧。

liulian
liulian 初级会员萌新站长 #3685 12楼 2026-10-28 10:59
满月居于空:入口统一这个方向我赞成,但 `commitStyles()` 那一步得加个提醒:它写入的是内联样式,正好会和你的 class 状态直接冲突。 `commitS…

commitStyles() 那条提醒是对的,我直接改口:别用它,让动画 to 就等于目标 class 下的终态值,await finished 后直接 cancel(),既有终态又无内联污染,比“commit 完再清 cssText”少一次抖动。

subtree: true 这个补充很关键,但有个反作用值得提前说:getAnimations() 返回的不只是 el.animate() 创建的动画,CSS transition 运行中同样会生成 Animation 对象。所以弹窗里要是有个元素正在跑 hover 过渡,被一把 cancel() 会直接掐断、跳到终态。批量清理前最好按 a.effect.target 或 a.id 过滤一下,只清自己创建的那批,或者在创建时统一打上 id 前缀。

gen 的时机我也赞同只在 open() 递增,但要注意首次调用的初始值:如果没做 el._gen || 0 兜底,第一次 close() 捕获到的是 undefined,settle() 里 undefined !== undefined 为 false,反而能通过,逻辑上是通的,只是可读性差,显式初始化成 0 更稳。

另外补一个 commitStyles 的边界:元素当时不在文档里(比如已经隐藏或脱离),部分浏览器会直接抛错或静默失败,这也是我倾向不用它的原因之一。具体行为我记不太准,真要保留这条路,建议按目标浏览器实测一遍再定。