
做前端这些年我发现一个很有意思的现象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、matrixtranslate位移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 30px3D 时还需要第三个值transform-origin: 50% 50% 0这个属性是很多“为什么效果不对”的根源。举个最常见的例子一个时钟指针要绕着它底端旋转而不是中心。默认情况下rotate(90deg)是绕指针中心转效果完全不对。解决办法是.clock-hand { transform-origin: bottom center; transform: rotate(90deg); }翻页动效里上半页翻折绕的是底边所以要用transform-origin: bottom;下半页绕的是顶边要用transform-origin: top;。这一条在后面的实战案例里还会再强调。2.3 进入 3Dperspective、transform-style、backface-visibilityperspective透视3D 变换如果没有透视旋转 90 度只会看到一条线没有近大远小的立体感。perspective属性定义观察者与 z0 平面之间的距离值越小透视效果越强烈越“夸张”。有两种用法写在父容器上perspective: 800px;所有子元素的 3D 变换共享同一个透视原点。写在子元素的 transform 函数里transform: perspective(800px) rotateY(45deg);。区别是整个 3D 场景的“相机”。第一种是统一场景多个子元素围绕同一个灭点第二种是每个元素各自成透视常用于单个卡片翻转。transform-style子元素要在一个 3D 空间里互相有景深关系比如 3D 轮播图里前面的卡片挡住后面的必须在父元素上写transform-style: preserve-3d。如果父元素是默认值flat那子元素所有的 3D 变换都会被压扁到父元素的平面上所谓的 3D 效果就变成 2D 投影了。backface-visibilitybackface-visibility: hidden可以让元素旋转 180 度后背面不可见。这是实现卡片翻转、翻页钟表最核心的属性。默认值是visible也就是背面镜像内容也能看到如果不加 hidden翻转时就会出现“正反面内容重叠”的诡异画面。3D 的坑祖先元素一旦有overflow: hidden会强制把preserve-3d拍扁成flat。这是非常隐蔽的问题我排查过一整个下午最后发现是父容器为了撑圆角加了overflow: hidden。2.4 CSS 独立属性 translate / rotate / scaleCSS 后来为 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代表播放两轮半。注意循环动画除了要infiniteanimation-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-modedelay 期间应用的是元素本身的样式所以透明的初始关键帧没生效。这时候把animation-fill-mode设为both就能解决。animation-play-staterunning或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做“动画播完后再加载数据”或“打开弹层后聚焦输入框”。注意如果动画是infiniteanimationend永远不会触发只有animationiteration会重复触发。4. 实操案例用 Animation Transform 实现时钟翻页4.1 需求与思路老式翻页时钟的视觉特点是数字变换时上半部分先翻倒下半部分随后翻起接住。用 CSS 来实现核心是两点每一半页面绕自身的底边或顶边翻转翻转角度到 90 度时进行内容切换。我做一个简化版结构上每个翻页单元由三部分组成上半静态数字、下半静态数字、以及覆盖在上半部分上面的翻转板。翻转板动画执行时先把上半部分“翻开”露出一半新数字然后在翻转板超过 90 度时切换下半部分。4.2 HTML 结构示例div classflip-clock div classflip-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: transformGPU 内存暴涨大量元素长期使用 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 函数加几帧关键帧在循环播放。如果你能把每个属性在页面上的表现都预判出来遇到问题可以按“名称、时间、中心点、填充模式、遮挡关系”的顺序去排查大部分坑都能在几分钟内定位。这大概是这两个属性家族最值得吃透的价值。