Lerp 和 FLIP,CSS 动画的小技巧

对于我来说,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 解构如下:

html 复制代码
<!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 添加点元素:

js 复制代码
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 的效果:

js 复制代码
const anchor = document.getElementById("anchor");
anchor.onclick = () => {
  scrollEl.scrollTo(0, 0);
};

可以看到一下就闪过去了。

接下来实现 lerp 配合 requestAnimationFrame API(很重要)的实现效果:

js 复制代码
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,添加动画的时机和动画实现的计算不同,可能会导致两个动画没有办法完全同步。这种情况,我们更希望自己手动控制。

加一个旋转收缩的效果看一下:

js 复制代码
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。更高刷新率的屏幕下,这个间隔会更短。

相比定时器,requestAnimationFrame 的 callback 执行更加稳定。注意这里稳定的只是执行频率,假设执行了一个耗时久或不稳定的操作,还是没有办法保证实现效果。requestAnimationFrame 的回调函数接受一个 timeStamp 的参数,代表执行时刻的时间戳。这个参数很有用,就像上面说的,requestAnimationFrame 执行频率依赖于屏幕刷新率,在不同的设备上,动画会快慢不一。在计算时,融合时间戳能统一动画时长。

FLIP

FLIP 的概念更复杂一点,首先思考一下实现动画中,最重的部分在哪里。想明白这一点,就不会觉得 FLIP 有些多此一举了。

总所周知,浏览器渲染主要可以分为以下几步,这几个步骤会依次执行,页面改动后的更新也是如此:

  1. 样式:计算应用于元素的样式。
  2. 布局:为每个元素生成几何图形和位置。
  3. 绘制 :将每个元素的像素填充到中。
  4. 合成:将图层绘制到屏幕上。

每个元素有默认样式、CSS 样式,JS 也可能做一些修改,这个过程走完就可以得到单个元素的样式。

有了元素本身的样式,加上相互之间的关系,就可以计算布局。布局计算同样很复杂,比如说没有设置高度的元素,需要根据内容计算高度。同时还需要增删元素,比如 display: none 无需渲染可以从布局中移除,一些伪类需要添加进去。布局计算完成可以得到整个 DOM 的地图(Layout),重点是整个。

截止到这里,流程都是很快的,除非你的 DOM 结构非常非常复杂。

有了布局,依旧不足以显示整个页面,不用的元素间还会有层叠,层叠后最终的效果还需要计算,这就是绘制。绘制是整个流程中消耗最大的一步,需要计算整个影响区域的效果。

接下来是合成,合成是现代浏览器针对绘制过程的优化。合成过程中,会将绘制得到的页面分层,每层使用一个线程单独处理,再将每层效果组合到一起就可以得到最终效果。并行处理,可以很好地提高效率。这个也很好理解,就像拼模型一样,多找几个人,一个负责腿部,一个负责头部...,每个人工作完成再拼接到一起就可以了。当然图层复合要复杂的多,可能有层叠等问题。这不是重点,重点是每个图层是单独的线程,处理后存储的数据也是单独的。每次变动图层后,需要重新合成一个新帧。

其实所有的动画都是类似的,如果只是计算是很快,重点绘制。浏览器展示内容,并不像拼图、或者我们往桌子上摆东西一样,有了元素,往响应的位置放就行了。浏览器绘制最终需要操作的是像素,通过每个位置的像素发出不用颜色的光,来展示图案。

注:不是这样一个个往上摆的,这个动画其实也是用 FLIP 做的,我也不会别的 😮‍💨。

计算位置很快,绘制可能很慢。很自然可以想到,利用浏览器强大的计算能力,获取动画的起止状态,接着单独开启一个线程做动画。这样触发布局更新的操作,只会发生在一帧时间内,剩下的动画操作不回触发布局更新,也就会更流畅。

这就是 FLIP 了,正式介绍下。F 代表 First,也就是动画的开始状态。L 代表 Last,代表动画结束状态。I 代表 Invert,也就是状态反转,使用 transform 创建单独的图层,并将元素状态反转回去。P 代表 Play,播放动画。

下面是一个简单例子:

html 复制代码
<!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!

深入了解现代网络浏览器(第 3 部分)

FLIP Your Animations

FLIP 技术给 Web 布局带来的变化

相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax