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.transitionel.style.transform 实现动画效果。
  6. 监听动画结束事件。在动画结束后,清除 el.style.transformel.style.transition,确保下次动画能够正常进行。

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

通用动画方法的实现

动画核心方法

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

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

元素位置信息

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

  1. getBoundingClientRect
  2. el.style.topel.style.left
  3. getComputedStyle
  4. offsetTopoffsetLeft

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

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

offsetTopoffsetLeft 是过去常用的访问元素位置的方法,访问的是布局树,但是相对的是 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 工具的流行,极大的提高了工作效率,但也让基础知识和底层原理的学习变得似乎无关紧要。

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

相关推荐
争当第一摸鱼前端2 小时前
Electron中的下载操作
前端
sjin2 小时前
React 源码 - Commit Phase 的工作细节
前端
烛阴2 小时前
【TS 设计模式完全指南】TypeScript 装饰器模式的优雅之道
javascript·设计模式·typescript
FisherYu2 小时前
AI环境搭建pytorch+yolo8搭建
前端·计算机视觉
YaeZed3 小时前
TypeScript7(元组)
typescript
学前端搞口饭吃3 小时前
react reducx的使用
前端·react.js·前端框架
闲云野鹤_3 小时前
typeScript学习笔记总结(常用)
typescript
aidingni8883 小时前
掌握 JavaScript 中的 Map 和 Set
前端·javascript
之恒君3 小时前
TypeScript(tsconfig.json - references)
typescript