做前端这些年,我发现一个很有意思的现象:transform和animation是很多同学最早接触、也最早放弃吃透的两个 CSS 属性。说“最早接触”,是因为做页面动效几乎绕不开它们;说“最早放弃吃透”,是因为用到一定程度就会卡在“为什么我这个翻转没立体感”“为什么动画执行到一半会跳回原位”“为什么明明写了 transform 却被覆盖”这些细节上。这篇东西不打算讲花哨的奇技淫巧,就是把这两个属性家族从底层逻辑到实操参数完整拆一遍,顺便把我踩过的坑和排查路径整理成速查清单。无论你是刚入门前端、正在准备面试,还是已经在项目里写过不少animation但想搞清楚性能原理,都能从中拿到直接能用的东西。
1. 先把 Transform 和 Animation 的关系理清楚
1.1 两者不是并列关系,是“素材”和“剧本”的关系
很多教程喜欢把transform和animation分开讲:先列一堆 2D、3D 函数,再讲关键帧怎么写。这个讲法没错,但容易让人忽略一个关键事实——你在 Animation 里真正“动”的东西,绝大多数时候就是 transform 和 opacity。
可以这样理解:transform是定义元素“变成什么样子”的静态描述,比如往右挪 100px、旋转 45 度、放大 1.5 倍;而animation是定义“从什么样子变到什么样子”的时间轴剧本。剧本里每一帧可以写各种属性,但如果你在一个动画里同时改width、left、margin-top,效果也能做出来,性能却是灾难级的。而 transform 恰恰是浏览器渲染管线里最“便宜”的属性之一。
这里要引出浏览器渲染的一个基础概念:渲染流水线。一次页面帧渲染,大体要经历 JavaScript 计算样式 -> Layout(布局) -> Paint(绘制) -> Composite(合成)。width、height、top这些属性变化会触发 Layout,整个渲染链路都要重走一遍;而transform和opacity的变化只发生在合成阶段,合成器线程直接把已经绘制好的图层移动、旋转、缩放或者改变透明度,不需要重新计算布局和重绘,所以性能好得多。
提示:记住这条结论就够了——能用
transform实现的位移、缩放、旋转,就不要用left/top/width/height去凑。这不是洁癖,是压帧率的关键。
1.2 什么时候用 transition,什么时候用 animation
两者很容易混淆,我见过不少同事把transition当animation用,或者反过来。区分标准其实很简单:
transition是状态切换的过渡,需要触发条件,比如hover、class 变化、JS 改样式。它没有中间帧的概念,只描述“从当前状态到目标状态怎么过渡”。animation是主动执行的动画,不需要外部条件,页面加载就跑,也可以配合 hover 暂停或反向。它通过@keyframes明确描述多个时间点的状态,可以循环、反向、播放多次。
打个比方:transition是“开门关门”的物理联动,你开门它才动;animation是“钟表指针”的自主运动,上发条后自己走。
实际项目里我的选择标准是:单次状态切换、只有起点和终点,用transition;需要多帧控制、循环播放、中途反向,用animation。比如按钮悬停阴影变化用 transition,而加载动画、数字翻页、跑马灯这种循环过程用 animation。
2. Transform 属性家族全解
2.1 2D 变换五个函数:translate、rotate、scale、skew、matrix
translate(位移)
translate(tx, ty)、translateX(tx)、translateY(ty)。如果只写一个值,默认Y轴为 0。单位可以用 px、em,也可以用百分比——注意这个百分比是元素自身尺寸的百分比,不是父容器。比如一个 200px 宽的元素,translateX(50%)就是右移 100px。这个特性在实现“水平垂直居中”时特别常用,配合绝对定位:
.modal { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }这里的 -50% 就是元素自己宽度和高度的一半,所以不管元素多大都能居中。
rotate(旋转)
rotate(angle),接收角度值。角度单位除了deg,还可以用turn(圈)、rad(弧度)、grad(百分度)。写rotate(0.5turn)就是转半圈。默认旋转中心是元素的transform-origin,默认值50% 50%,也就是元素中心。想让图片围绕左上角旋转,就改成transform-origin: left top。
scale(缩放)
scale(sx, sy)、scaleX(sx)、scaleY(sy)。参数是倍数,没有单位。写scale(2)等价于scale(2, 2),元素放大两倍。注意缩放会触发布局尺寸的实际变化吗?不会。元素在文档流里的占位不变,视觉上放大却不会把旁边的元素挤开。这既是优点也有坑:如果用它实现手风琴菜单展开,视觉内容变大了,但占位没有变大,很容易出现内容重叠。
skew(倾斜)
skew(ax, ay)、skewX(angle)、skewY(angle)。它的效果是把元素沿 X 轴或 Y 轴“压斜”,坐标系的轴不再垂直。这个函数在做平行四边形按钮、立体卡角效果时很常用。注意skew的默认值不是 0 而是0deg,写skew(30deg)等价于skew(30deg, 0),只倾斜 X 轴。
matrix(矩阵)
matrix(a, b, c, d, e, f)是一个 2D 变换的完整数学表达,前面四个函数其实都是matrix的特例。它的计算关系是:
x' = a * x + c * y + e y' = b * x + d * y + f其中a和d控制缩放(X/Y 方向),b和c控制倾斜/旋转,e和f控制位移。比如matrix(2, 0, 0, 2, 10, 20)就是把元素 X 方向放大 2 倍、Y 方向放大 2 倍,再向右移 10px、向下移 20px,等价于scale(2) translate(10px, 20px)的复合结果(注意顺序会影响最终结果)。
大多数业务场景我都不建议手写 matrix,用具体函数可读性更好。但是当你看到浏览器 DevTools 里计算后的 transform 值显示成 matrix 时别慌,知道它是这东西即可。
2.2 transform-origin:所有变换的“轴心”
transform-origin决定了rotate、scale、skew的参照点,对translate无影响。默认是50% 50%,可以写关键字:
top left/bottom right等方位组合- 长度值:
transform-origin: 20px 30px - 3D 时还需要第三个值:
transform-origin: 50% 50% 0
这个属性是很多“为什么效果不对”的根源。举个最常见的例子:一个时钟指针要绕着它底端旋转,而不是中心。默认情况下,rotate(90deg)是绕指针中心转,效果完全不对。解决办法是:
.clock-hand { transform-origin: bottom center; transform: rotate(90deg); }翻页动效里,上半页翻折绕的是底边,所以要用transform-origin: bottom;;下半页绕的是顶边,要用transform-origin: top;。这一条在后面的实战案例里还会再强调。
2.3 进入 3D:perspective、transform-style、backface-visibility
perspective(透视)
3D 变换如果没有透视,旋转 90 度只会看到一条线,没有近大远小的立体感。perspective属性定义观察者与 z=0 平面之间的距离,值越小,透视效果越强烈(越“夸张”)。有两种用法:
- 写在父容器上:
perspective: 800px;,所有子元素的 3D 变换共享同一个透视原点。 - 写在子元素的 transform 函数里:
transform: perspective(800px) rotateY(45deg);。
区别是整个 3D 场景的“相机”。第一种是统一场景,多个子元素围绕同一个灭点;第二种是每个元素各自成透视,常用于单个卡片翻转。
transform-style
子元素要在一个 3D 空间里互相有景深关系(比如 3D 轮播图里前面的卡片挡住后面的),必须在父元素上写transform-style: preserve-3d。如果父元素是默认值flat,那子元素所有的 3D 变换都会被压扁到父元素的平面上,所谓的 3D 效果就变成 2D 投影了。
backface-visibility
backface-visibility: hidden可以让元素旋转 180 度后背面不可见。这是实现卡片翻转、翻页钟表最核心的属性。默认值是visible,也就是背面镜像内容也能看到,如果不加 hidden,翻转时就会出现“正反面内容重叠”的诡异画面。
3D 的坑:祖先元素一旦有overflow: hidden,会强制把preserve-3d拍扁成flat。这是非常隐蔽的问题,我排查过一整个下午,最后发现是父容器为了撑圆角加了overflow: hidden。
2.4 CSS 独立属性 translate / rotate / scale
CSS 后来为 transform 引入了三个独立属性:translate、rotate、scale。以前要写复合变换时只能把函数都塞进transform字符串里,后一条规则会整个覆盖前一条;现在可以分开写,比如:
.card { translate: 50px 20px; rotate: 10deg; scale: 1.2; }这样不容易出现“想加一个 rotate 结果把原来的 translate 顶掉”的问题。独立属性还能在 hover 时单独改其中一个,不改动别的参数:
.card:hover { translate: 50px 20px; scale: 1.5; }兼容性上,现代浏览器已经普遍支持,但如果你还在维护老项目(比如需要兼容很老的那批国产浏览器 WebView),建议先查一下目标环境再使用,不能用时还是退回写transform复合函数。
2.5 Transform 的合成层与性能
前面说过transform走合成器线程,但这里有个隐藏机制:浏览器什么时候会给元素提升为独立的合成层?通常情况下,只要元素的transform被动画化,浏览器会自动为它创建一个合成层。但如果你在动画里同时修改了background-color这类 Paint 阶段的属性,合成层也救不了,因为每一帧还是要触发重绘。
还有will-change这个属性,它的作用是提前告诉浏览器“这个元素接下来要变”,让浏览器提前做合成准备。但它不是银弹,用多了反而会占用大量 GPU 内存,导致页面卡顿。我的经验:只对真正在动的元素使用,动画结束后把will-change移除;能用transform: translateZ(0)触发硬件加速的老技巧,在现在的浏览器里一般没必要再用了,反而浪费内存。
3. Animation 属性家族全解
3.1 八个子属性逐一说透
animation-name
指定要绑定的@keyframes名称。名称是自定义的,但不能是 CSS 关键字。如果名称写错了,或者@keyframes没定义,动画不会执行,控制台通常没有任何报错——这是它比 JS 动画难排查的地方。
animation-duration
动画完成一轮需要的秒数或毫秒数,比如1.5s、300ms。默认值是0s,意味着不执行。最容易犯的错是在使用简写animation时只写了一个时间值,浏览器会默认把它当作duration,另一边的animation-delay就变成 0 了。
animation-timing-function
缓动函数,描述每一帧速度变化。常见关键字:
linear匀速ease慢速开始、中间快、慢速结束(默认值)ease-in慢速开始,加速直到结束ease-out快速开始,减速直到结束ease-in-out两头慢中间快
进阶用法是cubic-bezier(x1, y1, x2, y2)自定义贝塞尔曲线。随手给一个弹性回弹曲线感受一下:cubic-bezier(0.34, 1.56, 0.64, 1),y 值超过 1,会出现“冲过头再回来”的效果,用来做弹跳很带感。
还有一个被用得比较少但功能强大的分段函数steps(count, direction)。steps(4, end)表示把动画过程分成 4 段,每段之间直接跳变,没有连续过渡。做打字机逐字出现、进度条一格一格增长、翻页钟表轮播时,steps都是关键。
animation-delay
延迟0s后开始执行。负数很有意思,比如animation-delay: -2s会跳过前 2 秒,直接从中途开始播。这个技巧可以用在“多个元素已经是循环动画,需要错开相位”的场景里,没必要为了错开相位给每个元素写不同的 keyframes。
animation-iteration-count
播放次数,默认1。写infinite无限循环。可以写小数,比如2.5代表播放两轮半。注意:循环动画除了要infinite,animation-direction和fill-mode也要一起配合,否则容易在某次循环末尾跳变。
animation-direction
动画播放方向:normal(默认,从 from 到 to)、reverse(从 to 到 from)、alternate(来回交替)、alternate-reverse(反向来回交替)。如果想让元素“放大—缩小—放大—缩小”,用alternate配合两帧 keyframes 就够了,没必要写 4 帧。
animation-fill-mode
动画执行前和执行后是否保留起始帧或结束帧的样式:
none默认值,动画前后都不应用 keyframes 的样式forwards动画结束后保留最后一帧的样式backwards动画开始前(包括 delay 等待期)应用第一帧的样式both两个方向都填充
最典型的坑:给一个元素写animation: fadeIn 1s ease 2s,结果发现前 2 秒元素是显示出来的,到了动画真正开始时才瞬间消失再从透明变回来。这就是没写fill-mode,delay 期间应用的是元素本身的样式,所以透明的初始关键帧没生效。这时候把animation-fill-mode设为both就能解决。
animation-play-state
running或paused。配合hover可以控制动画暂停:
.spinner { animation: spin 2s linear infinite; } .spinner:hover { animation-play-state: paused; }这个属性比用取消/恢复animation本身要省事,因为它不用破坏动画状态,暂停后从原位继续。
3.2 @keyframes 的写法和细节
@keyframes里面用百分比或from/to描述关键帧:
@keyframes pulse { from { transform: scale(1); opacity: 1; } to { transform: scale(1.2); opacity: 0.6; } }等同于:
@keyframes pulse { 0% { transform: scale(1); opacity: 1; } 100% { transform: scale(1.2); opacity: 0.6; } }中间可以任意加帧,比如50%、33.33%。每一帧里可以被动画的属性都会从上一帧过渡到这一帧,没写的属性则保持上一帧的样式,不会重置。这个机制有好处也有坑:如果发现某帧出现“不该有的属性残留”,八成是你没在该帧写清所有想改变的值。
还有个细节:@keyframes内部不能使用!important,如果写了会被忽略。另外在某些浏览器里,同一关键帧内部同时写transform: translateX(100px) rotate(45deg);,顺序就决定了计算效果,不能指望浏览器帮你自动修正。
3.3 简写顺序和注意事项
animation简写依次是:
animation: name duration timing-function delay iteration-count direction fill-mode play-state;但在实际书写时很多值位置是“可互换”的,唯一不能乱的是两个时间值:第一个时间一定是 duration,第二个才是 delay。比如:
animation: fadeIn 2s ease 0.5s 3 alternate both;这里的2s是持续时间,0.5s是延迟。如果把两个时间写反了,效果会变成“延迟 2 秒,只播 0.5 秒”,很魔幻,而且还不报错。这是一个特别难查的坑。
3.4 JS 事件:animationend、animationiteration、animationcancel
动画不只是 CSS 的事,经常需要等动画结束后执行 JS 逻辑。监听事件:
element.addEventListener('animationend', (event) => { console.log(event.animationName, event.elapsedTime); });animationend动画结束后触发animationiteration每次循环结束触发animationcancel动画被取消时触发
这三个事件的event对象上都有animationName和elapsedTime两个字段。实际项目里,我经常用animationend做“动画播完后再加载数据”或“打开弹层后聚焦输入框”。注意:如果动画是infinite,animationend永远不会触发,只有animationiteration会重复触发。
4. 实操案例:用 Animation + Transform 实现时钟翻页
4.1 需求与思路
老式翻页时钟的视觉特点是:数字变换时,上半部分先翻倒,下半部分随后翻起接住。用 CSS 来实现,核心是两点:每一半页面绕自身的底边或顶边翻转,翻转角度到 90 度时进行内容切换。
我做一个简化版,结构上每个翻页单元由三部分组成:上半静态数字、下半静态数字、以及覆盖在上半部分上面的翻转板。翻转板动画执行时,先把上半部分“翻开”露出一半新数字,然后在翻转板超过 90 度时切换下半部分。
4.2 HTML 结构示例
<div class="flip-clock"> <div class="flip-card">.flip-card { position: relative; width: 120px; height: 160px; font-size: 48px; line-height: 80px; text-align: center; border-radius: 8px; perspective: 300px; } .flip-card-top, .flip-card-bottom, .flip-top, .flip-bottom { position: absolute; left: 0; width: 100%; height: 50%; overflow: hidden; background: #222; color: #fff; backface-visibility: hidden; } .flip-card-top { top: 0; border-radius: 8px 8px 0 0; } .flip-card-bottom { bottom: 0; border-radius: 0 0 8px 8px; } .flip-bottom .bottom-num { display: inline-block; transform: translateY(-50%); } .flip-top { top: 0; transform-origin: bottom center; animation: flipTop 0.6s ease-in forwards; } .flip-bottom { bottom: 0; transform-origin: top center; transform: rotateX(90deg); animation: flipBottom 0.6s ease-out 0.3s forwards; } @keyframes flipTop { 0% { transform: rotateX(0deg); } 100% { transform: rotateX(-90deg); } } @keyframes flipBottom { 0% { transform: rotateX(90deg); } 100% { transform: rotateX(0deg); } }注意几个参数:
.flip-top的transform-origin是bottom center,所以它是以底边为轴向下翻倒。.flip-bottom的transform-origin是top center,它是以顶边为轴向内翻起。- 上半翻板动画用了
ease-in,到达 90 度后消失;下半翻板动画延迟 0.3s 启动,正好接上半翻板的反面。 - 两者的时间加起来是 0.9s,如果你想让整体节奏变成 1.2s,可以改成 0.8s 和 0.4s,延迟也相应调整。
backface-visibility: hidden保证翻转板转到背面时不会露出镜像数字。
这个案例把transform-origin、rotateX、animation-fill-mode: forwards、animation-delay全部用上了,改任何一个参数都会破坏翻页的接力感。实际做产品时我一般会把这套逻辑封装成 React/Vue 组件,用 JS 控制要展示的数字,CSS 只负责播放动画。
4.4 动画性能注意事项
翻页这种效果会同时操作两个图层的 3D 变换,在低端设备上有一定风险。几个优化经验:
- 给动画元素加上
will-change: transform,只加在正在播放动画的元素上,播完移除。 - 避免在动画过程中修改父容器的尺寸和背景,防止触发父级同步重排重绘。
- 不要在动画期间做大量 JS 计算,特别是
setInterval里频繁改 innerHTML。翻页钟通常每秒切换一次,JS 只更新数字文本即可,动画本身完全交给 CSS。 - 如果同一个页面同时存在多个翻页钟,建议它们共享同一个
@keyframes,浏览器渲染层能复用动画定义,而不是每个元素都声明一份私有动画。
5. 常见问题与排查技巧实录
5.1 动画没有执行,控制台又不报错
这是animation最让人头痛的问题。排查顺序我总结为四步:
- 检查
animation-name和@keyframes名称是否完全一致,包括大小写。CSS 选择器是大写敏感的,@keyframes fadeIn不能用fadein匹配。 - 检查简写属性里有没有把两个时间值写反,正确顺序一定是
duration在前,delay在后。 - 检查
animation-duration是否大于 0,默认值是 0,写着animation: fadeIn 0s当然不会动。 - 检查元素是否被
display: none、宽度高度为 0、被遮挡或者透明度为 0 等等。有时候动画在播放,但肉眼看不见,因为是整体不可见的状态。
其他平台上的报错也是同一个道理,比如用到某些第三方组件库时出现“unknown animation name: xxx”这类提示,本质就是组件内部的动画名没有正确注册到项目的样式环境里。先解开简写、确认名称,再往下查。
5.2 动画最后突然跳回原位
这个现象十有八九是animation-fill-mode的问题。动画结束后,如果没写forwards或both,元素样式会恢复到动画前的状态。
修复方式:
animation: fadeIn 1s ease forwards;如果你希望动画延迟期间也保持起始帧效果,要把forwards换成both。
5.3 transform 被覆盖,后面的规则盖掉了前面的
transform是一个整体属性,同一元素在同一时刻只能有一个transform值。两个规则都想设置transform,后一个会替换前一个,而不是合并。最典型的问题:
.box { transform: translateX(100px); } .box.active { transform: rotate(45deg); /* translateX 被覆盖 */ }解决办法有三种:
- 把复合函数写全:
.box.active { transform: translateX(100px) rotate(45deg); } - 使用独立属性
translate、rotate、scale,互不干扰:
.box { translate: 100px 0; } .box.active { rotate: 45deg; }- 把不同阶段的变换拆到不同子元素上,让它们各自负责一个方向的运动。
如果你通过 JS 设置element.style.transform = 'translateX(...)',也会遇到同样的覆盖问题,这就是很多同学搜索“js transform left”时想解决的场景。建议把 transform 字符串当成一个完整状态管理,而不是零散地拼。
5.4 3D 效果不立体,像一张纸
通常原因有三个:父容器没有perspective;父容器有overflow: hidden导致preserve-3d失效;或者你用了transform: rotateX()但需要先加perspective()函数。优先检查这两点:
.stage { perspective: 800px; transform-style: preserve-3d; /* 不要在这里加 overflow: hidden */ }5.5 性能问题排查表
有些问题不是效果不对,而是卡顿。整理成一张速查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 页面掉帧 | 动画了 width/height/left/top | 改用 transform 位移或缩放 |
| 动画卡顿 | 动画元素没有独立合成层 | 在动画期间加 will-change: transform |
| GPU 内存暴涨 | 大量元素长期使用 will-change | 动画结束后移除 will-change |
| 动画期间出现模糊闪烁 | 大尺寸 3D 变换 + 背景透明层级 | 避免过度叠加半透明层,必要时用 translateZ(0) |
| 动画执行时文字抖动 | 动画元素 z 轴未稳定 | 统一使用 transform: translateZ(0) 提升层级,避免每帧抖动 |
5.6 多端开发里容易忽略的“同名不同法”
有时在其他端也会看到相似概念,比如 Unity 里的 Animation 和 Animator、Android 里的属性动画,或者某些动画库在初始化时报告缺少动画名称。它们和 CSS 动画虽然在命名上相似,但引入动画的方式、时间轴模型、属性注册机制完全不同。如果你是在做跨端项目,遇到“unknown animation name”“transform 模型小白”这类问题,不要拿 CSS 的认知直接套,先确认目标框架里动画资源的注册方式和触发器,再排查命名。
而 Python 里那种“类批量生成属性”的热词,则是另一个方向的元编程能力,和前端 CSS 属性无关,但它说明“属性”这个概念在不同技术栈里含义差异巨大——CSS 里它是样式的声明,Python 里它是对象的成员封装。回到本文主题,我们只需要聚焦 CSS 中 transform 和 animation 这两个属性体系。
5.7 实用小技巧:用 animation-delay 负值同步循环动画
最后分享一个我常用来省事的技巧。假设有 5 个圆点做波浪加载动画,想让它们错开 0.2 秒依次跳动。通常新人会写 5 份不同的 keyframes 或者用 JS 加 delay。其实只用同一个动画,给每个圆点不同的负animation-delay即可:
.dot { animation: bounce 1s ease-in-out infinite; } .dot:nth-child(1) { animation-delay: -0.2s; } .dot:nth-child(2) { animation-delay: -0.4s; } .dot:nth-child(3) { animation-delay: -0.6s; } .dot:nth-child(4) { animation-delay: -0.8s; } .dot:nth-child(5) { animation-delay: -1s; }负 delay 让动画从一开始就处于“中途”状态,所以页面加载那一刻它们就已经错开相位,而不是先齐齐出现再各自延迟,观感会自然很多。
就我自己在项目里的体会,CSS transform 和 animation 这两个属性学起来不难,但真正用顺一定要先理解“合成层”“transform-origin”“fill-mode”这三个底层概念。很多看起来复杂的动效,拆开看都是一组标准 transform 函数加几帧关键帧在循环播放。如果你能把每个属性在页面上的表现都预判出来,遇到问题可以按“名称、时间、中心点、填充模式、遮挡关系”的顺序去排查,大部分坑都能在几分钟内定位。这大概是这两个属性家族最值得吃透的价值。