[前端] 为什么 CSS 设置了渐出动画却瞬间消失?一次关于前端动画问题排查记录

RoLingG 前端 2026-07-07

为什么 CSS 设置了渐出动画却瞬间消失?一次关于 height: auto 的前端动画问题排查记录

最近在开发自己的 Wails + Vanilla JS 桌面应用时,遇到了一个让我困惑很久的动画问题。项目里有一个 Pool Selector(卡池选择器),它会根据页面状态动态显示或隐藏。

展开(Fade In)的效果非常自然:

  • Select Pool 标题缓缓出现
  • 下拉框逐渐显现
  • 下方 Dashboard 被慢慢向下顶开

整个动画十分符合预期。

然而,收起(Fade Out)的时候却完全不是这样。

取而代之的是:

  • Select Pool 几乎像瞬间消失一样
  • Dashboard 确实缓慢上移
  • 但 Pool Selector 自己却像直接被删除了

虽然浏览器确实执行了 Transition,但视觉效果却像:渐入很好,渐出却像是直接消失。


是不是 opacity 动画失效了?

我的隐藏逻辑非常简单:

export function setPoolSelectorVisibility(el, visible) {
  if (visible) {
    el.classList.remove('pool-selector-hidden');
    el.style.height = '';
    el.style.overflow = '';
    el.style.opacity = '1';
  } else {
    el.classList.add('pool-selector-hidden');
    el.style.opacity = '0';
    el.style.height = '0';
    el.style.overflow = 'hidden';
  }
}

CSS 也非常直观:

.pool-selector-wrapper.pool-selector-hidden {
    opacity: 0;
    height: 0;
    margin-bottom: 0;
    overflow: hidden;
}

transition 也很简单:

el.style.transition = "all 0.5s ease-out";

看起来没有任何问题。

隐藏的时候:

  • opacity:1 → 0
  • height:auto → 0

理论上应该一边透明,一边折叠,底下的其他 DOM 元素也应该因为该 DOM 折叠,缓慢动画向上移动。


验证 opacity 是否真的没有动画

第一反应当然是:是不是 opacity Transition 根本没生效?

于是打开 Chrome DevTools,观察 Computed 面板中的 opacity,结果却是:

 1
 ↓
0.97
 ↓
0.92
 ↓
0.84
 ↓
...

说明 opacity 每一帧都在正常插值,并不是 Fade 动画失效了。


是不是 DOM 被提前删除了?

既然 opacity 正常。那么是不是动画还没结束,DOM 就已经重新渲染了?于是开启:

Break on
→ Subtree modifications

观察整个动画过程,结果没有任何 DOM 有异常的修改。说明 Pool Selector 一直都在,对应的 CSS 也没有设置之外的变化。


真正定位问题

为了进一步确认是谁"瞬间消失"。我给整个 Wrapper 临时加了一圈红框。

.pool-selector-wrapper{
    border:1px solid red;
}

然后再次隐藏。结果终于发现了真正的问题,红框不是慢慢消失,而是从 框体 → 框体消失后 height 为0的一条线。这说明真正瞬间变化的不是 opacity,而是 height


为什么展开正常,收起却异常?

这也是整个问题最容易误导人的地方,对于很多不了解前端相关稍微底层一些的开发者(包括我自己)都会觉得:

展开        和        收起
 0                      auto
 ↓                      ↓
auto                  0

应该是互逆过程,但实际上它们完全不是。


渐入:0 → auto

展开的时候:

height:0;
    ↓
height:auto;

浏览器会重新进行 Layout。此时浏览器知道 auto = 68px,于是重新布局。其他在下面的 DOM 元素会一点一点被顶下去。视觉上就形成慢慢展开。

虽然看起来像:

0
↓
20
↓
40
↓
68

实际上浏览器并没有真的动画 auto,只是在不断重新布局。


渐出:auto → 0

这里问题来了,Transition 开始之前浏览器需要知道:

10% 是多少?

30% 是多少?

80% 是多少?

但是 height:auto 根本不是一个数字,浏览器不知道:

auto
 ↓
50%
 ↓
???

应该是多少,于是只能 auto0 直接跳,整个 Wrapper 也就瞬间塌缩。虽然:

opacity: 1
           ↓
         0.95
           ↓
         0.90

还在正常动画。但是由于 height 已经瞬间变成了 0,肉眼看到就是 Opacity 好像没有动画。

实际上不是 opacity 太快,而是 height 缩小太快导致的动画消失问题。


标准 Collapse 是如何解决的?

后来我查阅了GPT提供的 Bootstrap、Element Plus、Vue Transition 等框架相关技术的实现。发现几乎所有 Collapse 都采用同一种方案。

不是:

auto
 ↓
 0

而是:

auto
 ↓
68px
 ↓
 0

也就是说,先把 height: auto 转换成 height: scrollHeight

这样浏览器终于得到:

68px
 ↓
0px

两个具体数值。于是 Transition 就可以正常插值

68
↓
60
↓
48
↓
35
↓
17
↓
0

我的最终实现

最终,我把代码改成了标准 Collapse 的实现方式。展开:

if (visible) {
    el.classList.remove("pool-selector-hidden");

    const height = el.scrollHeight;

    el.style.height = "0px";
    el.style.overflow = "hidden";

    requestAnimationFrame(() => {
        el.style.height = `${height}px`;
        el.style.opacity = "1";
    });

    const onEnd = () => {
        el.style.height = "";
        el.style.overflow = "";
        el.removeEventListener("transitionend", onEnd);
    };

    el.addEventListener("transitionend", onEnd);
}

隐藏:

else {
    el.style.height = `${el.scrollHeight}px`;
    el.style.overflow = "hidden";

    requestAnimationFrame(() => {
        el.style.height = "0px";
        el.style.opacity = "0";
    });
}

整个动画流程也从:

展开            收起
 0             auto
 ↓              ↓
auto          0

变成了:

展开            收起
 0             auto
 ↓              ↓
68px         68px
 ↓              ↓
auto          0

真正解决问题的,并不是 scrollHeight,而是中间固定高度这一层,它让浏览器终于拥有了一个可以参与 Transition 的起点。


为什么还需要 requestAnimationFrame?

这里还有一个很容易踩坑的地方。如果像下面这样写的话:

el.style.height = `${el.scrollHeight}px`;
el.style.height = "0px";

显而易见,浏览器会把这两句合并到同一帧,最终看到的只有:

height: 0;

Transition 根本不会开始,所以必须:

el.style.height = `${el.scrollHeight}px`;

requestAnimationFrame(() => {
    el.style.height = "0px";
});

这样浏览器才能认为,第一帧为 68px,第二帧为 0px。Transition 才会真正开始。


总结

一开始的误认为:既然 0 → auto 能工作,那么 auto → 0 也应该可以。真的让我踩了许多的坑,无论是修改动画效果,还是修改 Wrapper 与其他子 DOM 的层级关系,都没有达到我想要的效果或者完全没变化。

实际上浏览器对这两个方向的处理完全不同。

  • 0 → auto:浏览器可以通过重新布局,让视觉上看起来像"缓慢展开"。
  • auto → 0:由于 auto 无法参与插值计算,浏览器只能直接跳到 0

因此,这也是为什么几乎所有成熟 UI 框架的 Collapse 动画都会遵循同一个思路:

  • 不要直接动画 height: auto
  • 先将 auto 固定为 scrollHeight,再执行 px → 0 的 Transition,最后恢复为 auto
PREV
[LLM] Paged Cache策略
NEXT
[前端] 一次动画冲突引发的"卡顿"

评论(0)

发布评论