手风琴组件为什么不能平滑展开
一个常见的手风琴组件,点击标题后内容区展开,再次点击收起。最简单的实现是切换内容区的 display 属性:展开时设为 block,收起时设为 none。但如果你给 display 加上 transition,会发现过渡完全不生效——内容要么瞬间出现,要么瞬间消失。
直觉上,display 从 none 变到 block 是一个“开关”操作,没有中间值可以插值,所以浏览器不会为它生成过渡。CSS Transitions Level 1 规定,只有“可插值”的属性才能过渡,而 display 属于离散(discrete)动画类型,默认不参与过渡。
为了让展开收起有动画,传统做法是把内容区的高度或 max-height 从 0 过渡到某个值,同时配合 overflow: hidden。但高度过渡有缺陷:需要知道内容的确切高度,或者设置一个足够大的 max-height,导致收起时延迟明显。另一种做法是使用 transform: scaleY() 配合 visibility,但元素仍然占据布局空间,收起后底部会留白。
CSS Transitions Level 2 引入了 transition-behavior 属性,允许 display 这类离散属性也参与过渡。本文以手风琴组件为例,说明这个机制如何工作,以及它在工程上的取舍。
离散属性与连续属性的过渡差异
CSS 属性按动画类型分为几类:可插值(interpolable)、离散(discrete)、不可动画(not animatable)。可插值属性如 opacity、transform、color,浏览器可以在两个值之间计算中间帧。离散属性如 display、visibility、overlay,取值是枚举,没有中间值,传统上只能瞬间切换。
CSS Transitions Level 2 定义:当 transition-behavior 为 allow-discrete 时,离散属性也可以启动过渡。但离散属性没有中间值,所以过渡过程不是插值,而是在某个时间点“翻转”。规范规定,一般情况下,离散属性在过渡进行到 50% 时从起始值翻转到结束值。
display 有一个例外:当过渡涉及 display: none 或 content-visibility: hidden 时,翻转时机不再是 50%,而是根据方向调整:
- 从
none过渡到可见值(如block),在 0% 时就翻转为block,这样整个过渡期间元素都是可见的。 - 从可见值过渡到
none,在 100% 时才翻转为none,这样元素在整个过渡期间保持可见,直到最后一刻才隐藏。
这个例外对手风琴组件至关重要。展开时,内容区需要立即变为 display: block,才能让高度过渡(或其他动画)可见;收起时,内容区应该保持可见直到动画结束,最后才隐藏。
手风琴组件的传统实现与失效条件
假设一个手风琴内容区,初始状态是 display: none,展开时添加类名使其变为 display: block。如果不使用 transition-behavior,即使写了 transition: display 0.3s,浏览器也不会启动过渡,因为 display 默认不可过渡。
常见的替代方案是过渡 max-height:
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion-content.open {
max-height: 500px; /* 需要足够大 */
}
这个方案有两个问题。第一,max-height 必须大于内容实际高度,否则内容被截断;如果设置得过大,收起时动画后半段会有一段“无变化”的时间,因为 max-height 从 500px 降到内容高度时,实际高度不变,视觉上停顿。第二,内容区始终占据布局空间,即使 max-height: 0,overflow: hidden 隐藏了内容,但元素本身仍可能影响布局(比如有 margin 或 padding)。
另一种方案是使用 grid-template-rows 从 0fr 过渡到 1fr,配合 overflow: hidden,可以自适应内容高度,但浏览器支持较新,且仍然需要元素在布局中存在。
这些方案的根本问题是:它们试图用连续属性模拟离散属性的效果,但要么需要预知尺寸,要么留下布局痕迹。transition-behavior: allow-discrete 提供了一种更直接的方式:让 display 本身参与过渡,配合其他可插值属性实现平滑效果。
transition-behavior 的语法与用法
transition-behavior 是一个 CSS 属性,取值只有两个:normal(默认)和 allow-discrete。它通常配合 transition-property 和 transition-duration 使用,也可以写在 transition 简写中。
.accordion-content {
transition-property: opacity, display;
transition-duration: 0.3s;
transition-behavior: allow-discrete;
}
等价于简写:
.accordion-content {
transition: opacity 0.3s, display 0.3s allow-discrete;
}
在简写中,allow-discrete 可以放在每个属性的过渡定义末尾。如果使用 transition: all 0.3s allow-discrete,则所有属性(包括连续和离散)都会尝试过渡,但 allow-discrete 只影响离散属性,连续属性照常插值。
注意,transition-behavior 本身不可动画,也没有继承性。它只是一个开关,告诉浏览器“是否允许离散属性启动过渡”。
手风琴组件的平滑展开实现
现在我们可以实现一个真正平滑的手风琴。内容区默认 display: none 且 opacity: 0,展开时变为 display: block 且 opacity: 1。为了让展开动画可见,需要同时过渡 opacity 和 display。
.accordion-content {
display: none;
opacity: 0;
transition: opacity 0.3s ease, display 0.3s allow-discrete;
}
.accordion-content.open {
display: block;
opacity: 1;
}
当添加 .open 类时,display 从 none 变为 block,opacity 从 0 变为 1。由于 display 的翻转时机是 0%,元素立刻变为 block,然后 opacity 在 0.3s 内从 0 过渡到 1,产生淡入效果。
收起时,移除 .open 类,display 从 block 变为 none,翻转时机是 100%,所以元素在整个 0.3s 内保持可见,opacity 从 1 过渡到 0,最后 display 变为 none,元素消失。
如果还需要高度动画,可以加上 grid-template-rows 或 max-height,但要注意与 display 的配合。例如:
.accordion-content {
display: none;
opacity: 0;
grid-template-rows: 0fr;
transition: opacity 0.3s, grid-template-rows 0.3s, display 0.3s allow-discrete;
}
.accordion-content.open {
display: block;
opacity: 1;
grid-template-rows: 1fr;
}
这里 grid-template-rows 从 0fr 过渡到 1fr,配合 overflow: hidden,可以实现高度自适应动画。display 的翻转保证元素在动画期间可见。
流程图:手风琴展开收起的过渡时序
下面用流程图展示手风琴内容区在展开和收起时,display 与 opacity 的状态变化。
flowchart TD
A[初始状态 display:none opacity:0] -->|添加 .open 类| B[计算样式变化]
B --> C[启动过渡]
C --> D{display 翻转?}
D -->|是, 0% 翻转为 block| E[元素可见]
E --> F[opacity 从 0 过渡到 1]
F --> G[展开完成 display:block opacity:1]
G -->|移除 .open 类| H[再次计算样式]
H --> I[启动收起过渡]
I --> J[opacity 从 1 过渡到 0]
J --> K{display 翻转?}
K -->|是, 100% 翻转为 none| L[元素隐藏]
L --> M[收起完成 display:none opacity:0]
关键点在于 display 的翻转时机:展开时在 0% 翻转,收起时在 100% 翻转。这保证了动画期间元素始终可见,不会出现闪烁或提前消失。
浏览器支持与兼容性处理
transition-behavior 是较新的特性。根据 MDN 的标注,它属于 Baseline 2024,即从 2024 年 8 月起在主流浏览器中可用。但旧浏览器不支持,因此需要降级方案。
一个常见的做法是同时写两套 transition:第一套不带 allow-discrete,供不支持该属性的浏览器使用;第二套带 allow-discrete,供支持的浏览器使用。例如:
.accordion-content {
transition: opacity 0.3s, display 0.3s;
transition: opacity 0.3s, display 0.3s allow-discrete;
}
在不支持 transition-behavior 的浏览器中,第二条声明会被忽略,第一条声明生效。但 display 仍然不会过渡,所以这些浏览器中手风琴会瞬间展开/收起,但 opacity 过渡仍然有效。如果希望完全降级,可以检测支持情况:
@supports (transition-behavior: allow-discrete) {
.accordion-content {
transition: opacity 0.3s, display 0.3s allow-discrete;
}
}
注意,@supports 检测的是属性是否被解析,而不是是否真正支持离散过渡行为。在旧浏览器中,transition-behavior 可能被解析为未知属性,导致声明无效,从而回退到无过渡。
性能影响与注意事项
transition-behavior 本身不引入额外性能开销,因为它只是允许离散属性参与过渡。真正的开销来自被过渡的属性。display 的翻转不会触发动画帧,因为它只是切换值,但 opacity、transform 等连续属性的过渡会触发合成器工作。
在手风琴场景中,如果同时过渡 opacity 和 grid-template-rows,后者会触发布局计算,因为 grid-template-rows 影响布局。相比之下,transform 和 opacity 可以在合成器上处理,不触发布局。因此,如果追求性能,应优先使用 transform 和 opacity,而不是高度类属性。
另一个注意事项是:display 的翻转时机与过渡时长直接相关。如果过渡时长很短,比如 0.1s,那么收起时元素会在 0.1s 内保持可见,然后瞬间隐藏,视觉上可能感觉“延迟”。因此,时长设置要合理。
此外,transition-behavior 对 visibility 也有效。visibility 是离散属性,但它在过渡中的行为与 display 不同:visibility 在过渡期间保持可见,直到结束才切换。这与 display 的收起行为类似,但 visibility 隐藏后元素仍占据空间。
对比:传统方案与 transition-behavior 方案
下表对比手风琴组件的几种实现方案。
| 方案 | 动画效果 | 布局影响 | 实现复杂度 | 浏览器支持 |
|---|---|---|---|---|
max-height 过渡 | 高度动画,但可能停顿或截断 | 元素始终占据空间 | 低,但需设置足够大的值 | 广泛 |
grid-template-rows 过渡 | 高度自适应动画 | 元素始终占据空间 | 中,需配合 overflow: hidden | 较新 |
transform: scaleY + visibility | 缩放动画,但底部留白 | 元素始终占据空间 | 中 | 广泛 |
transition-behavior: allow-discrete + opacity | 淡入淡出,可配合高度 | 收起后不占空间 | 低,但需降级处理 | 2024 年后主流浏览器 |
从决策角度看,如果项目需要支持旧浏览器,且不介意布局占位,max-height 或 grid-template-rows 仍是可行选择。如果追求现代体验且目标浏览器较新,transition-behavior 能提供更干净的效果,且代码更简洁。
总结与适用边界
transition-behavior: allow-discrete 让 display 等离散属性能够参与过渡,通过特殊的翻转时机实现平滑的显示/隐藏效果。它解决了传统方案中“无法直接过渡 display”的问题,但依赖较新的浏览器支持。
适用场景包括:手风琴、弹窗、下拉菜单、Popover 等需要显示/隐藏动画的组件。不适用场景:需要支持 IE 或旧版 Safari 的项目,此时应使用降级方案。
工程上,建议始终提供降级路径,并谨慎选择过渡属性,避免触发布局抖动。如果动画涉及高度变化,优先考虑 transform 和 opacity,将 display 作为辅助。