红色的心形 loading 特效,是很多人学 CSS3 动画时绕不开的一个小练习。它看起来只是“画个心再让它跳一下”,但真正动手做的时候,坑比想象中多——心形画出来是个方块、跳着跳着形状塌了、加了阴影结果影子是方形的、在 flex 里被挤成一条线。我从最早用三个 div 拼心形,到后来用两个伪元素搞定,中间反复改过好几版,也见过不少同学卡在同样的地方。这篇就把一个纯 CSS3 心形 loading 从几何原理到动画编排、再到封装成可复用组件、最后排错,完整拆一遍。适合刚接触 CSS3 transform 和 animation、想弄明白“为什么这么写”的人,也适合已经会写、但总在细节上翻车的朋友。全程不依赖任何第三方库,一个 HTML 标签就够了。
1. 一个纯 CSS 心形,难在哪
先说结论:心形本身不难,难的是“让它稳定地像心形”。CSS 不是画图工具,它没有路径概念,你能用的只有盒子、圆角、变换和伪元素。所以画心形本质上是用几个基本图形拼出一个视觉上成立的心,再靠一次旋转把方向调正。拼法一旦定了,后面所有的动画、配色、响应式都得围着这套拼接关系转,所以第一步的结构设计非常关键,改起来成本很高。
1.1 心形的几何本质:三块拼图加一次旋转
把心形拆开看,它其实就是两个圆加一个正方形。正方形旋转 45 度之后,三个角朝下变成一个尖角,剩下两个角分别被两个圆盖住,两个圆相交的地方自然形成心形顶部的那个凹槽。这个拼法之所以成立,是因为圆的半径和正方形边长相等的时候,圆和正方形的边能视觉上对齐,接缝处看不出明显断裂。
具体来说,设正方形边长为s:
- 正方形本体尺寸
s × s,整体旋转-45deg; - 第一个伪元素是一个直径
s的圆,圆心对准正方形上边的中点,也就是top: -s/2; left: 0; - 第二个伪元素也是直径
s的圆,圆心对准正方形右边的中点,也就是top: 0; left: s/2。
旋转之后,正方形原来右下角那个顶点被转到正下方,成为心尖;两个圆的圆弧在顶部交汇,形成凹陷。这套数值关系不需要调,只要边长统一,接缝就永远是对的。很多人画出来心形“歪了”或者“胖了”,几乎都是因为两个圆的直径和正方形边长不一致,比如圆写了120px但正方形是100px,接缝立刻裂开。
我把这个拼法理解成“三块同尺寸的拼图”:一块方的,两块圆的,方的那块转个角度,两块圆的往上和往右各贴一半。想清楚这层,后面所有的尺寸推导都变成同一个变量--size的倍数关系,改一个值全身跟着动。
1.2 为什么不用 SVG 或图片:场景决定方案
有人会问,画个心形用 SVG 一条 path 就解决了,为什么要费劲拼盒子?这个问题得分场景。SVG 的优势是形状精确、可缩放、能做描边动画,如果你的 loading 需要复杂形变、描边生长、路径跟随,SVG 是更合适的选择。但如果你要的是体积极小、零请求、可被 CSS 变量随时改尺寸和颜色、能复用现有主题变量的一个小动效,纯 CSS 方案的优势就出来了。
我实际用下来,纯 CSS 心形的几个实际好处:
- 没有额外资源请求,首屏 loading 场景下不会出现“loading 图还没加载出来”的尴尬;
- 颜色、尺寸、速度全部走 CSS 变量,换主题不用改结构;
- 动画是纯 transform 驱动,走合成层,性能开销极低;
- 挂到任何前端框架里都是一段 HTML 加一段 CSS,不需要引入组件库。
代价是形状的精度依赖数学关系,改比例时得重新算,而且做不了特别复杂的心形变体(比如心电图那种波形)。所以在选型阶段我的判断标准很简单:形状固定、只需缩放和呼吸动画,用 CSS;形状要变、要描边、要做序列动画,用 SVG。这篇讨论的是前者。
2. 从零搭出心形骨架
这一步的目标很明确:不加动画,先把一个静止的、红色实心的心形画出来,而且保证它在任何尺寸下都不变形。结构上只需要一个容器加一个形状元素,两个伪元素负责两个圆,HTML 里不要出现多余的标签,这是纯 CSS 方案的基本要求。
2.1 两个伪元素的定位数值推导
先定 HTML,只有一个元素承担形状:
<div class="heart-loader" role="status" aria-label="加载中"> <div class="heart-shape"></div> </div>外层的.heart-loader只负责留白和居中,因为心形旋转之后,伪元素会明显超出.heart-shape的盒子范围,如果不给它留足位置,视觉上会显得偏心,甚至被相邻元素压住。
关键的 CSS 是这样:
.heart-loader { --size: 96px; --color-main: #e8384f; display: inline-flex; align-items: center; justify-content: center; padding: calc(var(--size) * 0.55); box-sizing: content-box; } .heart-shape { position: relative; width: var(--size); height: var(--size); background: var(--color-main); transform: rotate(-45deg); } .heart-shape::before, .heart-shape::after { content: ""; position: absolute; width: var(--size); height: var(--size); background: var(--color-main); border-radius: 50%; } .heart-shape::before { top: calc(var(--size) * -0.5); left: 0; } .heart-shape::after { top: 0; left: calc(var(--size) * 0.5); }这里的核心是四个数值,我一个个说明为什么要这么写。
position: relative必须加在.heart-shape上,因为两个伪元素是position: absolute,脱离文档流之后的定位基准就是最近的已定位祖先。如果漏了这一步,伪元素会以整个页面或者别的已定位元素为基准,位置直接飘走,看起来就是“心形散架”了。
伪元素必须写content: "",这是伪元素生效的前提。content缺省时伪元素不生成,页面上什么都不会出现。这个坑后面还会专门讲。
两个伪元素的宽高都用var(--size),和本体保持一致,这是保证圆弧和方边能对齐的关键。border-radius: 50%把方形变成圆,百分比是相对自身宽高算的,所以只要宽高相等就一定是个正圆。
top: calc(var(--size) * -0.5)的意思是,把圆向上推半个身位,让它的圆心正好落在正方形上边的中点。同理left: calc(var(--size) * 0.5)是把第二个圆向右推半个身位。这里用calc加变量而不是硬编码数值,是为了后面换尺寸时只改一个变量。
注意:
calc里面乘负数时,写法是calc(var(--size) * -0.5),不能写成calc(var(--size) * 0.5 * -1),虽然结果一样,但前者可读性更好,也少一层解析。
2.2 旋转方向与视觉重心的校正
旋转角度是最容易写反的地方。这套拼法出来的原始图形,心尖是朝右上方的(正方形未旋转时,两个圆在上边和右边,交汇凹陷在右上),所以要把它转正,必须逆时针转 45 度,也就是rotate(-45deg)。如果写成rotate(45deg),得到的是一个倒过来的心,心尖朝上、凹陷朝下,看起来像个屁股。
写对角度之后,还有第二个问题:视觉重心。因为旋转是围绕元素的几何中心进行的,而心形的视觉重心比几何中心偏上(心尖部分细长,体积集中在上半部分),所以旋转完成后,心形在容器里会显得略微偏下。我第一次做的时候没注意,把它丢进一个按钮里,看起来就是“心往下掉了一截”。
修正办法有两个,看你的使用场景选:
| 修正方式 | 写法 | 适用场景 |
|---|---|---|
| 用容器补偿 | 外层加padding-bottom或translateY | 独立 loading 组件,位置无所谓,只是视觉居中 |
| 调整 transform-origin | transform-origin: 50% 60% | 需要精确对齐基线、和文字并排显示 |
| 用负 margin 拉回 | margin-top: calc(var(--size) * -0.1) | 快速修补,不想动结构 |
我一般用第一种,给外层容器多留一点下边距,因为 loading 组件通常单独占位,微调上下不影响别人。如果是内联在按钮里的小图标,用transform-origin更干净,不会影响布局流。
还有一点容易被忽略:旋转之后整颗心占的宽度其实比--size大。正方形对角线是s√2 ≈ 1.414s,加上两个圆往外各溢出半个身位,整体的实际渲染宽度接近1.9s。所以外层容器的padding我给了0.55 × size,两边加起来正好覆盖溢出部分,避免被兄弟元素挤到。
3. 心跳节奏的编排:不只是 scale 来回
骨架搭好之后,动画是第二阶段。很多人第一版写出来的效果是“匀速放大缩小”,看着像呼吸而不像心跳。心形的动画之所以要单独调,是因为人对“心跳”这个动作有很强的节奏预期:两下急促的搏动,然后是相对长的停顿。这个节奏感靠单一的ease-in-out是调不出来的,必须在关键帧分布上做文章。
3.1 双峰心跳曲线的关键帧分布
真实的心跳节律在视觉上可以简化成:一次较强的收缩、快速回落、一次较弱的收缩、然后是一个明显的舒张停顿。映射到scale上,就是两个峰值,中间有一个不完全回到 1 的低谷,末尾有一段静止。
@keyframes heart-pump { 0% { scale: 1; } 12% { scale: 1.14; } 26% { scale: 1; } 38% { scale: 1.10; } 56% { scale: 1; } 100% { scale: 1; } }这里有几个数值上的取舍值得说。
第一,两个峰值不是一样大的。第一个峰1.14,第二个峰1.10,这样更接近真实的双搏动感。如果两个峰一样大,看起来就变成机械的往复,少了那种“第二下弱一点”的自然感。
第二,两次跳动之间的间隔很短(12% 到 26% 只有 14% 的时长),但第二次跳动之后到 56% 才开始停。也就是说,收缩要快,舒张要慢,这个比例关系是心形动画像不像的关键。很多教程写的是对称的,效果明显差一截。
第三,56% 到 100% 是一段完全静止的保持。这段静止不能省,因为心跳的本质是“有间歇的重复”,没有静止段就是一坨持续抖动的红块。这段静止占比接近一半,视觉上才有“等一下再跳”的感觉。
配合的时间函数也有讲究。我试过linear(太生硬)、ease-in-out(有点软)、cubic-bezier(.25, .8, .35, 1)(前段冲得快、后段收得住),最后这个比较接近肌肉收缩的质感。整段动画时长用1.2s ~ 1.6s都合理,短于 1s 会显得焦虑,长于 2s 就显得懒散。我默认给1.4s。
3.2 transform 被覆盖:旋转与缩放的层级拆分
这是这条路上最经典的坑,也是我见过最多人卡住的地方。骨架阶段用了transform: rotate(-45deg),动画阶段又要用transform: scale(),如果直接写:
.heart-shape { transform: rotate(-45deg); animation: heart-pump 1.4s infinite; } @keyframes heart-pump { 0% { transform: scale(1); } 50% { transform: scale(1.14); } 100% { transform: scale(1); } }动画一跑起来,心形立刻变回一个正方形。原因很简单:transform是一个整体属性,动画关键帧里的transform会完全覆盖声明块里的transform,而不是叠加。于是旋转角度丢了,只剩缩放。
这个问题有三种解法,我按推荐程度排。
第一种,把旋转写进每一帧。最直接,也最不容易出错:
@keyframes heart-pump { 0% { transform: rotate(-45deg) scale(1); } 12% { transform: rotate(-45deg) scale(1.14); } 26% { transform: rotate(-45deg) scale(1); } 38% { transform: rotate(-45deg) scale(1.10); } 56% { transform: rotate(-45deg) scale(1); } 100% { transform: rotate(-45deg) scale(1); } }缺点是每帧都要重复一遍旋转,改角度时容易漏改。但优点是逻辑最直白,兼容性最好,所有浏览器都认。
第二种,拆层级。外层做旋转,内层做缩放。结构上多一个 div,但职责清晰:
<div class="heart-shape"> <div class="heart-pulse"></div> </div>.heart-shape { transform: rotate(-45deg); } .heart-pulse { width: 100%; height: 100%; background: inherit; animation: heart-pump 1.4s infinite; }不过一旦拆了层,伪元素是挂在.heart-shape上的,缩放的是内层,两个圆不会跟着放大,接缝立刻裂开。所以拆层方案必须把伪元素挪到内层元素上,等于整个形状的宿主换了一个。改起来比重写一遍还费劲,除非你一开始就设计成两层。
第三种,用独立变换属性。这是新写法,把旋转和缩放拆成两个互不干扰的 CSS 属性:
.heart-shape { rotate: -45deg; animation: heart-pump 1.4s cubic-bezier(.25, .8, .35, 1) infinite; } @keyframes heart-pump { 0%, 26%, 56%, 100% { scale: 1; } 12% { scale: 1.14; } 38% { scale: 1.10; } }rotate和scale是独立的变换属性,在动画里改scale不会影响rotate,写起来最干净。这套属性在近几年的主流浏览器里都支持得不错,但如果项目要兼容比较老的浏览器,还是老实选第一种。
注意:
rotate、scale、translate这些独立属性和transform混用时,叠加顺序是固定的(先独立属性,后transform),不要两套混着用,很容易算不清最终效果。
3.3 时间函数与停顿感的细微调校
关键帧定好了之后,剩下的就是微调。我一般会做三件事。
第一件,关掉动画的填充模式。animation-fill-mode默认是none,动画结束后元素回到初始状态。对于无限循环的 loading 来说,这个没影响,但如果你做的是一个“加载完成后播放一次心跳”的效果,就必须考虑forwards,否则动画结束瞬间会跳回原形。
第二件,检查animation-direction。alternate会让动画来回播放,看起来像是缩放正放一遍再倒放一遍,节奏会翻倍。心跳动画千万不要用alternate,就用默认的normal,靠关键帧本身来控制回落。
第三件,在低端设备上降级。心形动画本身开销很小,但如果页面里同时有几十个 loading 实例(比如列表里每一项都在加载),合成层压力会上来。我一般的做法是给 loading 容器限制数量,或者用animation-play-state: paused在不可见时停掉。
.heart-loader:not(.is-active) .heart-shape { animation-play-state: paused; }这一行加上之后,配合 IntersectionObserver 做可见性判断,列表页的 CPU 占用能降下来一大截。这个是实测有效的,不是理论。
4. 让红色有体积感:光影分层
纯色红心看起来是一块扁平的色块,缺少立体感,尤其在大尺寸下特别明显。想要有体积感,核心思路是模拟一个固定方向的光源,让靠近光的一侧亮、远离光的一侧暗。CSS 里能做这件事的工具就三个:box-shadow、linear-gradient、filter: drop-shadow()。但它们用在拼接心形上的效果差别非常大。
4.1 box-shadow 的轮廓陷阱与 drop-shadow 的替代
先说个反直觉的结论:给这个心形加box-shadow,得到的影子是方的。因为box-shadow作用的是元素的盒子轮廓,不是渲染后的形状。心形由三个盒子拼成,你给本体加阴影,得到的是正方形投影;你给伪元素加阴影,得到的是两个圆形投影。三者叠在一起,视觉上就是一个方块加两个圆饼,非常难看。
我最早就是踩在这个坑里,调了半天阴影参数,怎么看都不对,后来才反应过来是作用对象错了。正确的工具是filter: drop-shadow(),它作用的是元素渲染后的实际轮廓,包括所有伪元素合成之后的形状:
.heart-shape { filter: drop-shadow(0 0 calc(var(--size) * 0.12) rgba(232, 56, 79, 0.45)); }这行加上之后,心形周围会有一圈柔和的红光,形状完全贴合心形轮廓,没有方块边。这个差别在深色背景上尤其明显,是那种“一眼看上去专业”和“一眼看上去凑合”的区别。
需要注意的是,filter会创建新的层叠上下文和包含块,如果心形内部还有绝对定位的元素(比如我们后面要放的高光层),定位基准可能受影响。不过对我们这个结构来说,伪元素是相对.heart-shape定位的,加filter不影响。
还有一个细节:drop-shadow的模糊半径不要给太大。给到尺寸的0.12倍左右比较合适,再大就会糊成一团,失去“发光”的锐度。颜色透明度我一般控制在0.4到0.5之间,太浓会显得脏。
4.2 渐变的错位问题与纯色分层方案
第二个想做的事通常是给心形加渐变,让它从上到下有个明暗过渡。但这里有个物理限制:background不会从父元素继承到伪元素。你给.heart-shape设了linear-gradient,两个圆还是独立的盒子,它们的渐变从各自盒子的左上角开始算,和本体的渐变对不上,接缝处会出现一条明显的色带断层。
有人会想到background: inherit,这确实能让伪元素拿到父元素的背景声明,但渐变的计算基准仍然是伪元素自己的盒子,位置和尺寸都不同,结果还是错位。想要对齐,得手动调background-size和background-position,把三个盒子的渐变坐标系统一到同一个虚拟画布上。这套操作极其繁琐,而且每改一次尺寸就要重算一遍,维护成本很高。
我的实际方案是放弃渐变,用纯色打底加分层阴影做体积。具体做法:
.heart-shape, .heart-shape::before, .heart-shape::after { background: var(--color-main); } .heart-shape { box-shadow: inset 0 0 calc(var(--size) * 0.25) rgba(0, 0, 0, 0.16); } .heart-shape::before { box-shadow: inset calc(var(--size) * 0.06) calc(var(--size) * 0.06) calc(var(--size) * 0.2) rgba(0, 0, 0, 0.18); } .heart-shape::after { box-shadow: inset calc(var(--size) * 0.06) calc(var(--size) * 0.06) calc(var(--size) * 0.2) rgba(0, 0, 0, 0.14); }用inset内阴影的好处是它只在元素内部绘制,不会溢出到元素外,所以不会破坏心形的轮廓。三个盒子各自加一点内阴影,整体上会形成“边缘略暗、中间略亮”的错觉,加上drop-shadow的外发光,体积感就出来了。
这里要注意内阴影的方向。因为整体旋转了-45deg,伪元素的内阴影方向也跟着转了,所以两个圆的阴影方向和本体是统一的(都是从左上到右下),看起来像同一个光源。如果手动给不同的伪元素设不同方向,光就会被“打散”,立体感反而消失。
提示:内阴影的模糊值不要超过尺寸的
0.3倍,超过之后边缘会糊掉,心形的锐利感就没了。心形这种偏锐利的形状,边缘清晰比柔和更重要。
5. 封装成能反复用的 loading 组件
到这一步,视觉和动画都定下来了,接下来要考虑的是“怎么在多个页面里复用”。如果每次用都复制一大段 CSS,改一次配色要改十个地方,那就失去了封装的意义。CSS 变量是这里最合适的工具,因为它能在不改变结构的前提下把尺寸、配色、速度全部参数化。
5.1 用 CSS 变量把尺寸、配色、速度全部参数化
我把所有可变的值都提到外层容器上,形状元素只引用不定义:
.heart-loader { --size: 96px; --color-main: #e8384f; --color-glow: rgba(232, 56, 79, 0.45); --speed: 1.4s; --easing: cubic-bezier(.25, .8, .35, 1); }这么做的关键原因是自定义属性会沿 DOM 树继承。伪元素虽然不在 DOM 里,但它们在样式继承上是父元素的子节点,所以能读到--size和--color-main。这样一来,只需要在 HTML 上换个 class 或者写个内联 style,就能得到不同尺寸的实例:
<div class="heart-loader" style="--size: 48px; --speed: 1.2s;"></div> <div class="heart-loader heart-loader--small"></div>我一般会再准备几个预设:
.heart-loader--small { --size: 40px; --speed: 1.1s; } .heart-loader--large { --size: 140px; --speed: 1.7s; }这里有一个小细节:--size变小的时候,内阴影和drop-shadow的模糊值如果写的是固定像素,比例就失调了。所以我在所有阴影参数里都用calc(var(--size) * 系数),这样缩到 40px 时阴影也跟着缩,视觉比例保持一致。这个习惯是我在做过一次“同一个 loading 在三个尺寸下效果完全不同”之后养成的,后来所有基于变量的图形组件都这么写。
还有一点,--speed不只是时长,它其实隐含了节奏。尺寸大的时候动画应该放慢一点(因为同样的缩放比例,大尺寸的位移量更大),尺寸小的时候要快一点。所以我把--size和--speed捆绑在一起调,而不是让用户单独改其中一个。
5.2 挂到真实页面时的布局与显隐控制
loading 组件在实际项目里最常见的问题不是长得不好看,而是布局把它挤变形了。心形元素有固定尺寸,但在 flex 容器里,如果有多个子元素争抢空间,flex-shrink默认是 1,心形会被压扁,画出来的形状立刻失真。
.heart-loader { flex: 0 0 auto; flex-shrink: 0; }这两行基本能解决 90% 的挤压问题。如果是在 grid 里,要检查列宽是不是auto,固定尺寸的列不会压扁内容。
第二个常见问题是层级遮挡。因为伪元素会溢出.heart-shape的盒子,如果父容器有overflow: hidden(比如卡片、滚动容器、圆角按钮),心形的上半部分和右半部分会被裁掉,看起来就是“缺了一块”。
我的处理原则是:loading 组件外层不要有overflow: hidden。如果确实身处一个必须裁剪的容器里,就把padding加够,让溢出部分落在裁剪区之外。比如外层给padding: calc(var(--size) * 0.6),配合box-sizing: content-box,就能保证伪元素完全在容器内。
第三个问题是显隐控制。用display: none隐藏会让动画暂停,再显示时从头播放,这个行为通常是我们想要的。但如果用visibility: hidden或者opacity: 0,动画其实还在跑,白耗 CPU。所以切换显示状态时,优先用display或者配合animation-play-state: paused。
最后补一个可访问性的小处理。loading 是一个状态提示,不是装饰,屏幕阅读器应该能读到:
<div class="heart-loader" role="status" aria-live="polite"> <span class="visually-hidden">加载中</span> </div>.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }另外,对开启了“减少动态效果”的用户,动画应该降级:
@media (prefers-reduced-motion: reduce) { .heart-shape { animation: none; } }这时候静止的心形依然能表达“正在加载”,只是不动。这是我在无障碍审查里被提过的问题,后来成了固定动作。
6. 排查实录:心形塌陷、动画抽搐、颜色断裂
前面讲的是怎么把它做出来,这一节讲怎么写坏了之后怎么办。我把实际遇到过、也帮别人排查过的问题整理成一条排查链路,按照“症状 → 可能原因 → 验证方法”走,比直接给答案有用。
6.1 心形变成方块或散架的几种典型原因
症状一:完全看不到心形,页面上只有一个红色方块。
按顺序排查这几点。第一,伪元素有没有写content: "",这是最基础的,缺了它伪元素不渲染,只剩本体那个正方形。第二,伪元素有没有position: absolute,如果漏了,两个圆会按静态流排在正方形下面,页面变成“一个方块加两个圆”的奇怪组合。第三,border-radius是不是写成了50(少了百分号)或者50px,前者无效,后者在尺寸大于 100px 时就不够圆。第四,父元素.heart-shape有没有position: relative,没有的话绝对定位的基准就跑了。
症状二:心形只有一半,或者上边/右边被切平了。
这个几乎一定是overflow: hidden造成的。因为伪元素的圆有一半在父盒子外面,任何裁剪都会把它们切掉。排查方法很简单,在开发者工具里把可疑祖先元素的overflow改成visible试一下,形状马上恢复就说明是它。
症状三:心尖朝上,看起来是个倒置的心。
旋转角度写反了。这套拼法必须是-45deg,写成45deg就是倒的。这个错误特别容易发生在从别的教程里抄代码的时候,因为有些教程的拼法是两个圆放在上边和左边,配合45deg也能得到正心形。拼法和角度是绑定的,不能拆开抄。
症状四:心形整体偏移,不在容器中间。
旋转会改变元素的视觉位置,因为旋转是绕transform-origin(默认是元素中心)进行的,而心形的视觉重心不等于几何中心。前面讲过,用容器 padding 或者调整transform-origin补偿。
症状五:动画一跑,心形变回方块。
这就是transform被关键帧覆盖的经典问题,回到 3.2 节的三种解法。
这五条我建议做成一个清单,新写一个心形的时候过一遍,能省掉大量调试时间。
6.2 动画卡顿与抖动的定位路径
loading 动画出现卡顿,排查思路是从“谁在重绘”入手。
第一步,在开发者工具的性能面板里录一段,看合成层的情况。如果心形元素有独立的合成层(通常由transform、filter、opacity触发),帧率应该很稳。如果发现每一帧都在重绘,多半是动画里改了会触发重排或重绘的属性,比如width、height、box-shadow、background。心跳动画只应该改scale或者transform,其他属性一律不动。
第二步,检查有没有在动画元素上加了filter: drop-shadow()同时又让scale动画。filter加transform的组合在部分设备上会走 CPU 合成,帧率会掉。如果确实卡,可以把drop-shadow换成静态的、不参与动画的光晕层,或者干脆去掉发光只保留纯色。
第三步,看页面里有多少个 loading 实例。如果是列表页每项一个,几十个元素同时跑无限动画,即使每个都很轻,加起来也会拖垮主线程。这种情况要么限制数量,要么用animation-play-state配合可见性检测暂停不可见的实例。
还有一种“抖动”不是性能问题,而是视觉抖动:心形在缩放时边缘出现锯齿或者闪烁。原因是缩放边界正好落在像素格的中间,浏览器做抗锯齿时产生了位移感。解决办法是给元素加will-change: transform提前提升为合成层,或者把尺寸设成偶数像素,减少半像素渲染。
注意:
will-change不要滥用。给几十个元素都加will-change: transform会占用大量显存,反而更卡。只给当前活跃的那一两个实例加,动画结束后移除。
6.3 移动端与高倍屏下的细节偏差
桌面调好的效果,到了手机上经常有肉眼可见的差异。
第一类是尺寸偏差。移动端的视口设置如果不正确,px和 CSS 像素的映射会变,心形看起来会偏小或偏大。建议尺寸用px而不是vw,因为 loading 组件需要稳定的视觉大小,跟着视口缩放没有意义。如果确实要做响应式,在媒体查询里改--size变量就可以。
第二类是动画时序差异。部分移动端浏览器在省电模式下会降低动画帧率,甚至暂停后台标签页的动画。这种情况下animation会跳帧,心跳看起来一顿一顿的。处理办法是接受它,或者用更长的周期减少顿挫感。不要在省电模式下降级成静态图,因为用户会以为页面卡死了。
第三类是高倍屏的渲染精度。在 2 倍、3 倍屏上,如果尺寸是奇数像素,缩放动画的边缘会出现轻微模糊。我的习惯是把--size设成 4 的倍数,比如 40、48、96、140,这样在各种缩放比下都能落在相对整齐的像素格上。这个细节不明显,但在深色背景配亮色心形的时候,边缘的模糊感是能看出来的。
第四类是圆角比例在小尺寸下失真。同样是border-radius: 50%,尺寸 40px 的时候两个圆的弧度视觉上和 96px 时不太一样,因为内阴影的模糊半径占比变大了。前面说过用calc按比例算阴影,就是为了解决这个。改完之后的验证方法是把三个尺寸并排放在一起截图,看看形状是不是同一颗心的等比缩放。
最后分享一个我自己觉得挺有用的小技巧:调试心形的时候,给.heart-shape临时加一个outline: 1px dashed #999,同时给伪元素各加一个不同颜色的半透明背景,一眼就能看出三个盒子的相对位置有没有错。等形状调好之后把这几行删掉。这比反复在脑子里推演坐标快得多,尤其是接缝对不齐的时候,加个底色立刻就能看出是圆心偏了还是直径不对。