CSS 动画和过渡很棒! 然而,最近在玩一个想法的时候,我真的很沮丧,因为渐变只能在 Edge(以及 IE 10+)中进行动画处理。 是的,我们可以使用 各种技巧,使用 background-position
、background-size
、background-blend-mode
甚至 opacity
和 transform
来处理伪元素/子元素,但有时这些方法还不够。 更不用说,当我们想要在没有 CSS 对应项的情况下对 SVG 属性进行动画处理时,我们也会遇到类似的问题。
本文将通过大量示例来解释如何使用 JavaScript 以类似于常见 CSS 定时函数的方式从一种状态平滑地过渡到另一种状态,而无需依赖任何库,因此无需包含大量复杂且不必要的代码,这些代码将来可能会成为很大的负担。
这不是 CSS 定时函数的工作原理。 我发现这种方法比使用贝塞尔曲线更简单、更直观。 我将展示如何使用 JavaScript 实验不同的定时函数,并剖析用例。 这不是关于如何制作精美动画的教程。
linear
定时函数的示例
一些使用 让我们从一个从左到右的 linear-gradient()
开始,它具有急剧过渡,我们想要对第一个停止点进行动画处理。 以下是如何使用 CSS 自定义属性来表达这一点。
background: linear-gradient(90deg, #ff9800 var(--stop, 0%), #3c3c3c 0);
单击时,我们希望此停止点的值在 NF
帧内从 0%
变为 100%
(或反过来,取决于其当前状态)。 如果在单击时动画正在运行,我们停止它,更改其方向,然后重新启动它。
我们还需要一些变量,例如请求 ID(由 requestAnimationFrame
返回)、当前帧的索引([0, NF]
区间内的整数,从 0
开始)以及我们的过渡方向(当向 100%
过渡时为 1
,当向 0%
过渡时为 -1
)。
在没有任何变化的情况下,请求 ID 为 null
。 我们还将初始当前帧索引设置为 0
,将方向设置为 -1
,就好像我们刚刚从 100%
到达 0%
一样。
const NF = 80; // number of frames transition happens over
let rID = null, f = 0, dir = -1;
function stopAni() {
cancelAnimationFrame(rID);
rID = null;
};
function update() {};
addEventListener('click', e => {
if(rID) stopAni(); // if an animation is already running, stop it
dir *= -1; // change animation direction
update();
}, false);
现在剩下的就是填充 update()
函数。 在函数中,我们更新当前帧索引 f
。 然后,我们将进度变量 k
计算为当前帧索引 f
与总帧数 NF
之间的比率。 鉴于 f
从 0
到 NF
(包含),这意味着我们的进度 k
从 0
到 1
。 将其乘以 100%
,我们得到所需的停止点。
在此之后,我们检查是否已达到其中一个结束状态。 如果已达到,我们将停止动画并退出 update()
函数。
function update() {
f += dir; // update current frame index
let k = f/NF; // compute progress
document.body.style.setProperty('--stop', `${+(k*100).toFixed(2)}%`);
if(!(f%NF)) {
stopAni();
return
}
rID = requestAnimationFrame(update)
};
结果可以在下面的 Pen 中看到(请注意,我们在第二次单击时返回)。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
伪元素与下方 background
形成对比的方式在 一篇较早的文章 中有解释。
上面的演示看起来就像我们可以轻松地使用元素和翻译一个可以完全覆盖它的伪元素来实现,但是如果我们将 background-size
的值设置为小于 100%
(沿 x
轴),例如 5em
,情况就会变得更加有趣。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
这给了我们一种类似于“百叶窗”的效果,如果我们不想使用多个元素,就无法用纯 CSS 以简洁的方式复制。
另一个选择是不改变方向,而是始终从左到右扫过,只是奇数次扫过是橙色。 这需要稍微调整一下 CSS。
--c0: #ff9800;
--c1: #3c3c3c;
background: linear-gradient(90deg,
var(--gc0, var(--c0)) var(--stop, 0%),
var(--gc1, var(--c1)) 0)
在 JavaScript 中,我们放弃了 direction 变量,并添加了一个 type 变量 (typ
),它在每次过渡结束时在 0
和 1
之间切换。 此时,我们还会更新所有自定义属性。
const S = document.body.style;
let typ = 0;
function update() {
let k = ++f/NF;
S.setProperty('--stop', `${+(k*100).toFixed(2)}%`);
if(!(f%NF)) {
f = 0;
S.setProperty('--gc1', `var(--c${typ})`);
typ = 1 - typ;
S.setProperty('--gc0', `var(--c${typ})`);
S.setProperty('--stop', `0%`);
stopAni();
return
}
rID = requestAnimationFrame(update)
};
这给了我们想要的结果(至少单击两次以查看效果与第一个演示中的区别)。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
我们也可以更改渐变角度而不是停止点。 在这种情况下,background
规则变为
background: linear-gradient(var(--angle, 0deg),
#ff9800 50%, #3c3c3c 0);
在 JavaScript 代码中,我们调整了 update()
函数。
function update() {
f += dir;
let k = f/NF;
document.body.style.setProperty(
'--angle',
`${+(k*180).toFixed(2)}deg`
);
if(!(f%NF)) {
stopAni();
return
}
rID = requestAnimationFrame(update)
};
现在,我们有了两种状态之间(0deg
和 180deg
)的渐变角度过渡。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
在这种情况下,我们可能还想继续顺时针旋转以返回到 0deg
状态,而不是改变方向。 因此,我们完全放弃了 dir
变量,丢弃了在过渡期间发生的任何单击事件,并始终递增帧索引 f
,当我们完成绕圆的完整旋转时将其重置为 0
。
function update() {
let k = ++f/NF;
document.body.style.setProperty(
'--angle',
`${+(k*180).toFixed(2)}deg`
);
if(!(f%NF)) {
f = f%(2*NF);
stopAni();
return
}
rID = requestAnimationFrame(update)
};
addEventListener('click', e => {
if(!rID) update()
}, false);
下面的 Pen 说明了结果 - 我们的旋转现在始终是顺时针的。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
我们还可以做的是使用 radial-gradient()
并对径向停止点进行动画处理。
background: radial-gradient(circle,
#ff9800 var(--stop, 0%), #3c3c3c 0);
JavaScript 代码与第一个演示的代码相同,结果可以在下面看到。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
我们可能也不想在再次单击时返回,而是让另一个圆点生长并覆盖整个视窗。 在这种情况下,我们在 CSS 中添加了一些额外的自定义属性。
--c0: #ff9800;
--c1: #3c3c3c;
background: radial-gradient(circle,
var(--gc0, var(--c0)) var(--stop, 0%),
var(--gc1, var(--c1)) 0)
JavaScript 代码与第三个 linear-gradient()
演示中的代码相同。 这给了我们想要的结果。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
一个有趣的调整是让我们的圆点从我们单击的点开始生长。 为此,我们引入了两个额外的自定义属性,--x
和 --y
。
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
var(--gc0, var(--c0)) var(--stop, 0%),
var(--gc1, var(--c1)) 0)
单击时,我们将这些属性设置为单击事件发生点的坐标。
addEventListener('click', e => {
if(!rID) {
S.setProperty('--x', `${e.clientX}px`);
S.setProperty('--y', `${e.clientY}px`);
update();
}
}, false);
这给了我们以下结果,其中一个圆点从我们单击的点开始生长。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
另一个选择是使用 conic-gradient()
并对角度停止点进行动画处理。
background: conic-gradient(#ff9800 var(--stop, 0%), #3c3c3c 0%)
请注意,**在 conic-gradient()
的情况下,我们必须为零值使用单位**(无论是 %
还是角度单位,例如 deg
,都没有关系),否则我们的代码将无法工作 - 写入 conic-gradient(#ff9800 var(--stop, 0%), #3c3c3c 0)
意味着不会显示任何内容。
JavaScript 代码与线性或径向情况下的动画停止点代码相同,但请记住,目前这仅在 Chrome 中有效,并且需要在 chrome://flags
中启用实验性 Web 平台功能。

仅仅为了在浏览器中显示圆锥渐变,Lea Verou 提供了一个 polyfill,它可以跨浏览器工作,但不允许使用 CSS 自定义属性。
下面的录制说明了我们的代码如何工作。

conic-gradient()
演示在 Chrome 中启用标志后的工作方式的录制 (实时演示)。这是我们可能不想在第二次单击时返回的另一种情况。 这意味着我们需要稍微改变一下 CSS,就像我们对最后一个 radial-gradient()
演示所做的那样。
--c0: #ff9800;
--c1: #3c3c3c;
background: conic-gradient(
var(--gc0, var(--c0)) var(--stop, 0%),
var(--gc1, var(--c1)) 0%)
JavaScript 代码与相应的 linear-gradient()
或 radial-gradient()
情况下的代码完全相同,结果可以在下面看到。

conic-gradient()
演示在 Chrome 中启用标志后的工作方式的录制 (实时演示)。在我们继续讨论其他定时函数之前,还有一件事需要说明:当我们不从 0%
到 100%
过渡,而是在任何两个值之间过渡时的情况。 我们以第一个 linear-gradient
为例,但 --stop
的默认值不同,例如 85%
,我们还设置了 --stop-fin
值 - 这将是 --stop
的最终值。
--stop-ini: 85%;
--stop-fin: 26%;
background: linear-gradient(90deg, #ff9800 var(--stop, var(--stop-ini)), #3c3c3c 0)
在 JavaScript 中,我们读取这两个值 - 初始值(默认值)和最终值 - 并计算它们之间的范围。
const S = getComputedStyle(document.body),
INI = +S.getPropertyValue('--stop-ini').replace('%', ''),
FIN = +S.getPropertyValue('--stop-fin').replace('%', ''),
RANGE = FIN - INI;
最后,在 update()
函数中,我们在设置 --stop
的当前值时考虑了初始值和范围。
document.body.style.setProperty(
'--stop',
`${+(INI + k*RANGE).toFixed(2)}%`
);
有了这些更改,我们现在有了 85%
和 26%
之间的过渡(以及偶数次单击时的反向过渡)。
查看 thebabydino 的 Pen (@thebabydino) 在 CodePen 上。
如果我们想要混合停止值的单位,事情就会变得更加复杂,因为我们需要计算更多内容(混合 %
和 px
时的框尺寸,如果我们混合了 em
或 rem
,则需要计算字体大小,如果我们想要使用视窗单位,则需要计算视窗尺寸,对于非水平或垂直渐变,则需要计算渐变线上的 0%
到 100%
段的长度),但基本思路保持不变。
ease-in
/ ease-out
模拟 一种 ease-in
型函数意味着值的变化最初很慢,然后加速。ease-out
正好相反 - 变化在开始时很快,但在结束时减速。
ease-in
(左) 和 ease-out
(右) 定时函数 (实时).上面曲线斜率告诉我们变化率。斜率越陡,值的变化越快。
我们可以通过调整第一部分描述的线性方法来模拟这些函数。由于 k
在 [0, 1]
区间内取值,将其提高到任何正数幂也会给我们一个在同一个区间内的数字。下面的交互式演示显示了函数 f(k) = pow(k, p)
(k
提高到指数 p
) 的图形(以紫色显示)以及函数 g(k) = 1 - pow(1 - k, p)
的图形(以红色显示)在 [0, 1]
区间内与恒等函数 id(k) = k
(对应于 linear
定时函数) 的对比。
请查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
当指数 p
等于 1
时,f
和 g
函数的图形与恒等函数的图形相同。
当指数 p
大于 1
时,f
函数的图形位于恒等线下方 - 变化率随着 k
的增加而增加。这就像一个 ease-in
型函数。g
函数的图形位于恒等线上方 - 变化率随着 k
的增加而减少。这就像一个 ease-out
型函数。
看起来指数 p
大约为 2
可以给我们一个与 ease-in
非常相似的 f
,而 g
与 ease-out
非常相似。经过更多调整,看起来最佳近似值是 p
值约为 1.675
。
请查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
在这个交互式演示中,我们希望 f
和 g
函数的图形尽可能接近虚线,虚线代表 ease-in
定时函数(位于恒等线下方)和 ease-out
定时函数(位于恒等线上方)。
ease-in-out
模拟 CSS ease-in-out
定时函数在下面的插图中看起来像这样
ease-in-out
定时函数 (实时).那么我们如何才能得到类似的东西呢?
好吧,这就是谐波函数的作用!更准确地说,ease-in-out
的形状让人联想起 sin()
函数在 [-90°,90°]
区间内的形状。
sin(k)
函数在 [-90°,90°]
区间内 (实时).但是,我们不想要一个输入在 [-90°,90°]
区间内,输出在 [-1,1]
区间内的函数,所以让我们来解决这个问题!
这意味着我们需要将上面的插图中的网格矩形 ([-90°,90°]x[-1,1]
) 压缩成单位矩形 ([0,1]x[0,1]
)。
首先,让我们看看定义域 [-90°,90°]
。如果我们将函数改为 sin(k·180°)
(或以弧度表示的 sin(k·π)
),那么我们的定义域就变成了 [-.5,.5]
(我们可以检查 -.5·180° = 90°
和 .5·180° = 90°
)
sin(k·π)
函数在 [-.5,.5]
区间内 (实时).我们可以将这个定义域向右移动 .5
并得到所需的 [0,1]
区间,如果我们将函数改为 sin((k - .5)·π)
(我们可以检查 0 - .5 = -.5
和 1 - .5 = .5
)
sin((k - .5)·π)
函数在 [0,1]
区间内 (实时).现在让我们得到所需的陪域。如果我们将函数加上 1
,使其成为 sin((k - .5)·π) + 1
,这将使我们的陪域向上移动到 [0, 2]
区间
sin((k - .5)·π) + 1
函数在 [0,1]
区间内 (实时).将所有内容除以 2
会给我们 (sin((k - .5)·π) + 1)/2
函数,并将陪域压缩成我们所需的 [0,1]
区间
(sin((k - .5)·π) + 1)/2
函数在 [0,1]
区间内 (实时).这恰好是 ease-in-out
定时函数的一个很好的近似值(在上面的插图中用橙色虚线表示)。
所有这些定时函数的比较
假设我们想要有一堆带有 linear-gradient()
的元素(就像第三个演示中一样)。单击时,它们的 --stop
值从 0%
变为 100%
,但每个元素使用不同的定时函数。
在 JavaScript 中,我们创建了一个定时函数对象,其中包含每种类型的缓动效果的相应函数
tfn = {
'linear': function(k) {
return k;
},
'ease-in': function(k) {
return Math.pow(k, 1.675);
},
'ease-out': function(k) {
return 1 - Math.pow(1 - k, 1.675);
},
'ease-in-out': function(k) {
return .5*(Math.sin((k - .5)*Math.PI) + 1);
}
};
对于每个元素,我们创建一个 article
元素
const _ART = [];
let frag = document.createDocumentFragment();
for(let p in tfn) {
let art = document.createElement('article'),
hd = document.createElement('h3');
hd.textContent = p;
art.appendChild(hd);
art.setAttribute('id', p);
_ART.push(art);
frag.appendChild(art);
}
n = _ART.length;
document.body.appendChild(frag);
update
函数几乎相同,只是我们将每个元素的 --stop
自定义属性设置为相应定时函数在输入当前进度 k
时返回的值。此外,当在动画结束时将 --stop
重置为 0%
时,我们还需要对每个元素进行此操作。
function update() {
let k = ++f/NF;
for(let i = 0; i < n; i++) {
_ART[i].style.setProperty(
'--stop',
`${+tfn[_ART[i].id](k).toFixed(5)*100}%`
);
}
if(!(f%NF)) {
f = 0;
S.setProperty('--gc1', `var(--c${typ})`);
typ = 1 - typ;
S.setProperty('--gc0', `var(--c${typ})`);
for(let i = 0; i < n; i++)
_ART[i].style.setProperty('--stop', `0%`);
stopAni();
return;
}
rID = requestAnimationFrame(update)
};
这给了我们一个很好的视觉比较这些定时函数
请查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
它们都同时开始和结束,但对于线性动画来说,进度是恒定的,而 ease-in 动画一开始很慢然后加速,ease-out 动画一开始很快然后减速,最后,ease-in-out 动画一开始很慢,然后加速,最后在结束时再次减速。
用于弹跳过渡的定时函数
我第一次接触到这个概念是在几年前,在 Lea Verou 的 CSS Secrets 演讲 中。当 cubic-bezier()
函数中的 y
(偶数) 值位于 [0, 1]
范围之外时,就会发生这种情况,它们创建的效果是动画值超出其初始值和最终值之间的区间。
这种弹跳可能发生在过渡开始后,结束前,或者在两端。
在开始时的弹跳意味着,一开始,我们不会朝最终状态移动,而是朝相反的方向移动。例如,如果我们想将一个停止点从 43%
动画到 57%
,并且我们在开始时有弹跳,那么一开始,我们的停止点值不会增加到 57%
,而是会下降到 43%
之下,然后再回到最终状态。同样,如果我们从 57%
的初始停止点值移动到 43%
的最终停止点值,并且我们在开始时有弹跳,那么一开始,停止点值会增加到 57%
以上,然后再下降到最终值。
在结束时的弹跳意味着我们会超过最终状态,然后才回到最终状态。如果我们想将一个停止点从 43%
动画到 57%
,并且我们在结束时有弹跳,那么我们从初始状态正常移动到最终状态,但快到结束时,我们会超过 57%
,然后再降回到最终状态。如果我们从 57%
的初始停止点值移动到 43%
的最终停止点值,并且我们在结束时有弹跳,那么一开始,我们会下降到最终状态,但快到结束时,我们会经过它,并且我们的停止点值会短暂地低于 43%
,然后我们的过渡在那里结束。
如果它们的作用仍然难以理解,下面有一个比较示例,展示了这三种情况的实际效果。

这些类型的定时函数没有与之相关的关键字,但它们看起来很酷,而且是我们在很多情况下都想要的。
就像 ease-in-out
的情况一样,获得它们的最快方法是使用谐波函数。不同之处在于现在我们不再从 [-90°,90°]
定义域开始。
对于开始时的弹跳,我们从 sin()
函数的 [s, 0°]
部分开始,其中 s
(开始角度) 位于 (-180°,-90°)
区间内。它越接近 -180°
,弹跳越大,并且弹跳后它会越快地移动到最终状态。所以我们不希望它离 -180°
太近,因为结果看起来会太不自然。我们也希望它离 -90°
足够远,以便弹跳是显而易见的。
在下面的交互式演示中,你可以拖动滑块来更改开始角度,然后单击底部的条带查看实际效果。
请查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
在上面的交互式演示中,阴影区域([s,0]x[sin(s),0]
)是我们需要移动和缩放至[0,1]x[0,1]
区域的区域,以便获得我们的定时函数。曲线低于其下边缘的部分是弹跳发生的地方。您可以使用滑块调整起始角度,然后单击底部的栏查看不同起始角度下的过渡效果。
就像在ease-in-out
情况下一样,我们首先通过用范围(最大值0
减去最小值s
)除以参数,将域压缩到[-1,0]
区间。因此,我们的函数变为sin(-k·s)
(我们可以验证-(-1)·s = s
和-0·s = 0
)。
sin(-k·s)
函数在[-1,0]
区间上的效果 (实时查看).接下来,我们将该区间向右平移(平移1
个单位,至[0,1]
)。这使得我们的函数变为sin(-(k - 1)·s) = sin((1 - k)·s)
(我们可以验证0 - 1 = -1
和1 - 1 = 0
)。
sin(-(k - 1)·s)
函数在[0,1]
区间上的效果 (实时查看).然后,我们将值域向上平移0
处的函数值(sin((1 - 0)*s) = sin(s)
)。现在我们的函数变为sin((1 - k)·s) - sin(s)
,值域为[0,-sin(s)]
。
sin(-(k - 1)·s) - sin(s)
函数在[0,1]
区间上的效果 (实时查看).最后一步是将值域扩展到[0,1]
范围。我们通过除以其上限(即-sin(s)
)来实现这一点。这意味着我们的最终缓动函数是1 - sin((1 - k)·s)/sin(s)
。
1 - sin((1 - k)·s)/sin(s)
函数在[0,1]
区间上的效果 (实时查看).为了在末尾实现弹跳效果,我们从sin()
函数的[0°, e]
部分开始,其中e
(结束角度)位于(90°,180°)
区间内。它越接近180°
,弹跳越大,它从初始状态移动到最终状态的速度就越快,然后它会超出最终状态并发生弹跳。因此,我们不希望它非常接近180°
,因为结果看起来会不自然。我们还希望它远离90°
,以便弹跳明显。
查看thebabydino (@thebabydino) 在CodePen上的演示。
在上面的交互式演示中,阴影区域([0,e]x[0,sin(e)]
)是我们需要压缩和移动到[0,1]x[0,1]
正方形的区域,以便获得我们的定时函数。曲线低于其上边缘的部分是弹跳发生的地方。
我们首先通过用范围(最大值e
减去最小值0
)除以参数,将域压缩到[0,1]
区间。因此,我们的函数变为sin(k·e)
(我们可以验证0·e = 0
和1·e = e
)。
sin(k·e)
函数在[0,1]
区间上的效果 (实时查看).剩下要做的就是将值域扩展到[0,1]
范围。我们通过除以其上限(即sin(e)
)来实现这一点。这意味着我们的最终缓动函数是sin(k·e)/sin(e)
。
sin(k·e)/sin(e)
函数在[0,1]
区间上的效果 (实时查看).如果我们希望在两端都出现弹跳效果,我们从sin()
函数的[s, e]
部分开始,其中s
位于(-180°,-90°)
区间内,e
位于(90°,180°)
区间内。s
和e
的绝对值越大,相应的弹跳就越大,在它们单独进行的整个过渡时间中所花费的时间就越多。另一方面,它们的绝对值越接近90°
,相应的弹跳就越不明显。因此,就像前面两种情况一样,关键是找到合适的平衡。
查看thebabydino (@thebabydino) 在CodePen上的演示。
在上面的交互式演示中,阴影区域([s,e]x[sin(s),sin(e)]
)是我们需要移动和缩放至[0,1]x[0,1]
正方形的区域,以便获得我们的定时函数。曲线超出其水平边缘的部分是弹跳发生的地方。
我们首先将域向右平移至[0,e - s]
区间。这意味着我们的函数变为sin(k + s)
(我们可以验证0 + s = s
以及e - s + s = e
)。
sin(k + s)
函数在[0,e - s]
区间上的效果 (实时查看).然后,我们将域缩小以适应[0,1]
区间,这将使我们的函数变为sin(k·(e - s) + s)
。
sin(k·(e - s) + s)
函数在[0,1]
区间上的效果 (实时查看).继续讨论值域,我们首先将其向上平移0
处的函数值(sin(0·(e - s) + s)
),这意味着现在我们有了sin(k·(e - s) + s) - sin(s)
。这给了我们新的值域[0,sin(e) - sin(s)]
。
sin(k·(e - s) + s) - sin(s)
函数在[0,1]
区间上的效果 (实时查看).最后,我们通过除以范围(sin(e) - sin(s)
)将值域缩小到[0,1]
区间,因此我们的最终函数是(sin(k·(e - s) + s) - sin(s))/(sin(e - sin(s))
。
(sin(k·(e - s) + s) - sin(s))/(sin(e - sin(s))
函数在[0,1]
区间上的效果 (实时查看).因此,为了对 CSS 等效于 CSS linear
、ease-in
、ease-out
、ease-in-out
的 JS 进行类似的比较演示,我们的定时函数对象变为
tfn = {
'bounce-ini': function(k) {
return 1 - Math.sin((1 - k)*s)/Math.sin(s);
},
'bounce-fin': function(k) {
return Math.sin(k*e)/Math.sin(e);
},
'bounce-ini-fin': function(k) {
return (Math.sin(k*(e - s) + s) - Math.sin(s))/(Math.sin(e) - Math.sin(s));
}
};
s
和e
变量是我们从两个范围输入中获得的值,它们使我们能够控制弹跳量。
下面的交互式演示显示了这三种类型定时函数的视觉比较
查看thebabydino (@thebabydino) 在CodePen上的演示。
交替动画
在 CSS 中,将animation-direction
设置为alternate
也会反转定时函数。为了更好地理解这一点,考虑一个.box
元素,我们对其进行动画处理,使其transform
属性发生变化,从而使其向右移动。这意味着我们的@keyframes
如下所示
@keyframes shift {
0%, 10% { transform: none }
90%, 100% { transform: translate(50vw) }
}
我们使用自定义定时函数,它使我们能够在末尾实现弹跳效果,并使此动画交替进行,即对于偶数次迭代(第二次、第四次等),从最终状态(translate(50vw)
)回到初始状态(无平移)。
animation: shift 1s cubic-bezier(.5, 1, .75, 1.5) infinite alternate
结果如下所示
查看thebabydino (@thebabydino) 在CodePen上的演示。
这里要注意的一点是,对于偶数次迭代,我们的弹跳不会发生在末尾,而是在开头,定时函数被反转。从视觉上看,这意味着它相对于.5,.5
点在水平和垂直方向上都被反射。
在 CSS 中,如果我们要使用这组关键帧和animation-direction: alternate
,则无法在返回时使用与对称函数不同的定时函数。我们可以将返回部分引入关键帧,并控制animation
每个阶段的定时函数,但这超出了本文的范围。
当使用本文迄今为止介绍的方式使用 JavaScript 更改值时,默认情况下也会发生相同的事情。考虑一下我们想要对linear-gradient()
的停止点进行动画处理,使其在初始位置和最终位置之间移动,并且我们希望在末尾实现弹跳效果。这与第一节中介绍的最后一个示例非常相似,该示例使用定时函数来实现末尾的弹跳(即之前描述的bounce-fin
类别中的一个),而不是linear
定时函数。
CSS 代码完全相同,我们只需对 JavaScript 代码进行一些细微的更改。我们设置一个限制角度E
,并将自定义bounce-fin
类型的定时函数用作线性定时函数的替代
const E = .75*Math.PI;
/* same as before */
function timing(k) {
return Math.sin(k*E)/Math.sin(E)
};
function update() {
/* same as before */
document.body.style.setProperty(
'--stop',
`${+(INI + timing(k)*RANGE).toFixed(2)}%`
);
/* same as before */
};
/* same as before */
结果如下所示
查看thebabydino (@thebabydino) 在CodePen上的演示。
在初始状态下,停止点位于85%
处。我们使用一个在末尾产生弹跳效果的定时函数将其动画处理到26%
(最终状态)。这意味着我们会在返回并停止在最终停止位置26%
处之前超过最终停止位置26%
。这就是在奇数次迭代期间发生的事情。
在偶数次迭代期间,这就像在 CSS 情况下一样,反转定时函数,使得弹跳发生在开头,而不是末尾。
但是,如果我们不希望定时函数被反转呢?
在这种情况下,我们需要使用对称函数。对于在[0,1]
区间(这是域)上定义的任何定时函数f(k)
,其值位于[0,1]
(值域)内,我们想要的对称函数是1 - f(1 - k)
。请注意,形状实际上相对于.5,.5
点对称的函数(如linear
或ease-in-out
)与其对称函数相同。
查看thebabydino (@thebabydino) 在CodePen上的演示。
因此,我们要做的是在奇数次迭代中使用我们的定时函数f(k)
,并在偶数次迭代中使用1 - f(1 - k)
。我们可以从方向(dir
)变量中判断迭代是奇数还是偶数。对于奇数次迭代,它是1
,对于偶数次迭代,它是-1
。
这意味着我们可以将两个定时函数组合成一个:m + dir*f(m + dir*k)
。
这里,乘数m
对于奇数次迭代(当dir
为1
时)为0
,对于偶数次迭代(当dir
为-1
时)为1
,所以我们可以用.5*(1 - dir)
来计算它。
dir = +1 → m = .5*(1 - (+1)) = .5*(1 - 1) = .5*0 = 0
dir = -1 → m = .5*(1 - (-1)) = .5*(1 + 1) = .5*2 = 1
这样,我们的 JavaScript 代码就变成了
let m;
/* same as before */
function update() {
/* same as before */
document.body.style.setProperty(
'--stop',
`${+(INI + (m + dir*timing(m + dir*k))*RANGE).toFixed(2)}%`
);
/* same as before */
};
addEventListener('click', e => {
if(rID) stopAni();
dir *= -1;
m = .5*(1 - dir);
update();
}, false);
最终结果可以在这个 Pen 中看到
查看 thebabydino 在 CodePen 上创建的 Pen(@thebabydino)。
更多示例
渐变色停止点并不是唯一不能跨浏览器仅用 CSS 动画的东西。
渐变色结束从橙色到紫色的过渡
举个不同的例子,假设我们想要让渐变色中的橙色动画过渡到一种紫色。我们从类似于以下 CSS 代码开始
--c-ini: #ff9800;
--c-fin: #a048b9;
background: linear-gradient(90deg,
var(--c, var(--c-ini)), #3c3c3c)
为了在初始值和最终值之间插值,我们需要知道通过 JavaScript 读取它们时得到的格式——它是否与我们设置时的格式相同?它是否总是rgb()
/rgba()
?
这里事情变得有点复杂。考虑以下测试,其中我们有一个渐变色,我们使用了所有可能的格式
--c0: hsl(150, 100%, 50%); // springgreen
--c1: orange;
--c2: #8a2be2; // blueviolet
--c3: rgb(220, 20, 60); // crimson
--c4: rgba(255, 245, 238, 1); // seashell with alpha = 1
--c5: hsla(51, 100%, 50%, .5); // gold with alpha = .5
background: linear-gradient(90deg,
var(--c0), var(--c1),
var(--c2), var(--c3),
var(--c4), var(--c5))
我们通过 JavaScript 读取渐变色图像和各个自定义属性--c0
到--c5
的计算值。
let s = getComputedStyle(document.body);
console.log(s.backgroundImage);
console.log(s.getPropertyValue('--c0'), 'springgreen');
console.log(s.getPropertyValue('--c1'), 'orange');
console.log(s.getPropertyValue('--c2'), 'blueviolet');
console.log(s.getPropertyValue('--c3'), 'crimson');
console.log(s.getPropertyValue('--c4'), 'seashell (alpha = 1)');
console.log(s.getPropertyValue('--c5'), 'gold (alpha = .5)');
结果看起来有点不一致。

无论我们做什么,如果 alpha 值严格小于1
,我们通过 JavaScript 获得的值似乎总是rgba()
值,无论我们是使用rgba()
还是hsla()
设置它。
所有浏览器在直接读取自定义属性时也一致,但是,这一次,我们获得的值似乎没有多大意义:orange
、crimson
和 seashell
以关键字形式返回,无论它们是如何设置的,但我们得到 springgreen
和 blueviolet
的十六进制值。除了在 Level 2 中添加的 orange
之外,所有这些值都在 Level 3 中添加到 CSS,所以为什么我们得到一些是关键字,而另一些是十六进制值呢?
对于background-image
,Firefox 总是只返回完全不透明的值作为rgb()
,而 Chrome 和 Edge 则以关键字或十六进制值的形式返回它们,就像他们在直接读取自定义属性时所做的那样。
好吧,至少这让我们知道我们需要考虑不同的格式。
所以我们需要做的第一件事就是将关键字映射到rgb()
值。不会手动写所有这些,所以快速搜索发现这个仓库——完美,它正是我们想要的!我们现在可以将其设置为CMAP
常量的值。
下一步是创建一个getRGBA(c)
函数,它将接受一个表示关键字、十六进制或rgb()
/rgba()
值的字符串,并返回一个包含 RGBA 值([red, green, blue, alpha]
)的数组。
我们首先构建十六进制和rgb()
/rgba()
值的正则表达式。这些表达式有点宽松,如果我们要处理用户输入,可能会捕获很多误报,但由于我们只是对 CSS 计算样式值使用它们,所以我们可以在此处采取快速而粗糙的路径。
let re_hex = /^\#([a-f\d]{1,2})([a-f\d]{1,2})([a-f\d]{1,2})$/i,
re_rgb = /^rgba?\((\d{1,3},\s){2}\d{1,3}(,\s((0|1)?\.?\d*))?\)/;
然后我们处理我们已经看到的三种类型的值,这些值可能是通过读取计算样式获得的。
if(c in CMAP) return CMAP[c]; // keyword lookup, return rgb
if([4, 7].indexOf(c.length) !== -1 && re_hex.test(c)) {
c = c.match(re_hex).slice(1); // remove the '#'
if(c[0].length === 1) c = c.map(x => x + x);
// go from 3-digit form to 6-digit one
c.push(1); // add an alpha of 1
// return decimal valued RGBA array
return c.map(x => parseInt(x, 16))
}
if(re_rgb.test(c)) {
// extract values
c = c.replace(/rgba?\(/, '').replace(')', '').split(',').map(x => +x.trim());
if(c.length === 3) c.push(1); // if no alpha specified, use 1
return c // return RGBA array
}
现在,在添加关键字到 RGBA 映射(CMAP
)和getRGBA()
函数后,我们的 JavaScript 代码与之前的示例并没有太大区别。
const INI = getRGBA(S.getPropertyValue('--c-ini').trim()),
FIN = getRGBA(S.getPropertyValue('--c-fin').trim()),
RANGE = [],
ALPHA = 1 - INI[3] || 1 - FIN[3];
/* same as before */
function update() {
/* same as before */
document.body.style.setProperty(
'--c',
`rgb${ALPHA ? 'a' : ''}(
${INI.map((c, i) => Math.round(c + k*RANGE[i])).join(',')})`
);
/* same as before */
};
(function init() {
if(!ALPHA) INI.pop(); // get rid of alpha if always 1
RANGE.splice(0, 0, ...INI.map((c, i) => FIN[i] - c));
})();
/* same as before */
这给了我们一个线性渐变动画。
查看 thebabydino 在 CodePen 上创建的 Pen(@thebabydino)。
我们也可以使用不同的非线性计时函数,例如允许在结尾弹跳的函数。
const E = .8*Math.PI;
/* same as before */
function timing(k) {
return Math.sin(k*E)/Math.sin(E)
}
function update() {
/* same as before */
document.body.style.setProperty(
'--c',
`rgb${ALPHA ? 'a' : ''}(
${INI.map((c, i) => Math.round(c + timing(k)*RANGE[i])).join(',')})`
);
/* same as before */
};
/* same as before */
这意味着我们在完全变为蓝色之前会回到最终的紫色。
查看 thebabydino 在 CodePen 上创建的 Pen(@thebabydino)。
但是请注意,一般来说,RGBA 过渡并不是展示弹跳的最佳选择。这是因为 RGB 通道严格限制在[0,255]
范围内,而 alpha 通道严格限制在[0,1]
范围内。rgb(255, 0, 0)
是红色中最红的颜色,第一个通道的值大于255
不会有更红的红色。alpha 通道的值为0
表示完全透明,负值不会有更高的透明度。
到目前为止,你可能已经对渐变色感到厌烦了,所以让我们切换到其他东西!
平滑地改变 SVG 属性值
目前,我们无法通过 CSS 改变 SVG 元素的几何形状。根据 SVG2 规范,我们应该能够做到这一点,Chrome 也支持其中的一些内容,但是如果我们现在想以更跨浏览器的方式对 SVG 元素的几何形状进行动画处理,该怎么办?
好吧,你可能已经猜到了,JavaScript 来帮忙!
圆形增长
我们的第一个示例是一个circle
,它的半径从无(0
)变为最小viewBox
维度的四分之一。我们保持文档结构简单,没有其他附加元素。
<svg viewBox='-100 -50 200 100'>
<circle/>
</svg>
对于 JavaScript 部分,与之前的演示唯一的区别是,我们读取 SVG viewBox
的尺寸以获得最大半径,现在我们在update()
函数中设置r
属性,而不是 CSS 变量(如果 CSS 变量可以作为此类属性的值,那将非常有用,但遗憾的是,我们生活在一个不完美的世界中)。
const _G = document.querySelector('svg'),
_C = document.querySelector('circle'),
VB = _G.getAttribute('viewBox').split(' '),
RMAX = .25*Math.min(...VB.slice(2)),
E = .8*Math.PI;
/* same as before */
function update() {
/* same as before */
_C.setAttribute('r', (timing(k)*RMAX).toFixed(2));
/* same as before */
};
/* same as before */
下面,你可以看到使用bounce-fin
类型的计时函数时的结果。
查看 thebabydino 在 CodePen 上创建的 Pen(@thebabydino)。
平移和缩放地图
另一个 SVG 示例是一个平滑的平移和缩放地图演示。在这种情况下,我们以 amCharts 的地图为例,清理 SVG,然后通过在按下 +
/-
键(缩放)和箭头键(平移)时触发一个线性的viewBox
动画来创建这种效果。
在 JavaScript 中,我们首先创建一个导航映射,在其中获取我们感兴趣的键码,并将有关按下相应键时执行的操作的信息附加到它(请注意,由于某种原因,我们需要使用 Firefox 中不同的键码才能获得 +
和 -
)。
const NAV_MAP = {
187: { dir: 1, act: 'zoom', name: 'in' } /* + */,
61: { dir: 1, act: 'zoom', name: 'in' } /* + Firefox ¯\_(ツ)_/¯ */,
189: { dir: -1, act: 'zoom', name: 'out' } /* - */,
173: { dir: -1, act: 'zoom', name: 'out' } /* - Firefox ¯\_(ツ)_/¯ */,
37: { dir: -1, act: 'move', name: 'left', axis: 0 } /* ⇦ */,
38: { dir: -1, act: 'move', name: 'up', axis: 1 } /* ⇧ */,
39: { dir: 1, act: 'move', name: 'right', axis: 0 } /* ⇨ */,
40: { dir: 1, act: 'move', name: 'down', axis: 1 } /* ⇩ */
}
当按下 +
键时,我们想要做的就是放大。我们执行的操作是正向的'zoom'
——我们'in'
。类似地,当按下 -
键时,操作也是'zoom'
,但在负向(-1
)方向——我们'out'
。
当按下左箭头键时,我们执行的操作是沿着 x 轴(即第一个轴,索引为 0
)进行负向(-1
)方向的'move'
——我们'left'
。当按下上箭头键时,我们执行的操作是沿着 y 轴(即第二个轴,索引为 1
)进行负向(-1
)方向的'move'
——我们'up'
。
当按下右箭头键时,我们执行的操作是沿着 x 轴(即第一个轴,索引为 0
)进行正向的'move'
——我们'right'
。当按下下箭头键时,我们执行的操作是沿着 y 轴(即第二个轴,索引为 1
)进行正向的'move'
——我们'down'
。
然后我们获取 SVG 元素及其初始viewBox
,将最大缩小级别设置为这些初始viewBox
尺寸,并将最小可能的viewBox
宽度设置为一个更小的值(假设为8
)。
const _SVG = document.querySelector('svg'),
VB = _SVG.getAttribute('viewBox').split(' ').map(c => +c),
DMAX = VB.slice(2), WMIN = 8;
我们还创建一个空的当前导航对象来保存当前导航操作数据,以及一个目标viewBox
数组来保存我们为当前动画动画到viewBox
的最终状态。
let nav = {}, tg = Array(4);
在'keyup'
上,如果我们还没有任何动画正在运行,并且按下的键是感兴趣的键之一,我们从一开始创建的导航映射中获取当前导航对象。在此之后,我们处理两种操作情况('zoom'
/'move'
)并调用update()
函数。
addEventListener('keyup', e => {
if(!rID && e.keyCode in NAV_MAP) {
nav = NAV_MAP[e.keyCode];
if(nav.act === 'zoom') {
/* what we do if the action is 'zoom' */
}
else if(nav.act === 'move') {
/* what we do if the action is 'move' */
}
update()
}
}, false);
现在让我们看看如果我们缩放会发生什么。首先,这是一个非常有用的编程策略,不仅仅是在这里,而是在一般情况下,我们首先处理使我们退出函数的边缘情况。
那么,这里的边缘情况是什么呢?
第一个情况是当我们想缩小(负向缩放)时,而我们的整个地图已经处于视野中(当前viewBox
尺寸大于或等于最大尺寸)。在我们的例子中,如果我们在最开始想缩小,因为我们一开始就看到了整个地图,那么应该发生这种情况。
第二个边缘情况是当我们达到另一个极限时——我们想放大,但我们处于最大细节级别(当前viewBox
尺寸小于或等于最小尺寸)。
将上述内容转换为 JavaScript 代码,我们有
if(nav.act === 'zoom') {
if((nav.dir === -1 && VB[2] >= DMAX[0]) ||
(nav.dir === 1 && VB[2] <= WMIN)) {
console.log(`cannot ${nav.act} ${nav.name} more`);
return
}
/* main case */
}
现在我们已经处理了边缘情况,让我们继续处理主要情况。在这里,我们设置目标viewBox
值。我们在每个步骤中使用2x
缩放,这意味着当我们放大时,目标viewBox
尺寸是当前缩放操作开始时尺寸的一半,而当我们缩小时,它们是当前尺寸的两倍。目标偏移量是最大viewBox
尺寸与目标尺寸之间的差值的一半。
if(nav.act === 'zoom') {
/* edge cases */
for(let i = 0; i < 2; i++) {
tg[i + 2] = VB[i + 2]/Math.pow(2, nav.dir);
tg[i] = .5*(DMAX[i] - tg[i + 2]);
}
}
接下来,让我们看看如果我们想移动而不是缩放,我们该怎么做。
以类似的方式,我们首先处理使我们退出函数的边缘情况。在这里,当我们处于地图边缘并想继续朝该方向移动时(无论方向如何)就会发生这种情况。由于我们的 `viewBox` 的左上角最初位于 `0,0`,这意味着我们不能低于 `0` 或高于最大 `viewBox` 大小减去当前大小。请注意,鉴于我们最初完全缩小,这也意味着在放大之前,我们无法向任何方向移动。
else if(nav.act === 'move') {
if((nav.dir === -1 && VB[nav.axis] <= 0) ||
(nav.dir === 1 && VB[nav.axis] >= DMAX[nav.axis] - VB[2 + nav.axis])) {
console.log(`at the edge, cannot go ${nav.name}`);
return
}
/* main case */
对于主要情况,我们沿着该轴移动 `viewBox` 大小的二分之一,以实现所需的方向。
else if(nav.act === 'move') {
/* edge cases */
tg[nav.axis] = VB[nav.axis] + .5*nav.dir*VB[2 + nav.axis]
}
现在让我们看看在 `update()` 函数中需要做什么。这将与之前的演示非常类似,除了现在我们需要分别处理 `'move'` 和 `'zoom'` 情况。我们还创建一个数组来存储当前的 `viewBox` 数据(`cvb`)。
function update() {
let k = ++f/NF, j = 1 - k, cvb = VB.slice();
if(nav.act === 'zoom') {
/* what we do if the action is zoom */
}
if(nav.act === 'move') {
/* what we do if the action is move */
}
_SVG.setAttribute('viewBox', cvb.join(' '));
if(!(f%NF)) {
f = 0;
VB.splice(0, 4, ...cvb);
nav = {};
tg = Array(4);
stopAni();
return
}
rID = requestAnimationFrame(update)
};
在 `'zoom'` 情况下,我们需要重新计算所有 `viewBox` 值。我们使用动画开始时的值和之前计算的目标值之间的线性插值来完成此操作。
if(nav.act === 'zoom') {
for(let i = 0; i < 4; i++)
cvb[i] = j*VB[i] + k*tg[i];
}
在 `'move'` 情况下,我们只需要重新计算一个 `viewBox` 值——我们移动轴的偏移量。
if(nav.act === 'move')
cvb[nav.axis] = j*VB[nav.axis] + k*tg[nav.axis];
就是这样!现在我们有了工作正常的平移和缩放演示,并在状态之间平滑地过渡。
查看 thebabydino 在 CodePen 上的 Pen (@thebabydino)。
从悲伤的正方形到快乐的圆圈
另一个例子是将一个悲伤的正方形 SVG 形态转换为一个快乐的圆圈。我们创建一个具有正方形 `viewBox` 的 SVG,其 `0,0` 点位于正中间。相对于 SVG 坐标系的原点,我们有一个正方形(一个 `rect` 元素)覆盖了 SVG 的 `80%`。这是我们的脸。我们使用 `ellipse` 和它的一个副本创建眼睛,它们相对于垂直轴对称。嘴巴是 一条三次贝塞尔曲线,使用 `path` 元素创建。
- var vb_d = 500, vb_o = -.5*vb_d;
- var fd = .8*vb_d, fr = .5*fd;
svg(viewBox=[vb_o, vb_o, vb_d, vb_d].join(' '))
rect(x=-fr y=-fr width=fd height=fd)
ellipse#eye(cx=.35*fr cy=-.25*fr
rx=.1*fr ry=.15*fr)
use(xlink:href='#eye'
transform='scale(-1 1)')
path(d=`M${-.35*fr} ${.35*fr}
C${-.21*fr} ${.13*fr}
${+.21*fr} ${.13*fr}
${+.35*fr} ${.35*fr}`)
在 JavaScript 中,我们获取脸部和嘴巴元素。我们读取脸部的 `width`,它等于 `height`,并用它来计算最大的角圆角。这是我们获得圆圈的值,等于正方形边的一半。我们还获取嘴巴路径数据,从中提取控制点的初始 `y` 坐标,并计算相同控制点的最终 `y` 坐标。
const _FACE = document.querySelector('rect'),
_MOUTH = document.querySelector('path'),
RMAX = .5*_FACE.getAttribute('width'),
DATA = _MOUTH.getAttribute('d').slice(1)
.replace('C', '').split(/\s+/)
.map(c => +c),
CPY_INI = DATA[3],
CPY_RANGE = 2*(DATA[1] - DATA[3]);
其余部分与迄今为止所有其他点击演示中的过渡非常相似,只有几个细微的差异(请注意,我们使用了一种 ease-out 类型的计时函数)。
/* same as before */
function timing(k) { return 1 - Math.pow(1 - k, 2) };
function update() {
f += dir;
let k = f/NF, cpy = CPY_INI + timing(k)*CPY_RANGE;
_FACE.setAttribute('rx', (timing(k)*RMAX).toFixed(2));
_MOUTH.setAttribute(
'd',
`M${DATA.slice(0,2)}
C${DATA[2]} ${cpy} ${DATA[4]} ${cpy} ${DATA.slice(-2)}`
);
/* same as before */
};
/* same as before */
因此,我们得到了我们愚蠢的结果。
查看 thebabydino 在 CodePen 上的 Pen (@thebabydino)。
嗨,Ana
我刚开始学习编码,并参加了一些 HTML、JavaScript 和 CSS 课程。看到专家能够做到的事情真是太令人印象深刻了。感谢你的鼓舞人心的作品。我计划花一些时间尝试理解你所做的事情,包括观看你的 YouTube 演示文稿。
Ed
我只想说声感谢,因为我知道这篇文章花了你大量的心血,我知道我会反复参考它。
在过去的几个月里,我不得不开始用 js 编写动画,而不是标准 css,如果动画发生在本地页面中,该页面嵌套在一个 div 中,嵌套在一个 div 中,嵌套在一个 div 中,css 动画会开始变得怪异。(第一个 div 加载了一个本地页面,第二个 div 位于加载的页面中,并用作装饰性容器,包含第三个 div,它根据一些 php 加载另一个页面。)我发现使用像这样的深层嵌套系统(或 4-5 个 div 深度的系统)对于显示特定信息非常有效,根据谷歌 Lighthouse 测试,加载速度非常快,并且提供了单页网站的外观,同时允许开发人员(我自己)创建更小的组件,这些组件可以轻松地插入网站。