对我来说,CSS 中最迷人的部分,当属动画。看到一个元素,根据自己的想法动起来,会由衷觉得有趣且有成就感。动画有不同的实现方式,可以使用 keyframe animation,也可以使用 JS 计算修改属性。
不考虑实现效果,大部分动画都不难,无非改变一些属性。这是一句没用的话,展示给用户的东西一定要以最后呈现效果为准。动画的难点主要在于怎么实现的丝滑、自然,不出现丢帧。通常来说,操作 transform 是更好的选择,transform 只影响浏览器渲染的合成过程,浏览器可以开启一个单独线程处理。使用 JS 修改属性,可能引起页面的回流,重绘,增加开销。更大的问题则是,由于 JS 特性,定时器动画不能保证执行时机。JS 执行线程和渲染线程是互斥的,针对 DOM 的操作将被合并,只会渲染最后的结果。这就会导致,动画执行的卡顿(执行时机不确定),或者跳帧(操作被合并)。有些情况,必须通过计算实现效果,怎么保证动画的丝滑就值得思考了。
在学习过程中,遇到两个有意思的概念,一个是 Lerp(Linear Interpolation,线性插值方法),可以使动画更加自然。还有 FLIP,FLIP 是实现动画的一种方式,防止丢帧,效果也很棒,涉及概念比较多,后面详解。
LERP
Lerp 用于动画状态计算,让动画过渡更自然。比如说,将 Element 从 A 点移动到 B 点。在一次页面重绘中完成操作(例如,直接将 B 位置的 Top,Left 设置给 Element),用户会觉得 Element 出现了闪现。距离很远,看不到轨迹,用户可能在改变后找不到 Element。Lerp 的实现方式是,每次按百分比插值。比如说 A、B 间距离为 100,第一次插值取 95%,插值后 A、B 间距离还剩 95。下一次继续取 95%, A、B 间距离还剩 95 * 0.95 = 90.25,依次类推直到结束。这样做的好处就是,整个动画按百分比移动,开始快,后面慢,很自然。
我用滚动举个例子,基本 DOM 解构如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lerp</title>
<style>
html,
body {
margin: 0;
padding: 0;
}
.wrapper {
position: relative;
height: 100vh;
width: 600px;
margin: 0 auto;
box-sizing: border-box;
padding: 8px;
overflow: hidden;
}
#container {
border: 1px solid #e0dede;
height: 100%;
overflow-y: scroll;
}
.inner {
height: 100px;
line-height: 100px;
text-align: center;
background-image: linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%);
}
#anchor {
width: 32px;
height: 32px;
border-radius: 32px;
cursor: pointer;
background-color: #84fab0;
position: absolute;
right: 40px;
bottom: 60px;
}
.icons {
width: 32px;
height: 32px;
}
</style>
</head>
<body>
<div class="wrapper">
<div id="container"></div>
<div id="anchor">
<svg class="icons">
<use xlink:href="#arrow"></use>
</svg>
</div>
</div>
<!-- svg 绘制箭头 -->
<svg style="display: none">
<symbol
id="arrow"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
>
<path
d="M112 328L256 184L400 328"
style="
stroke: black;
fill: transparent;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 4px;
"
/>
</symbol>
</svg>
</body>
</html>
这里使用 svg 绘制了一个箭头。很简单,使用 path 绘制路径。viewBox="0 0 512 512" 指定 svg 画布区域的宽高都是 512。path 用来绘制路径,d="M112 328L256 184L400 328" 是绘制的指令。指令中涉及两个命令,M 是 Move to 的缩写,移动画笔位置。M112 328 即将画笔移动到 (112, 328) 位置,作为开始。L 是 Line to 的缩写,可以移动画笔到一个新位置,并画一条线,L256 184 也就是从当前位置 (112, 328) 移动到 (256, 184) ,画一条线,下一个 L400 328 同理。这样就能画一个箭头了。style 中指定的图案的样式,重点是 stroke(线条颜色),fill(图形中间的填充颜色),stroke-width(线条宽度)这三个属性,剩下属性是用来指定圆角效果的。
为了使元素能滚动起来,给 div#container 添加元素:
const scrollEl = document.getElementById("container");
// 循环添加元素
for (let i = 1; i < 10001; i++) {
const div = document.createElement("div");
div.textContent = i;
div.className = "inner";
scrollEl.appendChild(div);
}
接下来给 anchor 添加点击事件,先看不用 lerp 的效果:
const anchor = document.getElementById("anchor");
anchor.onclick = () => {
scrollEl.scrollTo(0, 0);
};
可以看到一下就闪过去了。
接下来实现 lerp 配合 requestAnimationFrame API(很重要)的实现效果:
let state = 0;
anchor.onclick = () => {
// 滚动开始位置
state = scrollEl.scrollTop;
loop();
};
// 循环进行动画的方法
function loop() {
const next = lerp(0, state, 0.95);
// 小于 1 px 结束动画
state = next > 1 ? next : 0;
// 滚动
scrollEl.scrollTo(0, state);
// requestAnimationFrame 控制动画频率
state > 0 && requestAnimationFrame(loop);
}
// lerp 方法,start end 为开始结束状态
// factor 控制变化快慢的参数
function lerp(start, end, factor) {
return start + (end - start) * factor;
}
代码很简单,就是根据 factor 控制变化快慢,效果能看到有一个先快后慢的过程。
只是这样的效果,看起来没什么大用。scrollTo 也可以指定 behavior,实现平滑过渡。在动画中,可以通过 animation-timing-function 调整动画的过渡模式。真正适用 lerp 的地方,是当你有一套 "combo" 的时候。
比如说,这里的 anchor 在触发滚动效果后就没有额外的行为了,滚动中也不应该重复触发点击。最好在 anchor 上增加一个反馈,就好像在说,congrats,你已经点击成功,静待结果吧,不需要再次点击了。
反馈的动画和滚动行为有逻辑上的联系,让两者间形成搭配就是自然的想法。假设分别写了两个动画,使用 animation-timing-function 指定同样的 mode,添加动画的时机和动画实现的计算不同,可能会导致两个动画没有办法完全同步。这种情况,我们更希望自己手动控制。
加一个旋转收缩的效果看一下:
let state = 0,
origin = null;
anchor.onclick = () => {
// 滚动开始位置
state = scrollEl.scrollTop;
if (state === 0) return;
origin = state; // 保存原始值
loop();
};
function loop() {
const next = lerp(0, state, 0.95);
state = next > 1 ? next : 0;
scrollEl.scrollTo(0, state);
const factor = state / origin; // 缩放旋转比例
const rotateDeg = factor * 360;
anchor.style.transform = `scale(${factor}) rotate(${rotateDeg}deg)`;
state > 0 && requestAnimationFrame(loop);
}
滚动是从最大值到 0,和缩小到 0 一致,保存下开始滚动位置即可。
实现一套作用于不同元素的动画,就可以使用 lerp 手动计算。效果还是很不错的,改变 factor 参数可以控制变化的效果。具体效果可以自己去尝试,我这套动画其实很简陋,可以去看看 Paul Lewis 的这个视频。
Lerp 通常会搭配 requestAnimationFrame 使用,可以说 requestAnimationFrame 才是重点。Lerp 只是一种计算方式,保证动画流畅的关键是 requestAnimationFrame。就像上面开头说的,JS 控制动画的最大问题是,没办法保证执行时间。requestAnimationFrame 提供了一种稳定的动画执行方式,由浏览器严格控制执行频率,在下一次 repaint 前执行。这个时间间隔依赖于屏幕的刷新频率,现在大多数屏幕是 60hz,也就是说每隔 16.67ms 会刷新一次屏幕,做一次 repaint。更高刷新率的屏幕下,这个间隔会更短。
Paul Lewis 的视频中,动画 loop 是保持运行的。通常的动画逻辑都是这样,requestAnimationFrame 是一个异步操作,计算足够简单时,时间消耗可以忽略不计,异步又可以保证不阻塞主线程。
相比定时器,requestAnimationFrame 的 callback 执行更加稳定。注意这里稳定的只是执行频率,假设执行了一个耗时久或不稳定的操作,还是没有办法保证实现效果。requestAnimationFrame 的回调函数接受一个 timeStamp 的参数,代表执行时刻的时间戳。这个参数很有用,就像上面说的,requestAnimationFrame 执行频率依赖于屏幕刷新率,在不同的设备上,动画会快慢不一。在计算时,融合时间戳能统一动画时长。
FLIP
FLIP 的概念更复杂一点,首先思考一下实现动画中,最重的部分在哪里。想明白这一点,就不会觉得 FLIP 有些多此一举了。
总所周知,浏览器渲染主要可以分为以下几步,这几个步骤会依次执行,页面改动后的更新也是如此:
- 样式:计算应用于元素的样式。
- 布局:为每个元素生成几何图形和位置。
- 绘制:将每个元素的像素填充到层中。
- 合成:将图层绘制到屏幕上。
启用渲染进程后,首先需要计算 DOM 中每个元素的样式。元素有默认样式、CSS 设置的样式,JS 也可能做一些修改。按照优先级,计算可以得到元素每个属性最终应用的值。
有了元素本身的样式,加上相互之间的关系(例如,BFC 还是 IFC,元素前后位置),就可以计算布局。布局计算很复杂,比如说没有设置高度的元素,需要根据内容计算高度。同时还需要增删元素,如 display: none 无需渲染可以从布局中移除,还需要添加一些额外的伪类元素(html 文件并没有将伪类元素写入 body 结构中)。布局计算完成可以得到整个 DOM 的地图(Layout)。
截止到这里,流程都是很快的,除非你的 DOM 结构非常非常复杂。
有了布局,依旧不足以显示整个页面,不用的元素间还会有层叠,层叠后最终的效果还需要计算,这就是绘制。绘制是整个流程中消耗最大的一步,需要计算整个影响区域的效果。
接下来是合成,合成是现代浏览器针对绘制过程的优化。合成过程中,会将绘制得到的页面按规则分层(如元素应用了 transform,就会被处理成一个单独的图层)。每层单独使用一个线程保存相关光栅化数据(保存光栅数据,会造成额外内存消耗,这是一种 tradeoff,使用时要考虑下),将所有图层合成后可以的到最终结果。并行处理,可以很好地提高效率。这个也很好理解,就像拼模型一样,多找几个人,一个负责腿部,一个负责头部...,每个人工作完成再拼接到一起就可以了。每个图层是单独的线程,处理后存储的数据也是单独的。修改图层,只需操作单个线程数据,再将最终结果合并渲染。相比修改整个布局,重新计算所有 DOM,效率要高很多。
所有的动画都是类似的,单纯的计算很快,消耗主要在绘制。浏览器展示内容,并不像拼图、或者我们往桌子上摆东西,有了元素,移动至相应位置。浏览器绘制需要操作的是像素,通过每个位置的像素发出不用颜色的光,来展示图案。也就是需要先计算出来结果,再渲染。
注:不是这样一个个往上摆的,这个动画其实也是用 FLIP 做的,我也不会别的 😮💨。
计算位置很快,绘制可能很慢。自然会想到,利用浏览器强大的计算能力,获取动画的起止状态,接着单独开启一个线程做动画。这样触发布局更新的操作,只会发生在一帧时间内,剩下的动画跑在单独的线程上,会更流畅。
这就是 FLIP 的基本思路。
正式介绍下。F 代表 First,也就是动画的开始状态。L 代表 Last,代表动画结束状态。I 代表 Invert,也就是状态反转,使用 transform 等属性,创建单独的图层,并将元素状态反转回去。P 代表 Play,播放动画。
下面是一个简单例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flip</title>
<style>
html,
body {
padding: 0;
margin: 0;
width: 100%;
}
#old-container {
height: 100vh;
padding: 30px;
border: 1px solid #96b6c1;
}
#new-container {
width: 600px;
height: 800px;
background-image: url(./assests/bg-migong.jpg);
background-size: contain;
background-repeat: no-repeat;
position: absolute;
top: 100px;
left: 600px;
/* transform: rotate(-45deg); */
transform-origin: top right;
}
.box {
height: 50px;
width: 50px;
line-height: 50px;
text-align: center;
background-color: #e5d3d3;
color: aliceblue;
border-radius: 8px;
box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 0, 0.2);
}
.action-button {
position: absolute;
top: 400px;
left: 200px;
height: 80px;
width: 110px;
border: none;
box-shadow: inset 0px 0px 0px 1px #c4e3de50;
background-color: #55888b;
border-radius: 12px;
font-size: 32px;
color: #f3f3f3;
}
</style>
</head>
<body>
<div id="old-container">
<div id="new-container"></div>
</div>
<button class="action-button" onclick="handleClick()">切换</button>
</body>
<script>
const box = document.createElement("div");
box.textContent = "BOX";
box.classList.add("box");
const oldEl = document.getElementById("old-container");
const newEl = document.getElementById("new-container");
const handleClick = () => {
Flip(box, () =>
(box.parentNode === newEl ? oldEl : newEl).appendChild(box)
);
};
// Flip
function Flip(el, action) {
// 获取开始状态
const startState = el.getBoundingClientRect();
action();
// 获取结束状态
const endState = el.getBoundingClientRect();
// 开始位置的反方向
// 0 -> 1
// 需要从 1 回到 0,也就是开始位置 1 结束位置 0
// 结束位置减开始位置
// transform (0 - 1)
// 计算位置大小
// 开始状态减去结束状态
const deltaY = startState.top - endState.top;
const deltaX = startState.left - endState.left;
const deltaW = startState.width / endState.width;
const deltaH = startState.height / endState.height;
// 进行动画
el.animate(
[
{
transformOrigin: "top left",
transform: `
translate(${deltaX}px, ${deltaY}px)
scale(${deltaW}, ${deltaH})`,
},
{
transformOrigin: "top left",
transform: "none",
},
],
{
duration: 300,
easing: "ease-in-out",
fill: "both",
}
);
}
</script>
</html>
在 action 中,我切换了 box 元素的父节点。这个操作会改变布局,渲染进程需要重新计算布局。假设我们自己计算,不仅要占据主线程,还要考虑很多问题,例如不同的屏幕大小怎么计算移动距离。直接调用 getBoundingClientRect, 浏览器高效地计算动画所需状态。随后,触发动画,浏览器会在下一帧渲染时,变动布局,使用单独的图层操作动画。一帧内完成的操作,浏览器会将变动合并,这样用户不会看到元素移动到结束位置,再反转回来的过程,只有我们自己知道已经偷偷做了这么多事。
看下效果,背景图片是我随便放的,为了区分区域:
参考资料:
Creating a circular reveal animation with JavaScript and CSS!