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 动画主要使用 transformopacity,这两个属性:

// ❌ 性能差:会触发回流
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 动画技术是一种强大的动画实现方式:

核心优势

使用场景

最佳实践

  1. 批量读写:避免布局抖动
  2. 使用 will-change:提前告知浏览器
  3. 清理资源:动画结束后移除 transition
  4. 降级处理:考虑浏览器兼容性
  5. 性能监控:对大量元素进行节流

记住:FLIP 不是万能的,但在处理布局动画时,它是最优雅的解决方案之一。

参考资源