FLIP 动画技术深度解析与实战应用
Created on
前言
在现代 Web 开发中,流畅的动画效果是提升用户体验的重要手段。FLIP(First, Last, Invert, Play)技术通过巧妙的实现方式,能够创建高性能、视觉流畅的动画效果。本文将深入探讨 FLIP 技术的原理、优势和实战应用。
推荐阅读:David Khourshid 的文章 Animating Layouts with the FLIP Technique
什么是 FLIP?
FLIP 是一种动画技术,通过四个关键步骤创建流畅的动画效果:
四个步骤详解
F - First(首帧) → 记录元素初始状态
L - Last(末帧) → 记录元素最终状态
I - Invert(反转) → 计算状态差异并应用反向变换
P - Play(播放) → 播放动画回到最终状态
1. First(首帧)
在任何 DOM 变化发生之前,记录元素的初始状态。
const element = document.querySelector(".box");
const first = element.getBoundingClientRect();
console.log("初始状态:", {
left: first.left,
top: first.top,
width: first.width,
height: first.height,
});
2. Last(末帧)
执行 DOM 变化(如添加 class、改变样式等),然后记录元素的最终状态。
// 执行 DOM 变化
element.classList.add("moved");
// 记录最终状态
const last = element.getBoundingClientRect();
console.log("最终状态:", {
left: last.left,
top: last.top,
width: last.width,
height: last.height,
});
3. Invert(反转)
计算初始状态和最终状态的差异,然后将元素”反转”回初始位置。
// 计算位置差异
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
// 计算缩放差异
const deltaW = first.width / last.width;
const deltaH = first.height / last.height;
// 应用反向变换(此时元素视觉上回到了初始位置)
element.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(${deltaW}, ${deltaH})
`;
element.style.transformOrigin = "top left";
4. Play(播放)
移除反向变换,让元素通过 CSS transition 动画到最终状态。
// 启用过渡
element.style.transition = "transform 0.3s ease-out";
// 下一帧移除 transform(触发动画)
requestAnimationFrame(() => {
element.style.transform = "";
});
完整示例
基础 FLIP 动画实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FLIP 动画示例</title>
<style>
.container {
display: flex;
gap: 20px;
padding: 20px;
}
.box {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.box.moved {
transform: translateX(300px) scale(1.5);
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
}
</style>
</head>
<body>
<button onclick="flipAnimate()">触发 FLIP 动画</button>
<div class="container">
<div class="box" id="flipBox"></div>
</div>
<script>
function flipAnimate() {
const element = document.getElementById("flipBox");
// F - First: 记录初始状态
const first = element.getBoundingClientRect();
// L - Last: 改变状态并记录
element.classList.toggle("moved");
const last = element.getBoundingClientRect();
// I - Invert: 计算并应用反向变换
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
const deltaW = first.width / last.width;
const deltaH = first.height / last.height;
element.style.transformOrigin = "top left";
element.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(${deltaW}, ${deltaH})
`;
// P - Play: 播放动画
requestAnimationFrame(() => {
element.style.transition =
"transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
element.style.transform = "";
});
// 清理
element.addEventListener(
"transitionend",
() => {
element.style.transition = "";
},
{ once: true },
);
}
</script>
</body>
</html>
封装 FLIP 工具类
class FLIP {
constructor(element, options = {}) {
this.element = element;
this.options = {
duration: 300,
easing: "cubic-bezier(0.25, 0.46, 0.45, 0.94)",
...options,
};
}
// 记录初始状态
first() {
this.firstRect = this.element.getBoundingClientRect();
return this;
}
// 记录最终状态
last() {
this.lastRect = this.element.getBoundingClientRect();
return this;
}
// 反转
invert() {
const deltaX = this.firstRect.left - this.lastRect.left;
const deltaY = this.firstRect.top - this.lastRect.top;
const deltaW = this.firstRect.width / this.lastRect.width;
const deltaH = this.firstRect.height / this.lastRect.height;
this.element.style.transformOrigin = "top left";
this.element.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(${deltaW}, ${deltaH})
`;
return this;
}
// 播放动画
play(callback) {
return new Promise((resolve) => {
requestAnimationFrame(() => {
this.element.style.transition = `transform ${this.options.duration}ms ${this.options.easing}`;
this.element.style.transform = "";
const onEnd = () => {
this.element.style.transition = "";
if (callback) callback();
resolve();
};
this.element.addEventListener("transitionend", onEnd, { once: true });
});
});
}
// 一键执行完整流程
static animate(element, changeFunc, options) {
const flip = new FLIP(element, options);
// First
flip.first();
// 执行变化
changeFunc();
// Last
flip.last();
// Invert & Play
flip.invert();
return flip.play();
}
}
// 使用示例
const box = document.querySelector(".box");
FLIP.animate(box, () => box.classList.toggle("moved"), { duration: 500 }).then(
() => {
console.log("动画完成");
},
);
实战应用场景
场景 1:列表重排动画
<style>
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item {
padding: 15px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>
<div class="list" id="list">
<div class="list-item">Item 1</div>
<div class="list-item">Item 2</div>
<div class="list-item">Item 3</div>
<div class="list-item">Item 4</div>
</div>
<script>
function reorderList() {
const list = document.getElementById("list");
const items = Array.from(list.children);
// First: 记录所有元素的初始位置
const firstPositions = items.map((item) => ({
element: item,
rect: item.getBoundingClientRect(),
}));
// Last: 打乱顺序
items.sort(() => Math.random() - 0.5);
items.forEach((item) => list.appendChild(item));
// Last: 记录新位置
const lastPositions = items.map((item) => ({
element: item,
rect: item.getBoundingClientRect(),
}));
// Invert & Play
firstPositions.forEach((first, index) => {
const last = lastPositions[index];
const deltaX = first.rect.left - last.rect.left;
const deltaY = first.rect.top - last.rect.top;
// Invert
last.element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Play
requestAnimationFrame(() => {
last.element.style.transition = "transform 0.4s ease-out";
last.element.style.transform = "";
});
});
}
</script>
场景 2:卡片展开动画
class CardExpand {
constructor(card) {
this.card = card;
this.isExpanded = false;
}
toggle() {
const flip = new FLIP(this.card);
// First
flip.first();
// 切换展开状态
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.card.classList.add("expanded");
} else {
this.card.classList.remove("expanded");
}
// Last
flip.last();
// Invert & Play
flip.invert();
flip.play();
}
}
// 使用
const card = document.querySelector(".card");
const cardExpand = new CardExpand(card);
card.addEventListener("click", () => {
cardExpand.toggle();
});
场景 3:图片画廊
class ImageGallery {
constructor(container) {
this.container = container;
this.images = container.querySelectorAll(".gallery-image");
this.setupEventListeners();
}
setupEventListeners() {
this.images.forEach((image, index) => {
image.addEventListener("click", () => {
this.expandImage(image, index);
});
});
}
expandImage(image, index) {
// 创建全屏遮罩
const overlay = document.createElement("div");
overlay.className = "overlay";
document.body.appendChild(overlay);
// 创建放大的图片
const expandedImage = image.cloneNode(true);
expandedImage.className = "expanded-image";
overlay.appendChild(expandedImage);
// FLIP 动画
const flip = new FLIP(expandedImage);
// First
const firstRect = image.getBoundingClientRect();
expandedImage.style.position = "fixed";
expandedImage.style.left = firstRect.left + "px";
expandedImage.style.top = firstRect.top + "px";
expandedImage.style.width = firstRect.width + "px";
expandedImage.style.height = firstRect.height + "px";
flip.first();
// Last
const padding = 40;
expandedImage.style.left = padding + "px";
expandedImage.style.top = padding + "px";
expandedImage.style.width = `calc(100vw - ${padding * 2}px)`;
expandedImage.style.height = `calc(100vh - ${padding * 2}px)`;
flip.last();
// Invert & Play
flip.invert();
flip.play();
// 点击遮罩关闭
overlay.addEventListener("click", () => {
this.closeImage(expandedImage, image, overlay);
});
}
closeImage(expandedImage, originalImage, overlay) {
const flip = new FLIP(expandedImage);
// First
flip.first();
// Last
const lastRect = originalImage.getBoundingClientRect();
expandedImage.style.left = lastRect.left + "px";
expandedImage.style.top = lastRect.top + "px";
expandedImage.style.width = lastRect.width + "px";
expandedImage.style.height = lastRect.height + "px";
flip.last();
// Invert & Play
flip.invert();
flip.play().then(() => {
overlay.remove();
});
}
}
// 使用
new ImageGallery(document.querySelector(".gallery"));
场景 4:拖拽排序
class DraggableList {
constructor(listElement) {
this.list = listElement;
this.items = Array.from(listElement.children);
this.setupDragging();
}
setupDragging() {
this.items.forEach((item) => {
item.draggable = true;
item.addEventListener("dragstart", (e) => {
this.draggedItem = item;
e.dataTransfer.effectAllowed = "move";
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
const afterElement = this.getDragAfterElement(e.clientY);
if (afterElement == null) {
this.list.appendChild(this.draggedItem);
} else {
this.list.insertBefore(this.draggedItem, afterElement);
}
});
item.addEventListener("dragend", () => {
this.animateReorder();
});
});
}
getDragAfterElement(y) {
const draggableElements = [
...this.list.querySelectorAll(":not(.dragging)"),
];
return draggableElements.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
},
{ offset: Number.NEGATIVE_INFINITY },
).element;
}
animateReorder() {
const items = Array.from(this.list.children);
items.forEach((item) => {
const flip = new FLIP(item);
flip.first();
// 触发重排(浏览器会自动处理)
requestAnimationFrame(() => {
flip.last();
flip.invert();
flip.play();
});
});
}
}
// 使用
new DraggableList(document.querySelector(".draggable-list"));
FLIP 的优势
1. 性能优化
FLIP 动画主要使用 transform 和 opacity,这两个属性:
- ✅ 不会触发回流(Reflow)
- ✅ 不会触发重绘(Repaint)
- ✅ 可以利用 GPU 加速
- ✅ 在合成线程上运行
// ❌ 性能差:会触发回流
element.style.width = "200px";
element.style.left = "100px";
// ✅ 性能好:只触发合成
element.style.transform = "translateX(100px) scaleX(2)";
2. 视觉流畅
FLIP 能够创建视觉上连贯的动画,即使元素的布局发生了复杂变化。
3. 代码简洁
相比手动计算和编写 keyframes,FLIP 的代码更加简洁直观。
4. 灵活性
可以轻松应用于各种布局变化场景。
FLIP 的局限性
1. 实现复杂度
需要准确捕获和计算元素状态,对于复杂布局可能需要额外处理。
// 需要考虑各种边界情况
if (!firstRect || !lastRect) {
console.warn("无法获取元素位置");
return;
}
// 需要处理父元素的 transform
const parentTransform = getComputedStyle(element.parentElement).transform;
// ...
2. 计算开销
对于大量元素同时动画,计算 getBoundingClientRect() 可能有性能影响。
优化方案:
// 批量读取(避免布局抖动)
const positions = elements.map((el) => ({
element: el,
first: el.getBoundingClientRect(),
}));
// 执行变化
changeLayout();
// 批量读取
positions.forEach((pos) => {
pos.last = pos.element.getBoundingClientRect();
});
// 应用动画
positions.forEach((pos) => {
// FLIP 动画
});
3. 浏览器兼容性
部分老旧浏览器可能不支持某些 CSS 特性。
解决方案:
// 检测支持
const supportsTransform = "transform" in document.body.style;
if (!supportsTransform) {
// 降级方案
element.style.left = targetLeft + "px";
element.style.top = targetTop + "px";
}
4. 学习曲线
相比简单的 CSS transition,FLIP 需要理解更多概念。
性能优化建议
1. 使用 will-change
.animating {
will-change: transform;
}
// 动画前添加
element.style.willChange = "transform";
// 动画后移除
element.addEventListener(
"transitionend",
() => {
element.style.willChange = "";
},
{ once: true },
);
2. 避免布局抖动
// ❌ 错误:读写交替
elements.forEach((el) => {
const rect = el.getBoundingClientRect(); // 读
el.style.transform = `...`; // 写
});
// ✅ 正确:批量读写
const rects = elements.map((el) => el.getBoundingClientRect()); // 批量读
elements.forEach((el, i) => {
el.style.transform = `...`; // 批量写
});
3. 使用 requestAnimationFrame
// 确保在下一帧执行
requestAnimationFrame(() => {
requestAnimationFrame(() => {
element.style.transform = "";
});
});
4. 节流大量元素
// 限制同时动画的元素数量
const BATCH_SIZE = 10;
const batches = [];
for (let i = 0; i < elements.length; i += BATCH_SIZE) {
batches.push(elements.slice(i, i + BATCH_SIZE));
}
// 分批动画
batches.forEach((batch, index) => {
setTimeout(() => {
batch.forEach((element) => {
FLIP.animate(element, () => {
// 变化
});
});
}, index * 50); // 错开时间
});
推荐库
如果不想手动实现 FLIP,可以使用这些优秀的库:
1. Framer Motion(React)
import { motion } from "framer-motion";
<motion.div layout>
{items.map((item) => (
<motion.div key={item.id} layout>
{item.content}
</motion.div>
))}
</motion.div>;
2. Auto-Animate
import autoAnimate from "@formkit/auto-animate";
const list = document.querySelector(".list");
autoAnimate(list);
// 之后的任何 DOM 变化都会自动动画
3. FLIP.js
import Flip from "flip-toolkit";
const flipper = new Flipper({
element: container,
duration: 300,
});
flipper.recordBeforeUpdate();
// DOM 变化
flipper.update();
4. Vue 的内置 transition-group
<template>
<transition-group name="list" tag="div">
<div v-for="item in items" :key="item.id">
{{ item.text }}
</div>
</transition-group>
</template>
<style>
.list-move {
transition: transform 0.3s ease;
}
</style>
总结
FLIP 动画技术是一种强大的动画实现方式:
核心优势
- ✅ 高性能:利用 transform 和 GPU 加速
- ✅ 视觉流畅:创建自然的过渡效果
- ✅ 灵活性强:适用于各种布局变化
- ✅ 用户体验好:提供连贯的视觉反馈
使用场景
- 列表重排
- 卡片展开/收起
- 图片画廊
- 拖拽排序
- 页面转场
- 组件动画
最佳实践
- 批量读写:避免布局抖动
- 使用 will-change:提前告知浏览器
- 清理资源:动画结束后移除 transition
- 降级处理:考虑浏览器兼容性
- 性能监控:对大量元素进行节流
记住:FLIP 不是万能的,但在处理布局动画时,它是最优雅的解决方案之一。