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

之前分享过一篇关于 flip 动画的文章,介绍了我编写的fan-flip-js
如何使用。
文章链接:Flip-js 优雅的处理元素结构变化的动画 - 掘金
今天来聊一聊究竟什么是 FLIP 动画思想,分享一下具体是如何实现的,以及其中一些从设计模式出发的考虑和 TS 实现的技巧。
什么是 FLIP 动画?
CSS3 出现以后,绝大多数动画都无需使用 JS,即可实现功能并且拥有更好的性能。但是 CSS 动画仅能处理 CSS 属性值发生变化的动画,无论 tranisition
还是 animation
都需要一个 CSS 属性从一个明确的值变化到另一个明确的值。
而元素结构的变化,只能提供起始和结束的元素状态,元素所在具体是无从知晓的,当然也无法提供具体的 left/top 或 translate 属性值。这时 FLIP 动画思想就能够派上用场。
FLIP,由四个单词组成:
- First:记录元素的初始位置
- Last:记录元素的最终位置
- Invert:将元素从初始位置移动到最终位置
- 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
实现元素的移动动画。具体步骤如下:
- 记录起始位置。
const first = el.getBoundingClientRect()
- 记录结束位置。
const last = el.getBoundingClientRect()
- 移动元素回到起始位置。 设置
el.style.transform
- 强制 reflow。在这个实现中,这是比较重要的一步。读取元素的布局属性会触发浏览器的重排,重新渲染页面,让元素处于起始位置。这涉及到另一块知识,有感兴趣的朋友可以评论,我有时间可以写篇文章详细介绍一下。
- 开启动画。设置
el.style.transition
和el.style.transform
实现动画效果。 - 监听动画结束事件。在动画结束后,清除
el.style.transform
和el.style.transition
,确保下次动画能够正常进行。
要实现一个元素的 FLIP 动画并非难事,但是如果要写一个通用的动画方法,还需要在一些细节上做额外的考虑。
通用动画方法的实现
动画核心方法
最开始要思考的是两个核心部分:
- 如何记录元素的起始位置和结束位置?
- 如何实现元素的移动动画?
元素位置信息
在 JS 中获取元素位置信息的方式有多种,比较常用的有以下几种:
getBoundingClientRect
el.style.top
和el.style.left
getComputedStyle
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 工具的流行,极大的提高了工作效率,但也让基础知识和底层原理的学习变得似乎无关紧要。
但相信,基础的东西,还是要有的。欢迎喜欢讨论知识和钻研原理的朋友一起交流!🛫