Flip-js 优雅的处理元素结构变化的动画(解读)

基于 FLIP 动画思想,处理元素结构变化的动画,并且同时处理元素样式变化引起的动画


之前分享过一篇关于 flip 动画的文章,介绍了我编写的fan-flip-js如何使用。

文章链接:Flip-js 优雅的处理元素结构变化的动画 - 掘金

今天来聊一聊究竟什么是 FLIP 动画思想,分享一下具体是如何实现的,以及其中一些从设计模式出发的考虑和 TS 实现的技巧。

什么是 FLIP 动画?

CSS3 出现以后,绝大多数动画都无需使用 JS,即可实现功能并且拥有更好的性能。但是 CSS 动画仅能处理 CSS 属性值发生变化的动画,无论 tranisition还是 animation都需要一个 CSS 属性从一个明确的值变化到另一个明确的值。

而元素结构的变化,只能提供起始和结束的元素状态,元素所在具体是无从知晓的,当然也无法提供具体的 left/top 或 translate 属性值。这时 FLIP 动画思想就能够派上用场。

FLIP,由四个单词组成:

  1. First:记录元素的初始位置
  2. Last:记录元素的最终位置
  3. Invert:将元素从初始位置移动到最终位置
  4. Play:播放动画

听起来很简单,实际上实现起来也很简单😂。

html 复制代码
<html>
    <head>
        <title>FLIP 动画</title>
    </head>
    <body>
        <div id="app">
            <div class="item">1</div>
            <div class="item">2</div>
            <div class="item">3</div>
            <div class="item">4</div>
            <div class="item">5</div>
            <div class="item">6</div>
            <div class="item">7</div>
            <div class="item">8</div>
            <div class="item">9</div>
            <div class="item">10</div>
        </div>
        <button id="btn">切换</button>
        <script>
            const btn = document.getElementById('btn');
            const app = document.getElementById('app');
            btn.addEventListener('click', () => {
                const el = app.children[0];
                // 1. 记录起始位置
                const first = el.getBoundingClientRect();
                app.appendChild(el);
                // 2. 记录结束位置
                const last = el.getBoundingClientRect();
                const diffX = last.left - first.left;
                const diffY = last.top - first.top;
                // 3. 移动元素回到起始位置
                el.style.transform = `translate(${-diffX}px, ${-diffY}px)`;
                el.clientTop; // 强制 reflow
                // 4. 开启动画
                el.style.transition = 'transform 300ms ease-in-out';
                el.style.transform = `translate(0, 0)`;
                el.addEventListener('transitionend', () => {
                    el.style.transform = '';
                    el.style.transition = '';
                });
            });
        </script>
    </body>
</html>

效果展示:

上面的例子中,使用 getBoundingClientRect 方法获取元素位置,通过设置 el.style.transform 实现元素的移动动画。具体步骤如下:

  1. 记录起始位置。 const first = el.getBoundingClientRect()
  2. 记录结束位置。 const last = el.getBoundingClientRect()
  3. 移动元素回到起始位置。 设置 el.style.transform
  4. 强制 reflow。在这个实现中,这是比较重要的一步。读取元素的布局属性会触发浏览器的重排,重新渲染页面,让元素处于起始位置。这涉及到另一块知识,有感兴趣的朋友可以评论,我有时间可以写篇文章详细介绍一下。
  5. 开启动画。设置 el.style.transition 和 el.style.transform 实现动画效果。
  6. 监听动画结束事件。在动画结束后,清除 el.style.transform 和 el.style.transition,确保下次动画能够正常进行。

要实现一个元素的 FLIP 动画并非难事,但是如果要写一个通用的动画方法,还需要在一些细节上做额外的考虑。

通用动画方法的实现

动画核心方法

最开始要思考的是两个核心部分:

  1. 如何记录元素的起始位置和结束位置?
  2. 如何实现元素的移动动画?

元素位置信息

在 JS 中获取元素位置信息的方式有多种,比较常用的有以下几种:

  1. getBoundingClientRect
  2. el.style.top 和 el.style.left
  3. getComputedStyle
  4. offsetTop 和 offsetLeft

el.style 通过元素行内样式获取位置信息,局限性很大,通过访问 DOM 树,只能得到设置的行内样式,并非最后的位置信息。

getComputedStyle 方法可以获取元素的计算样式,访问的是 CSSOM 树,读取元素样式计算的结果。

offsetTop 和 offsetLeft 是过去常用的访问元素位置的方法,访问的是布局树,但是相对的是 offsetParent 的位置,并非只是视口或 body,关于 offsetParent 的判定也有很多注意点。

getBoundingClientRect 方法返回的是元素真实的位置信息,包含元素的位置和尺寸信息。最重要的一点就是会考虑 transform 属性的影响,得到的结果是元素在浏览器中渲染的视觉像素,因为 transform 属性的计算并不在浏览器渲染主线程中,而是在合成线程中进行。这是其他几个方法都做不到。

综上所述,选择 getBoundingClientRect 方法获取元素位置信息。

实现移动动画

在之前的实现中,我们使用 el.style.transform 实现元素的移动动画。这种方式中重要的一点就是设置属性后,需要一次强制回流。其实还可以采用 el.animate API 实现动画,相比之下更灵活,功能也更强大。

使用 el.animate API,需要提供起始和结束的关键帧,并设置动画相关参数,类似于这样:

js 复制代码
el.animate([
  { transform: `translate(${first.left}px, ${first.top}px)` },
  { transform: `translate(${last.left}px, ${last.top}px)` },
], {
  duration: 300,
  easing: 'ease-in-out',
});

而且 el.animate API 返回的是一个 Animation 对象,还可以监听动画结束事件,动画结束后调用回调函数。

js 复制代码
const animation = el.animate(...);
animation.addEventListener('finish', () => {
  // 动画结束的回调
});

这两个核心方法准备就绪,开始实现通用动画方法。

1. FLIP 步骤

FLIP 动画分为四步,最开始想着把这四步显示的提取出来,在编写代码时也可以一目了然,目标清晰。于是编写了一个抽象父类:

ts 复制代码
export abstract class FlipProcedure {
  /**
   * 1. first 记录初始位置状态
   */
  abstract init(): void;
  /**
   * 2. last 记录最后位置状态
   */
  abstract last(): void;
  /**
   * 3. invert 回退到起始位置
   */
  abstract invert(): void;
  /**
   * 4. play 执行动画
   */
  abstract play(onFinish: () => void): void;
  // 最终执行动画的函数
  runAnimate(onFinish: () => void) {
    this.last();
    this.invert();
    this.play(onFinish);
  }
}

雏形像上面这样,在之后具体实现的时候,只需要继承这个父类,实现四个抽象方法。

动画结束后的回调这一块,又考虑到可以使用 Promise 实现,于是提供了两种不同风格的使用方式:

ts 复制代码
export abstract class FlipProcedure {
  // ...
  runAnimate(): Promise<void>;
  runAnimate(callback: () => void): void;
  /**
   * 动画结束之后调用回调函数,支持两种风格调用方式。Promise 和 callback
   */
  runAnimate(callback?: () => void): Promise<void> | void {
    const _animate = (fn: () => void) => {
      this.last();
      this.invert();
      this.play(fn);
    }
    if (callback && typeof callback === 'function') {
      _animate(callback);
    } else {
     return new Promise<void>(_animate);
    }
  }
}

利用 TS 的函数重载,实现了两种不同的调用方式。将来使用时,可以使用 await 关键字或 then 方法,也可以传入回调函数。

TS 中的函数重载只限于编译时,所以只能实现不同的函数签名,具体的函数实现依然是杂糅在一起的。

相较于其他运行时的强类型语言,这一点确实较弱。不过 TS 现在提倡类型擦除,不希望影响运行时,只限于编译时的类型检查。

当然,我还是有个想法,编写一个函数重载的方法,看看能不能尽可能的把函数实现也分离出来。

2. 实现核心类 Flip

Flip 类继承自 FlipProcedure 类,实现了具体的动画逻辑。目标很明确,实现四个步骤方法。

动画是针对于 DOM 元素,保证了方法的灵活性,哪怕是将来想要编写适用于不同框架的方法,基于此就可以实现。

所以该构造函数肯定需要接收两个参数:

  • el 目标元素
  • animateOption 动画配置项

前两个步骤需要记录元素的初始位置和最终位置,所以编写一个 getRect 方法,用来获取元素的位置信息。

我们使用 getBoundingClientRect 方法获取元素的位置信息,考虑到之后使用 el.animate 实现动画时,也需要设置包含 transform 属性的关键帧,而元素本身可能也设置了 transform 属性。所以这里记录变换前位置信息,同时也保存本身的 transform 属性供后续使用。

ts 复制代码
private getRect() {
  const style = getComputedStyle(this.el);
  const transform = style.transform;
  // 先将元素的 transform 置为 none
  this.el.style.transform = 'none';
  // 得到变换前的位置值
  const rect = this.el.getBoundingClientRect();
  // 恢复元素的 transform 值
  this.el.style.transform = transform;
  return {
    left: rect.left,
    top: rect.top,
    transform: transform === 'none' ? '' : transform,
  };
}

前两步只需要调用 getRect 方法记录信息即可。

接下来实现第三步,我们使用 el.animate 方法实现回退动画,需要两个关键帧。所以这一步进行的操作是通过之前记录的信息,计算起始和结束的动画关键帧。

ts 复制代码
invert(): void {
    const { left, top, transform } = this.firstRect!;
    const { left: lastLeft, top: lastTop, transform: lastTransform } = this.lastRect!;
    const diffX = left - lastLeft;
    const diffY = top - lastTop;

    this.firstAnimateKeyframe = {
      transform: `translateX(${diffX}px) translateY(${diffY}px) ${transform}`
    }
    this.lastAnimateKeyframe = {
      transform: lastTransform
    }
  }

实现起来也不难,注意保留元素本身的 transform 属性,避免动画导致元素的位置被改变,在起始或结束位置出现闪烁。

接下来是第四步,执行动画。

ts 复制代码
play(onFinish: () => void): void {
  if (this.isRunning) {
    return;
  }
  this.isRunning = true;
  const animation = this.el.animate([
    this.firstAnimateKeyframe!,
    this.lastAnimateKeyframe!
  ], this.animateOption);
  this.animation = animation;
  animation.addEventListener('finish', () => {
    this.isRunning = false;
    // 动画结束的回调
    onFinish();
  });
}

大致实现如上所示,当然其中还有一些关于状态清除的操作,这里就不展开了。

我们在父类中实现了 runAnimate 方法,但是子类实例调用时还可能在每次动画时,设置不同的动画配置项,因此还需要包装一层,对参数进行处理,最终对外暴露的是 animate 方法。

typescript 复制代码
animate(animateOption?: IAnimateOption): Promise<void>;
animate(callback: () => void, animateOption?: IAnimateOption): void;
animate(params1?: IAnimateOption | (() => void), params2?: IAnimateOption): void | Promise<void> {
  const [animateOption, callback] = this.animateFuncParamsMerge(params1, params2);
  // 如果有新的配置项,更新配置
  if (animateOption) {
    this.animateOption = animateOption;
  }
  return super.runAnimate(callback);
}

其中 animateFuncParamsMerge 方法是进行了参数归一化处理。

至此核心逻辑就实现了,之前在使用文档中,还提到了在动画不再使用时,需要调用 destroy 方法销毁实例。

其实最初是没有这一步的,想着在动画结束后内部销毁实例,清除相关状态。但是考虑到创建一次动画实例,也有可能进行多次动画,因此不能立即销毁实例。

但是这样一来,为了避免对同一个 DOM 元素创建多个实例,或是一次动画结束前,触发下一次动画等,诸多可能造成紊乱或 bug 的问题,使用 WeakSet 保存当前已创建动画实例的 DOM 对象,在 destroy 方法中销毁实例时,并移除 DOM 对象,清除相关状态。

这样的话,虽然使用起来多了一步,但是也避免了很多潜在的问题。

除此之外,还提供了 pause 方法,用来暂停动画;resume 方法,用来恢复动画。

至此,Flip 类的核心逻辑就实现了。又考虑到这类动画通常都是在列表中,多个元素同时需要动画,于是编写了一个包装类,支持同时传入多个 DOM 对象。

3. 支持多个元素

实现 FlipFactory 类,支持同时传入多个 DOM 对象,每个对象创建一个 Flip 实例。

ts 复制代码
class FlipFactory implements IAnimateFunc, IAnimationMethods {
  flips?: Flip[] = [];
  constructor(
    el: HTMLElement | HTMLElement[] | NodeList | HTMLCollection | Element[],
    animateOption: IAnimateOption,
    otherStyleKeys: string[] = []
  ) {
    if (el instanceof HTMLElement) {
      this.flips = [new Flip(el, animateOption, otherStyleKeys)];
    } else if (
      el instanceof NodeList ||
      el instanceof HTMLCollection ||
      Array.isArray(el)
    ) {
      for (let i = 0; i < el.length; i++) {
        this.flips.push(new Flip((el[i] as HTMLElement), animateOption, otherStyleKeys));
      }
    } else {
      throw new Error("FlipFactory: el must be HTMLElement or HTMLElement[] or NodeList or HTMLCollection");
    }
  }

  // ...
}

核心逻辑很简单,就是在构造函数中,根据传入的参数,创建一个或多个 Flip 实例。

然后重写其他几个方法,调用每个实例的方法。具体就不再赘述。大致就是这样,有问题的朋友欢迎查阅源码和评论区讨论。


写在最后

现在 AI 工具的流行,极大的提高了工作效率,但也让基础知识和底层原理的学习变得似乎无关紧要。

但相信,基础的东西,还是要有的。欢迎喜欢讨论知识和钻研原理的朋友一起交流!🛫

相关推荐
仿生狮子13 分钟前
OpenAI 攻下千禧年难题之后,开发者似乎无动于衷
前端·数学·vibecoding
Lvan的前端笔记3 小时前
docker:每个前端项目一个 Nginx 容器还是只有一个Nginx容器
前端·nginx·docker
三天不学习4 小时前
Egg.js 4 突然爆火,原因是否归结于AI 原生落地需求爆发?
前端·javascript·全栈·egg.js
xcs194055 小时前
前端 vue 的前端页面debugger 进不去
前端·javascript·vue.js
明月_清风6 小时前
Deno 终局来了:从挑战 Node 到被 Cloudflare 收编
前端·后端·node.js
Csvn7 小时前
框架性能优化
前端
回眸&啤酒鸭8 小时前
【回眸】OpenSwarm 多智能体协作系统实战指南
大数据·前端·人工智能
用户69371750013848 小时前
2026,程序员的时代拐点到了
android·前端·后端
大龄秃头程序员8 小时前
一次 iBeacon + BLE 无感解锁方案的实现记录
前端
小兔子9 小时前
Python 的 GIL 与 free-threading:3.13 之后「去 GIL」走到哪一步了
前端