☰
CSS transition 进阶指南:从 all 的陷阱到复杂动效实战
2026/10/17 1:17:47 网站建设 项目流程

1. 写在前面:这个属性被低估了

做前端这么多年,如果让我选一个“天天用但未必真懂”的CSS属性,transition绝对排得上号。你大概率写过transition: all 0.3s ease,觉得这玩意儿简单到不值一提——设置时长、设置缓动函数、触发状态变化,完事。但实际上去掉all之后怎么写才性能最好?delay传负值会发生什么?transitionend事件为什么有时候不触发?steps()除了做打字机还能干嘛?这些细节,文档里都有,但没人给你划重点,今天我来。

这篇文章适合谁?刚入门想系统搞懂过渡动画的初学者,写了不少页面但对动画性能说不清楚的中级开发者,以及想在手势交互、拖拽场景里用transition玩出花来的进阶玩家。不涉及框架,纯 CSS 原生知识点,但你完全可以把它用到任何框架里。

先说一个结论:transition不是“简单的属性变化动画”,它是一套完整的状态插值机制。理解了这个定位,后面所有技巧都顺理成章。

2. 先从“all”这个坑说起

2.1 你写的 transition: all 到底干了什么

transition: all 0.3s ease,中文翻译过来就是“所有属性变化,我都给你补间”。听起来省事,但代价有三个。

第一,语义不明确。写all意味着后续任何可动画属性发生变化,都会触发插值。如果哪天你给元素加了width变化,本来没打算做动画,它也会自己动起来,而且transition的优先级会覆盖掉你用 JS 设置的瞬时变化,常导致“改了个样式没生效,刷新一下才变”的诡异问题。这类 bug 特别难排查,因为代码逻辑没错,是all的副作用在作祟。

第二,性能有隐患。浏览器拿到all之后,需要在属性变化时对所有可动画属性做样式计算。这个开销在低端移动设备上体会尤其明显,页面掉帧、卡顿,你甚至不知道是哪条transition: all惹的祸。如果只声明transition: transform 0.3s,浏览器就知道只需要关心transform,可以做更多优化。

第三,多个属性的过渡时间、缓动函数无法差异化。比如我想让opacity在 0.2s 内完成、transform在 0.5s 内完成,all只能用一个时长一个缓动函数,做不到。

所以第一个实操建议就是:能不用all就不用。哪怕只写一个属性,也明确写出来。你会发现代码可维护性直接上一个台阶。

2.2 可动画属性到底有哪些

很多人以为“所有 CSS 属性都能过渡”,其实不对。能不能过渡取决于属性值能否被插值,也就是能不能从 A 点“连续”地走向 B 点。

能过渡的有这么几类:

  • 数值型:width、height、font-size、left、top、padding、margin这些带长度的属性,以及opacity、z-index(注意它只能取整数值)、border-radius、box-shadow等。
  • 颜色型:color、background-color、border-color、outline-color、fill、stroke等,支持颜色插值。
  • 变换型:transform里的位移、旋转、缩放函数,浏览器会对变换矩阵做插值。
  • 滤镜型:filter中的函数,只要参数是数值型,大多能插值。

不能过渡的也给你列一下:display(它是个离散值,无法插值)、visibility(虽然能切但不能渐变,实际上是延迟切换)、background-image(不能补间两张位图)、font-family、content等。所以如果你发现display: none到display: block没动画,别怀疑,它本来就不支持,需要用visibility或其它手段配合。

这里有个特别容易踩的坑:height: auto。给一个高度为auto的元素加transition: height 0.3s,高度变化时动画不会生效,因为auto是个关键字,浏览器不知道该从哪里开始插值。常见绕法是用 JS 算实际高度赋值,或者用max-height做近似动画。后面我会详细说这个。

2.3 多属性声明的写法差别

当你要同时过渡多个属性,有两种写法,效果上有细微差别:

/* 写法一:逗号分隔 */ transition-property: width, transform; transition-duration: 0.3s, 0.5s; transition-timing-function: ease, cubic-bezier(0.2, 0.8, 0.4, 1); /* 写法二:一条声明里逗号分隔 */ transition: width 0.3s ease, transform 0.5s cubic-bezier(0.2, 0.8, 0.4, 1);

写法一的好处是属性可以分组对应:duration列表的每一项按顺序对应property的每一项,如果列表长度不一致,浏览器会怎么处理?这个细节很多人不知道——列表长度不一致时,浏览器不会报错,而是会按“超出部分循环复用”的规则处理。比如property有 3 个属性但duration只有 2 个值,那么第三个属性就会复用第一个时长。反过来,duration有 3 个值但property只有 1 个属性,多余的时长会被忽略。这个机制在设计多个属性不同速率动画时非常有用,但也是心智负担的来源,我建议这种复杂情况直接老老实实写写法二,可读性高得多。

3. 时间、延迟与缓动函数的细节

3.1 duration 和 delay 的默认值,可能和你想的不一样

很多人以为不写duration动画就不播放,实际上transition-duration的默认值是0s,transition-delay的默认值也是0s。关键是:如果 duration 为 0,即使 delay 不为 0,元素也会立刻跳到终态,然后 delay 计时结束后没有任何动画效果。这听起来比较绕,但背后逻辑是严谨的——duration 为 0 意味着没有过渡过程,状态变化是瞬时的,delay 只影响状态变化的“开始时机”,而不是影响“可见动画的时机”。

在 JS 里操作的时候,这个特性很容易让人犯迷糊。例如:

el.style.transition = 'opacity 0s ease 1s'; el.style.opacity = '0';

你以为会是“1 秒后淡出”,实际是“opacity 立刻变成 0,1 秒后什么也没发生”。想要延迟淡出,必须让 duration 大于 0,比如opacity 0.3s ease 1s。

顺带说一句,delay支持负值。负 delay 的含义是“动画从时间轴的中间开始播放”。比如transition: transform 1s ease -0.5s,元素会直接跳到 0.5 秒时的中间帧状态,然后再继续播放剩下的 0.5 秒。这个技巧在需要“入场动画看起来已经进行到一半”的场景非常好用,比如轮播图切换时想让新卡片带着“已经滑到一半”的动势进场,负 delay 比手动计算状态要优雅得多。

3.2 easing 不只是“快慢”:理解三次贝塞尔曲线

ease、ease-in、ease-out、ease-in-out、linear,这几个是预置的缓动函数,它们本质都是三次贝塞尔曲线,只是参数不一样:

  • ease:cubic-bezier(0.25, 0.1, 0.25, 1),开头快、结尾慢,最直观的“自然感”动画。
  • ease-in:cubic-bezier(0.42, 0, 1, 1),加速入场,适合元素离开视口、坠落这类有“初速慢、越来越快”物理感觉的场景。
  • ease-out:cubic-bezier(0, 0, 0.58, 1),减速出场,适合元素进入视口,像是有东西滑过来然后慢慢停住。
  • ease-in-out:cubic-bezier(0.42, 0, 0.58, 1),两头慢中间快,适合前后状态都是“静止”的往复动画。
  • linear:全程匀速,适合进度条、加载条这类需要精确线性的场景。

但很多时候预置函数不够用。比如你要做 iOS 那种“快进慢出”的弹簧感,预置函数给不了,就需要自定义cubic-bezier。实际经验是:大部分 UI 交互动画,cubic-bezier(0.2, 0.8, 0.4, 1)比ease好看得多,它前段加速更快、后段收得更柔和,视觉上很接近原生应用的阻尼感。

还有一点要注意:贝塞尔曲线允许超出一阶边界。也就是说cubic-bezier(0.8, 0, 1, 1.5)里的 y 值可以大于 1,动画会有“回弹”或“过冲”的效果。这个特性可以做“看似弹性但不需要关键帧”的动效,不过小心别过度使用,过冲太夸张会显得廉价。

3.3 用 steps() 做“逐帧”动画

transition-timing-function不仅可以设置为贝塞尔曲线,还可以是steps()阶梯函数。它的作用是让动画不连续插值,而是跳变式地走完。公式是steps(n, direction),n 是分段数量,direction 可以是start或end,表示每段的跳变位置。

你可能见过用它做打字机效果:宽度从 0 变为文字实际宽度,配合steps(文字字数, end),每步显示一个字符。但它的用途远不止如此,比如做 loading 菊花旋转,用steps(8, start)可以模拟一个只有 8 帧的逐帧动画,效果比连续旋转更有手绘感。再比如数字滚动器,数字从 0 滚到 9,用steps(10)做阶梯变化,效果就是“咔、咔、咔”地跳动,而不是滑块式的连续滑动。

start和end的区别要理解:end表示在每段“末尾”才跳变,通俗说就是先保持不动、到点再切换;start表示在每段“开头”就切换,相当于先跳变、再等待。做打字机时用end更合理,因为每个字符应该在显示一段时间后才被下一个替换;做跑马灯用start更自然,因为是先出现新内容再走过宽度。

4. 触发时机:什么时候会动,什么时候不会

4.1 transition 的触发条件远比你想的严苛

一个元素要发生过渡,必须满足三个条件:

  • 存在一个可动画属性,并且初始值和目标值不同。
  • 该属性在transition-property列表里(或使用了all)。
  • 属性值的变化发生在样式计算层面,也就是浏览器认为这是一个“新的”状态变化。

第三点容易被误解。不是“在开发者工具里改了属性就会动”,而是过渡只会在元素从一个已渲染状态切换到另一个已渲染状态时触发。这里有几个日常踩坑的例子:

页面加载时,如果你在 CSS 里写了transition: opacity 0.5s,而元素的初始opacity是 0、稍后 JS 把它改成 1,这里是会触发的。但如果你把初始 0 写在 CSS、JS 同步改成 1,而且这个修改发生在第一次样式计算之前,那浏览器不会把它当成“变化”,而是当成“首次渲染就是 1”,过渡直接不播。解决办法是强制触发重绘,比如把 JS 赋值放在requestAnimationFrame回调里,或者在元素上加一个无意义的重排操作。

同理,display: none到display: block再改opacity也不会过渡,因为display切换本身就打断了渲染状态连续性。常见做法是让元素先渲染为visibility: hidden+opacity: 0,再切换状态。这些都属于“先渲染后过渡”的边界问题。

4.2 事件触发与被动触发:hover 和 class 切换的异同

最常见的触发方式是伪类,比如:hover、:focus、:active。它们的共同点是:状态变化由用户交互直接引起,浏览器会自然地应用过渡。

另一种是 class 切换,比如 JS 里el.classList.add('active'),这类触发是否生效取决于 class 引入的方式。如果你用外链 CSS,在 class 生效的一瞬间,浏览器重新计算样式,如果属性值变了且transition-property匹配,过渡会启动。这没问题。

但如果你的 class 是内联style直接写的,比如el.setAttribute('style', 'width: 100px'),然后又要过渡到width: 200px,这也很常见,需要确保前一个 style 已经被浏览器“消化”,再去设置新值,否则两次样式的计算可能在同一个渲染帧内完成,动画直接丢失。

我自己调试时最常用的一招:在状态切换前,刻意读一次el.offsetHeight强制浏览器回流,确保前一个状态已经被“固化”。这在很多复杂业务里屡试不爽。

4.3 transition 不会自动触发的场景:媒体查询、系统主题切换

还有一个值得注意的点:媒体查询和系统主题切换不会自动触发 transition。比如你写:

.card { background: #fff; transition: background 0.3s; } @media (prefers-color-scheme: dark) { .card { background: #333; } }

用户切换系统深色模式时,卡片背景会突然变掉,没有过渡。原因是浏览器把媒体查询状态变化视为“环境变化”而非“元素状态变化”,不会主动为它播放过渡动画。如果你想让主题切换有渐入效果,要么用 JS 监听matchMedia事件,动态给某个根元素加 class 再触发子元素过渡;要么用@property注册自定义属性做插值(这个后面讲)。别指望纯 CSS 能自动搞定。

5. 事件监听:transitionend 的坑与用法

5.1 监听结束事件:不止一个、还可能不触发

transitionend事件是在过渡结束时触发的 DOM 事件。看起来简单,实际有一堆陷阱。

第一,一个元素有多个属性同时过渡时,每个属性都会触发一次transitionend。如果你监听事件做“动画结束后移除某个节点”,就要判断event.propertyName,过滤掉你不需要的属性。不然回调可能执行多次,造成意外副作用。

第二,过渡被打断时,transitionend不一定会触发。比如你 hover 一个元素,过渡进行到一半移出鼠标,浏览器会反向过渡,原本向终态的过渡被“反向覆盖”,之前那次过渡的transitionend事件就不会触发了。所以如果你依赖这个事件收尾清理状态,很有可能会出现状态残留。

第三,transitionend事件可以被cancel打断。当元素从 DOM 中移除、或者transition-property被动态改掉,事件可能压根不冒泡到你监听的父节点上。稳妥的做法是结合transitionstart(如果有)以及定时器做兜底判断。

实操建议:用 Promise 封装过渡结束,同时监听transitionend和transitioncancel,前者 resolve,后者 reject,再加上一个setTimeout兜底。这样既不怕漏事件,也不会卡死等待。

5.2 判断过渡是否真正执行:el.getAnimations()

如果你需要更精确地控制,el.getAnimations()是比监听事件更好的选择。这个方法返回元素上所有正在运行或已经结束的 CSS 动画(包括 Web Animations API 创建的动画)。

用它可以解决一个很难缠的问题:怎么知道一个过渡是否真的“正在播放”。比如你在某个逻辑分支里设置了transition,但目标值和初始值相同,浏览器会认为无需插值,不启动动画。此时transitionend不会触发,你再等也是白等。而getAnimations()能告诉你这个元素现在到底有没有活跃动画。

const animations = el.getAnimations(); if (animations.length === 0) { // 没有活跃动画,可能是属性没变化 }

这在做“点击后滑出再删除”这类手势交互时特别好用——先判断当前动画状态,再决定是否执行删除逻辑。

6. 进阶技巧:把 transition 用到极致的几个场景

6.1 过渡 start 事件:监听动画开始的时机

和transitionend相对的是transitionstart,它在过渡开始播放时触发。你可能觉得这个事件没用,实际上配合手势交互非常关键。

比如做一个触摸拖拽的卡片,松手时判断速度决定卡片是滑出还是回弹。在transitionstart里根据预测的终点方向,动态切换transition-duration和transition-timing-function?不行,因为动画已经开始播放了,中途改duration不一定生效。实测是:在触发 transition 的前一帧设置好所有参数,过渡才能按预期播放。

所以transitionstart更多是用来做“动画开始瞬间的副作用”——比如记录开始时间、锁定 UI 状态、移除某个pointerdown监听器。它能帮你避免“动画播放期间用户连续点击导致状态错乱”的问题。

6.2 动态修改 transition 参数:为什么有些修改不生效

这个坑我踩过太多次了。你可能会写:

el.style.transition = 'transform 0.3s ease'; el.style.transform = 'translateX(100px)'; // 过一会儿 el.style.transition = 'transform 0.6s ease'; el.style.transform = 'translateX(0)';

第一段过渡会正常播放,第二段过渡却经常“跳变”而不是“过渡”。原因是浏览器会把连续的样式修改合并到同一帧的样式计算里,第二次transition修改可能还没被浏览器“消化”,就被同一批次的状态变更覆盖了。解决方法是强制插入一次回流:

el.style.transition = 'transform 0.6s ease'; el.offsetHeight; // 强制重排 el.style.transform = 'translateX(0)';

或者用requestAnimationFrame把第二次修改放到下一帧。这是动态切换动画参数的核心要点,几乎所有复杂交互都躲不开。

6.3 用 transition 做拖拽吸附:比 JS 手写动画省心太多

做拖拽吸附时,常规思路是监听pointermove实时更新位置,松手后用 JSrequestAnimationFrame手写减速动画。其实完全可以用transition简化:松手时,把元素当前的transform从“手指位置”平滑过渡到“吸附位置”,让 CSS 接管后半段。

操作要点是:

  1. 拖拽过程中,给元素设置transition: transform 0s,保证元素跟手,没有延迟。
  2. 松手时,先设置目标transform值,再在同一帧或回流的下一帧把transition改成transform 0.3s cubic-bezier(...)。
  3. 元素就会自动从当前拖拽位置滑向目标位置。

这里关键仍然是强制回流,确保浏览器意识到 “当前 transform 是 A,transition 参数变了,目标 transform 是 B,需要启动插值”。如果不强制回流,浏览器可能认为你在同一帧里直接改成了最终状态,没有任何过渡。这个模式做左右滑动的卡片列表、底部弹层半展开收起、侧边抽屉,都非常好用。

6.4 配合 @property 自定义属性做复杂插值

transition传统上只能插值 CSS 已知的数值、颜色等,但通过@property注册自定义属性,你可以让浏览器“理解”一个自定义属性的类型,然后对它做插值。比如:

@property --progress { syntax: '<number>'; inherits: false; initial-value: 0; } .card { --progress: 0; transition: --progress 0.5s ease; background: linear-gradient( to right, #4f8cff calc(var(--progress) * 100%), #e5e5e5 0 ); } .card.active { --progress: 1; }

这样背景色的填充进度就可以用transition无缝驱动,不需要 JS 逐帧更新。这个技巧本质上是把“插值能力”扩展到你自定义的变量上,很多从前必须用 Canvas、SVG 或 JS 动画解决的场景,现在纯 CSS 加一行transition就能搞定,性能和代码量都占优。

7. 常见问题速查表

问题现象根本原因解决方案
过渡不执行,直接跳变初始状态和目标状态之间缺少一次“已渲染”确认强制回流el.offsetHeight,或使用requestAnimationFrame延迟修改
用 height: auto 做展开动画不生效auto不是可插值的关键字用max-height近似,或 JS 计算实际高度后赋值
transitionend 触发多次多个属性同时过渡,每个属性各触发一次在事件回调里判断event.propertyName
transitionend 不触发过渡被反向打断或元素被移除增加transitioncancel监听和定时器兜底
transition: all 导致意外动画所有可动画属性都被插值改为明确列出需要过渡的属性
动态修改 transition 参数后动画异常样式修改合并到同一帧,浏览器未识别为“新状态”强制回流或把第二次修改放到下一帧
display: none 切换后透明度无过渡渲染状态不连续用 visibility + opacity 组合过渡
负 delay 不理解负 delay 表示从动画中间开始播放用来模拟“已进行一半”的入场动画
steps 做打字机不逐字分段数和字符数不匹配用steps(字数, end)并确保每步宽度一致

8. 最后分享一点实操心得

做动画,我最大的体会是:不要迷信复杂的 keyframes 动画,先把 transition 吃透。大部分 UI 交互需要的无非是“从 A 变到 B,中间的过程好看一点”,transition的天然优势是它由浏览器管理生命周期,你可以随时通过脚本触发、取消、反向播放,而不用自己维护一整套动画循环。在自己做过的几个项目里,用 transition 处理拖拽吸附、弹层显隐、列表增删,代码量比手写动画库少了至少一半,效果还更顺滑。

再分享一个小技巧:开发时在 Chrome DevTools 的 Rendering 面板里打开“Paint flashing”,观察哪些属性变化导致了重绘。如果一个简单的opacity过渡都在闪烁,说明可能有合成层问题,这时候可以给元素加will-change: opacity或transform,让浏览器提前知道这个元素会频繁变化。这个习惯能帮你少走很多弯路。

transition 这个属性看似基础,但它连接着浏览器渲染管线的核心机制。搞懂它,相当于打通了 CSS 动画的任督二脉,后面学 animation、学 Web Animations API 都会轻松很多。希望这些细节对你有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询