CSS计算动画&性能优化
你可能用过 calc(100% - 20px)。但现在的 CSS 数学能力已经进化到了“逻辑判断”的层次。
sin, cos, tan
以前的痛点: 想要把一堆图标围成一个圆圈排列(类似转盘菜单)。你必须用 Sass 的循环算好每个 top/left 的像素值,或者用 JS 算。
现代解法: 原生三角函数。
.circle-menu-item {
/* 只需要知道索引(--i) 和总数 */
--angle: calc(360deg / var(--total) * var(--i));
/* 直接用极坐标公式算出 x, y */
translate:
calc(cos(var(--angle)) * 100px)
calc(sin(var(--angle)) * 100px);
}深度理解: 这赋予了 CSS 处理几何布局的能力。配合 CSS 变量,你可以构建出极其复杂的物理运动模拟(比如模拟钟摆、行星轨迹),且不需要 JS 介入每帧的计算。

min(), max(), clamp()
你可能用过 calc(100% - 20px)。但现在的 CSS 数学能力已经进化到了“逻辑判断”的层次。
场景: 排版文字大小。你希望在大屏上是大字,小屏上是小字,但不能太小也不能太大。
以前的做法: 写一堆 @media 查询。
现代解法: 流体排版 (Fluid Typography)。
h1 {
/* 翻译一下:
- 最小 2rem
- 理想情况是 视口宽度的 5% + 1rem
- 最大 5rem
*/
font-size: clamp(2rem, 5vw + 1rem, 5rem);
}
.container {
/* 哪个小用哪个 */
/* 如果屏幕很宽,用 800px;如果屏幕很窄(小于 800px),用 100% */
width: min(100%, 800px);
}底蕴价值: 这代表了 CSS 的响应式思维从“断点(Breakpoints)”转向了“流体(Fluid)”。你的布局不再是阶梯状的变化,而是像水一样平滑适应任何容器。
round(), mod(), rem()
calc() 只是开始,现在 CSS 拥有了更高级的数学函数,专门处理离散数据。
- 场景: 你想做一个响应式网格,要求宽度必须是 50px 的整数倍(避免出现半个像素导致的模糊)。
- 现代解法: 使用
round()。
.card {
width: 100%;
/* 核心:
不管 100% 是多少,强制把它四舍五入到最近的 50px
比如算出来是 312px -> 变成 300px
算出来是 330px -> 变成 350px
*/
width: round(nearest, 100%, 50px);
/* 还有取余数 mod() */
/* 比如:生成条纹背景的偏移量 */
--offset: mod(100px, 30px); /* 结果是 10px */
}路径动画:offset-path
这是 CSS 处理 非线性几何 的能力。
痛点: 你想让一个飞机的图标,沿着一条复杂的贝塞尔曲线(比如地图上的航线)飞行。以前这必须用 JavaScript 实时计算 x, y 坐标,或者用 SVG SMIL 动画(已被废弃)。
现代解法 (offset-path): 只要你有路径(path),元素就能沿着它跑。
.plane {
/* 1. 定义路径:这就和 SVG 里的 path 数据一模一样 */
offset-path: path('M10 10 Q 50 50 90 10');
/* 2. 让飞机头始终朝向路径的前方 (自动旋转) */
offset-rotate: auto;
/* 3. 动画:从路径的 0% 跑到 100% */
animation: move-plane 3s linear infinite;
}
@keyframes move-plane {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}底蕴价值: 它打破了 CSS 只能做“直线运动”的限制。在制作数据可视化(如粒子沿着管道流动)、复杂的引导页动画时,它是最高效的方案,且不占用 JS 线程。
页面转场的魔法:View Transitions API (视图过渡)
这是目前 Web 开发中最令人兴奋的特性之一。它允许你在两个不同的 DOM 状态之间创建平滑的变形动画 (Morphing)。
- 场景: 比如在一个列表页点击一张小图,这张图“飞”到了详情页变成了大图(类似手机 App 的相册体验)。
- 以前的做法: 极其复杂。需要计算两个图片的位置差 (FLIP 技术),用绝对定位模拟飞行,非常容易出 Bug。
- 现代解法: 给两个元素起同一个名字,浏览器自动处理中间的变形。
核心 CSS 属性:view-transition-name
/* 页面 A(列表页)的小图 */
.thumbnail {
view-transition-name: hero-image;
contain: layout; /* 性能优化 */
}
/* 页面 B(详情页)的大图 */
.header-image {
/* 只要名字一样,浏览器就会自动把它们关联起来做动画 */
view-transition-name: hero-image;
}为什么说它是“底蕴”? 它改变了 SPA (单页应用) 和 MPA (多页应用) 的界限。配合少量的 JS (document.startViewTransition),你可以让网页拥有原生 App 级别的丝滑转场,而 CSS 负责控制所有的动画曲线和持续时间。
linear() 缓动函数
CSS 动画一直被诟病只有简单的 ease-in, ease-out, cubic-bezier。做不出那种“Q 弹”的果冻效果。
- 痛点: 想要一个按钮按下去有弹簧回弹的效果 (Spring Physics)。
- 以前的做法: 只能引入 JS 动画库(如 Anime.js, Motion One),或者写一个超级复杂的
@keyframes包含 50 个关键帧。 - 现代解法:
linear()函数允许你通过定义一系列点来模拟任何曲线,包括弹簧。
.button:hover {
transform: scale(1.1);
/* 这一串数字模拟了一个真实的弹簧回弹效果 */
transition: transform 0.6s linear(
0, 0.009, 0.035 2.1%, 0.141 4.4%, 0.723 12.9%, 0.938 16.7%, 1.017,
1.077, 1.121, 1.149 24.3%, 1.159, 1.163, 1.161, 1.154 29.9%, 1.129 32.8%,
1.051 39.6%, 1.017 43.1%, 0.991, 0.977 51%, 0.974 53.8%, 0.975 57.1%,
0.997 69.8%, 1.003 76.9%, 1.004 83.8%, 1
);
}底蕴价值: 虽然这串数字看起来很乱(通常由工具生成),但它意味着 CSS 动画引擎的能力上限被打破了。你不再需要为了一个微交互引入几十 KB 的 JS 库。
虚拟列表新方案
content-visibility
这是一个能让你的网页渲染性能瞬间提升 10 倍的属性,尤其是对于长列表页面(比如新闻流、商品列表)。
- 痛点: 假设你的页面有 1000 张卡片。即使它们在屏幕外(用户还没滚下去),浏览器默认也会去计算它们的高度、布局和渲染。这会让页面加载变慢,滚动卡顿。
- 以前的做法: 使用 JavaScript 的“虚拟列表”(Virtual List)技术。非常复杂,要计算滚动位置,动态销毁和创建 DOM。
- 现代解法: 告诉浏览器:“屏幕外的东西,先别算!”
.card-list {
/* 核心:自动跳过屏幕外元素的渲染工作 */
content-visibility: auto;
/* 关键补充:给未渲染的元素占个位 */
/* 如果不写这个,滚动条会随着加载乱跳(因为浏览器不知道实际高度) */
contain-intrinsic-size: 1000px;
}底蕴价值: 这叫 CSS 级别的懒渲染 (Lazy Rendering)。你不需要写一行 JS,浏览器引擎会自动根据视口可见性来决定是否分配 CPU/GPU 资源。这是构建高性能 Web 应用的基石。
contain (渲染隔离)
这是 content-visibility 的底层原理,也是手动挡的性能优化。
场景: 你有一个复杂的侧边栏组件,里面有几千个 DOM 节点。
痛点: 当你改变侧边栏里一个小图标的颜色时,浏览器可能会重排 (Reflow) 整个页面,因为它担心这个图标变大变小会影响到页面其他部分。
现代解法: 告诉浏览器:“这个盒子内部发生任何事,都不会影响外部。”
.complex-widget {
/* 核心:
layout: 内部布局改动不影响外部
paint: 内部绘制不溢出盒子
style: 计数器等样式不影响外部
*/
contain: layout paint style;
/* 或者简写为 strict (最严格的隔离) */
contain: strict;
}底蕴价值: 这是浏览器渲染管线 (Rendering Pipeline) 的控制权。你在用代码画一个圈,告诉渲染引擎:“在这个圈里,你随便折腾;出了这个圈,我不负责。”这是构建高性能 Web App 的核心思维。
性能优化的“预判”:will-change
这是一个危险但强大的属性。它是你和浏览器 GPU 之间的“秘密热线”。
你做了一个复杂的侧滑菜单动画。 问题: 动画开始的那一瞬间(0ms -> 10ms),页面卡了一下(Jank)。 原因: 浏览器还没来得及把这个元素提升到合成层 (Compositor Layer),它需要一点时间准备 GPU 纹理。
现代解法: 提前告诉浏览器。
.sidebar {
/* 告诉浏览器:
"兄弟,待会儿我要变 transform 了,你先把 GPU 资源准备好。"
*/
will-change: transform;
}⚠️ 架构师警告:
不要滥用! 不要给 * { will-change: all; }。这会把显存撑爆,导致手机闪退。
用完即弃: 最好只在动画开始前(比如 :hover 时)加上,动画结束后用 JS 移除它。
拖动排序:那种手感是怎么做出来的
dnd-kit 的 sortable 示例,是我见过手感最好的一个:卡片贴着指针 1:1 走,抬起来的时候微微放大、垫上一层影子,其余的行滑开让位,松手是落座而不是瞬间归位。
我照着它手写了一份,没有引入它的包。可以现在就拖:
- 写周报
- 交房租
- 给猫铲屎
- 把书还了
- 订机票
- 修灯泡
四个开关对应正文的四步。关掉一个,就退回做那一步之前。
拖动排序的 JS 其实很少:记住按下的坐标、算出手指现在想插到第几个、松手改数组。手感几乎全在 CSS 上,而且是四步叠出来的。每讲完一步,就去关掉演示里对应的那个开关,看看没有它是什么样。
最小实现
先写一个能跑的:
const STEP = 78; // 一行的高度 + 行距
let from = index, to = index;
handle.addEventListener('pointerdown', (e) => {
const startY = e.clientY;
const move = (ev) => {
const dy = ev.clientY - startY;
row.style.transform = `translateY(${dy}px)`; // 跟着手走
to = from + Math.round(dy / STEP); // 走过半行就换位
};
const up = () => {
commit(from, to); // 改数组
row.style.transform = '';
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});“想插到第几个”就是位移除以行距、四舍五入——Math.round 天然就是“过了半行才换”。监听器挂在 window 上而不是卡片上,手指划出卡片范围也不会断。
这样已经能排序了。但它现在是:卡片跟着手走,其余的行在你越过半行的那一刻啪地换位置,松手卡片啪地归位。功能对,观感像 2005 年。
差四步。
第一步:让位要滑开
问题很直接——其余行是瞬移的。
原因是我们让数组去换位了。数组一变,框架就把节点搬到新位置,浏览器画出来的是最终结果,中间没有过程可言。
改法是:拖动期间根本不动数组,只给该让位的行一个位移,让 CSS 去补中间那段。
.item {
transition: translate .3s cubic-bezier(.2, 0, 0, 1);
}// 手指从第 from 行拖到第 to 行,夹在中间的那些各让开一格
const shift = (i) =>
from < to && i > from && i <= to ? -STEP :
to < from && i >= to && i < from ? STEP : 0;数组等到松手才改。于是“看到什么,松手就是什么”——这也是它和 HTML5 那套 draggable 最根本的区别:那套是“拖过去、松手才生效”,中途只能画一条插入线去暗示结果。
关掉演示里的 ①,让位就变回瞬移。
第二步:位移不能有过渡——可它和第一步共用一个属性
刚加完过渡你就会发现被拖的那张卡不对劲:它落后于手指。
因为那条 transition 是给所有行写的,被拖的那张也吃到了。而它要的恰恰相反:
| 需要的时长 | |
|---|---|
| 让位的行 | 300ms,要看见滑动 |
| 被拖的那张 | 0ms,晚一帧就是“拖不动” |
| 抬起的放大 | 300ms,瞬间弹到位像卡了一下 |
同一个元素上要两种时长,写在 transform 上做不到——它是一个属性,只有一条 transition-duration。
以前的解法是套两层 DOM:外层管位移、内层管放大,各写各的过渡。现在不用了,因为 translate / rotate / scale 已经是三个独立的 CSS 属性(CSS Transforms Level 2),各有各的过渡。
把位移剥出来交给 translate,transform 原样留给放大:
.item {
transition: transform .3s, box-shadow .3s;
}
.item[data-dragging] {
transition: transform .3s, box-shadow .3s, translate 0ms linear;
}row.style.translate = `0 ${dy}px`; // 逐帧写,没有过渡dnd-kit 就是这么分的。它没有直接写样式,而是往元素上写两个自定义属性——拖动中的卡片上是:
--dnd-translate: 0px 0px 0;
--dnd-transition: transform 0.3s, box-shadow 0.3s, translate 0ms linear;消费它们的是库自带的那张样式表。它挂在 document.adoptedStyleSheets 上,所以你在 DevTools 的 Styles 面板里看得到它生效,却在页面的 <link> 里找不到来源:
[data-dnd-dragging] {
transition: var(--dnd-transition) !important;
will-change: translate;
}
[data-dnd-dragging]:not([data-dnd-dropping]) {
translate: var(--dnd-translate) !important;
}--dnd-transition 的值值得多看一眼:它是把作者原本那条读出来、末尾接一句 translate 0ms linear。剥出去的只有位移这一样,作者写的 transition: transform .3s 一个字都不用改——库和作者各占一个属性,互不打架。
关掉演示里的 ②,两样就合回一条 transform。我量过一次:手指走了 90px,50ms 时卡片只走到 28.7px。
第三步:抬起来
动作已经对了,但卡片还是平的:它在列表里滑,不像被拿起来。
.item[data-dragging] {
transform: scale(1.03);
backdrop-filter: blur(5px);
background-color: rgb(255 255 255 / .9);
box-shadow: inset 0 0 1px rgb(0 0 0 / .5),
-1px 0 15px rgb(34 33 81 / .01),
0 15px 15px rgb(34 33 81 / .25);
}这几个数是从它示例上量下来的,每个都有讲究。
只放大 3%。 再大就从槽位里胀出来压到邻行。要的是“离开了桌面”,不是“变成了另一个东西”。
影子往下压,而且 15px 偏移只配 15px 模糊。 静息态那份是 0 1px 3px,贴着桌面;拖起来换成 15/15。模糊半径要是给到三四十,就成了一团雾,反而没有高度——硬边才像有个东西悬在上面。
底色降到 90% 再加一层 blur(5px)。 这条最容易被跳过,但它是“厚度”的来源:卡片下面透出一点点被糊掉的东西,你才觉得它是浮着的一片,而不是换了位置的同一块。
关掉演示里的 ③ 对比一下——没有这一步,整个动作看着像在推一格表格。
第四步:松手是落座,不是瞬移
现在松手,卡片会“啪”地出现在新位置,因为它的位移过渡还钉在 0ms 上。
落座要做的就是把那 300ms 装回去,把位移改到目标槽位,等它滑完再改数组:
row.style.transition = 'transform .3s, box-shadow .3s, translate .3s';
row.style.translate = `0 ${(to - from) * STEP}px`;
setTimeout(() => commit(from, to), 300);同一时刻放大回到 1、影子收回去。三样同一条曲线、同一个时长,读起来才是“把卡片放下”这一个动作,而不是“滑到位,外加影子啪地消失”。
关掉演示里的 ④,就是没有这一步的样子。
这一步有两个坑,都不难踩。
行内的 transition 是整条替换的,不是合并。 位移归 JS 逐帧写,那 JS 就得动 style.transition;只写位移那一段:
row.style.transition = 'translate 300ms ease'; // ❌ 放大和影子的过渡一起没了表现正是刚才说的那种“一个动作裂成两半”。所以每次写行内 transition 都得把另外几段一起带上。dnd-kit 那条 transition: var(--dnd-transition) !important 是同一件事的另一种绕法:整条声明留在样式表里,JS 只改一个变量的值。
真正改数组的那一帧必须关掉过渡。 那一帧里两件事同时发生:元素换到了新的流位置,它的 translate 也从 ±78 归 0。两者本该互相抵消、画面一动不动——可 translate 上还挂着 300ms 过渡,于是它从“新槽位 −78”慢慢滑回来,看着是整列先跳一下再归位。改法是那一帧写 transition: none,下一帧再装回去(要双 requestAnimationFrame:一帧让浏览器把没有过渡的新样式画出去,下一帧才敢装回来)。
到这儿就够用了
四步做完,手感和它的示例已经很接近,JS 那侧一共也就是记坐标、算 index、改数组。
下面是 dnd-kit 多做的几件事。它们不是为了更好看,是为了在别人的页面里也不出错——你自己那个列表未必需要,但每一条都能单独拿走用。
多做的一件:把卡片送进 top layer
祖先上一个 overflow: hidden,被拖起来的卡片就被削掉半截;祖先上一个 filter 或 transform,它连 z-index 都比不过外面的东西。作为一个要贴到任意页面上的库,这些都得防。
它的办法是把那张卡整个抠出来:
[data-dnd-dragging] {
position: fixed !important;
top: var(--dnd-top) !important;
left: var(--dnd-left) !important;
right: unset !important;
bottom: unset !important;
width: var(--dnd-width, auto);
max-width: var(--dnd-width, auto);
height: var(--dnd-height, auto);
max-height: var(--dnd-height, auto);
z-index: calc(infinity);
touch-action: none;
pointer-events: none !important;
}
[data-dnd-dragging] * { pointer-events: none !important; }position: fixed 加那四个变量,是把卡片从文档流里拿出来、原地钉在它刚才占的那个矩形上。好处不只是脱离流:这样一来 translate 的坐标系就是视口,和指针是同一套,位移可以直接写“手指走了多少”,不用换算。
z-index: calc(infinity) 是个能直接偷走的写法。infinity 是 CSS Values 4 的关键字,写在 calc() 里合法,落到实现的最大整数(我读到的计算值是 2147483647)。比手敲一个 9999 诚实。
pointer-events: none 连同它所有的子元素,是因为卡片就悬在指针底下——不关掉的话命中测试永远打在它自己身上,“现在悬在谁上面”这个判断整个失效。
真正意外的是最后一手:拖动中的元素上挂着 popover="manual",也就是说它被送进了 top layer。到了那一层连 z-index 都不必比,祖先的 overflow、filter、层叠上下文一概管不着它。代价是浏览器会给 popover 一套默认外观(白底、黑边、居中定位),所以库得先把这套抹平:
@layer {
:where([data-dnd-dragging][popover]) {
overflow: visible;
background: unset;
border: unset;
margin: unset;
padding: unset;
color: inherit;
}
}
[data-dnd-dragging]::backdrop { display: none; visibility: hidden; }@layer 套 :where() 是双保险:匿名图层排在所有具名图层和无图层规则之前,:where() 又把特指度压成 0——作者自己写的 .item { background: #fff } 一定赢。给别人的元素铺一层默认样式时,这是该抄的写法。
多做的一件:留在原位的那张占位
卡片飞走之后,原来的位置留一张灰的:
[data-dnd-placeholder] { transition: none; }
.item[data-dnd-placeholder] { opacity: .5; box-shadow: none; transform: none; }transition: none 不是图省事,是必须的。占位要瞬间出现在新槽位上——它和被拖的那张卡是同一个东西的两半,一半跟手、一半瞬移,谁也不能有对方的过渡。
多做的一件:算出缩放原点,但按需才用
拖动中的元素上还有一个 --dnd-transform-origin: 91.67% 50%。卡片宽 300px,91.67% 正好是把手所在的横向位置——它按你抓住的地方算了原点。
但消费它的规则带着一道门:
[data-dnd-dragging][style*="--dnd-scale"] {
scale: var(--dnd-scale) !important;
transform-origin: var(--dnd-transform-origin) !important;
}只有 --dnd-scale 同时在场才生效,那是跨容器尺寸变化时库自己缩放用的。示例里没有它,所以那 3% 的抬起其实是从卡片中心放的——300px 的 3% 是 9px,两边各 4.5px,手感觉不到。
放大量再大一点就不是这样了。把演示里的放大量拉到 1.3,再关掉“缩放原点跟手”,卡片会明显从手底下往外胀。所以这条是按需的:小幅抬起可以不管,一旦放大量上去、或者把手长在卡片边缘,原点就得钉在抓取点上。
顺带回答一个到这儿肯定会冒出来的疑问:transform-origin 改了,位移会不会跟着跑偏?不会。规范(CSS Transforms 2 §6)把矩阵的乘法顺序钉死了:
| 步 | 做什么 |
|---|---|
| 1 | 单位矩阵 |
| 2 | 按 transform-origin 平移 |
| 3 | 按 translate 平移 |
| 4 | 按 rotate 旋转 |
| 5 | 按 scale 缩放 |
| 6 | 按 offset 平移并旋转 |
| 7 | 从左到右乘 transform 里的每个函数 |
| 8 | 按 transform-origin 的负值平移回来 |
translate 是纯平移,和第 2、8 步那两次平移可交换,一乘一抵消——所以原点只影响旋转和缩放。这张表还顺手说明了另一件事:独立属性之间没有书写顺序,translate 和 scale 谁写在前面都一样,浏览器按上表算。不用再背 transform 里那串函数该怎么排。
最后:关掉动效之后还得能用
@media (prefers-reduced-motion: reduce) {
.item { transition: none; }
}关掉之后卡片仍然跟手、顺序仍然实时变,只是不再有滑行和放大。可见性和可用性一样都没依赖动画,这是任何动效都要守住的底线。
评论
评论加载中……