FLIP 动画策略的理解
最近在学习前端动画相关的内容,发现一个叫做FLIP的动画策略,感觉挺有意思的,记录一下✏️
什么是FLIP?
FLIP 是 First , Last , Invert , Play 的缩写,是一种用于实现高性能动画的技术。它通过计算元素在动画开始和结束时的位置和大小变化,然后应用相应的转换来实现平滑的动画效果。
在浏览器中,改变 width, height, top, left 等属性会触发 Layout(重排),这非常消耗性能。FLIP 的核心是将这些昂贵的改变,转化为性能极高的 transform 动画。
FLIP的四个步骤
- First-初始状态:记录元素在动画开始时的位置和大小。
- Last-最终状态:记录元素在动画结束时的位置和大小。
- Invert-反转状态:计算出从 First 到 Last 的变化,并应用相应的反向转换,使元素看起来仍然在原来的位置。
- Play-播放动画:应用转换,并触发浏览器的重绘,开始动画。
示例代码
下面是一个简单的 FLIP 动画示例:
<div id="box" style="width:100px; height:100px; background-color:blue; position:absolute;"></div>
<button id="moveBtn">Move Box</button>const box = document.getElementById("box")
const moveBtn = document.getElementById("moveBtn")
let toggled = false
moveBtn.addEventListener("click", () => {
// First
const firstRect = box.getBoundingClientRect()
// Toggle position
toggled = !toggled
box.style.marginLeft = toggled ? "200px" : "0px"
box.style.marginTop = toggled ? "200px" : "0px"
// Last
const lastRect = box.getBoundingClientRect()
// Invert
const deltaX = firstRect.left - lastRect.left
const deltaY = firstRect.top - lastRect.top
box.style.transition = "none"
box.style.transform = `translate(${deltaX}px, ${deltaY}px)`
// Play
requestAnimationFrame(() => {
box.style.transition = "transform 0.5s ease"
box.style.transform = "translate(0, 0)"
})
})实战-switch按钮
下面代码将使用 FLIP 技术实现了一个切换按钮的动画效果。点击按钮时,按钮会平滑地移动到新的位置,而不是突然跳跃。
为了方便代码书写,我将会使用tailwindcss来进行样式的编写。
<div class="h-screen flex justify-center items-center">
<div
id="toggleBtn"
class="w-38 h-20 rounded-full bg-black p-2 flex items-center justify-start cursor-pointer transition-colors duration-300"
>
<div id="indicator" class="bg-white size-16 rounded-full shadow-md"></div>
</div>
</div>const toggleBtn = document.getElementById("toggleBtn")
const indicator = document.getElementById("indicator")
toggleBtn.onclick = () => {
// First: 记录动画开始前的位置
const firstRect = indicator.getBoundingClientRect()
// updateState: 改变布局/状态
const currentJustify = toggleBtn.style.justifyContent || getComputedStyle(toggleBtn).justifyContent
const isStart = currentJustify === "flex-start" || currentJustify === "normal"
toggleBtn.style.justifyContent = isStart ? "flex-end" : "flex-start"
if (isStart) {
toggleBtn.classList.remove("bg-black")
toggleBtn.classList.add("bg-green-500")
} else {
toggleBtn.classList.add("bg-black")
toggleBtn.classList.remove("bg-green-500")
}
// Last: 记录布局改变后的新位置
const lastRect = indicator.getBoundingClientRect()
// Invert: 计算位置差值 (从哪里来 - 到哪里去)
const dx = firstRect.left - lastRect.left
const dy = firstRect.top - lastRect.top
// Play: 执行动画
indicator.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], {
duration: 300,
// 使用一个更有弹性的贝塞尔曲线,显得更顺滑
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
})
}小拓展-FLIP 动画辅助函数封装
为了更方便地使用 FLIP 动画,我们可以将其封装成一个通用的函数:
/**
* FLIP 动画辅助函数
* @param {Element} dom - 需要执行动画的 DOM 元素
* @param {() => void} updateState - 更新 DOM 状态的回调函数(修改样式、DOM结构等)
* @param {number | KeyframeAnimationOptions} [options] - 动画配置,支持传入数字(duration)或对象
* @returns {Animation} - 返回 Animation 对象,方便外部控制(如 .finished.then())
*/
export default function flipAnimate(dom, updateState, options = {}) {
// 参数归一化:设置默认参数和处理 options 可能是数字的情况
const defaultOptions = {
duration: 300,
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
fill: "both", // 防止动画结束闪烁,默认保持结束状态
}
let finalOptions = { ...defaultOptions }
if (typeof options === "number") {
finalOptions.duration = options
} else if (typeof options === "object") {
finalOptions = { ...defaultOptions, ...options }
}
// 1. First: 记录初始状态
const firstRect = dom.getBoundingClientRect()
// State: 执行状态更新
updateState()
// 2. Last: 记录最终状态
const lastRect = dom.getBoundingClientRect()
// 3. Invert: 计算变换差值
const dx = firstRect.left - lastRect.left
const dy = firstRect.top - lastRect.top
// 如果位置没有变化,直接返回(性能优化,避免不必要的图层提升)
if (dx === 0 && dy === 0) return
// 4. Play: 执行动画
const player = dom.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
finalOptions,
)
return player
}上面的实战案例使用这个函数,我们可以更简洁地实现 FLIP 动画:
const toggleBtn = document.getElementById("toggleBtn")
const indicator = document.getElementById("indicator")
toggleBtn.onclick = () => {
// 回调函数只关心状态更新,不关心动画细节
// 动画细节完全由 flipAnimate 函数内部处理
flipAnimate(btn, () => {
// updateState: 改变布局/状态
const currentJustify = toggleBtn.style.justifyContent || getComputedStyle(toggleBtn).justifyContent
const isStart = currentJustify === "flex-start" || currentJustify === "normal"
toggleBtn.style.justifyContent = isStart ? "flex-end" : "flex-start"
if (isStart) {
toggleBtn.classList.remove("bg-black")
toggleBtn.classList.add("bg-green-500")
} else {
toggleBtn.classList.add("bg-black")
toggleBtn.classList.remove("bg-green-500")
}
})
}⚠️tips:
element.getBoundingClientRect()会触发 Layout (重排)
🤔思考:为什么要这么麻烦?
如果你直接给一个元素设置 height: 200px 变到 height: 400px 的动画:
浏览器压力大: 每一帧都要重新计算布局,容易掉帧。
影响周围: 该元素变大,会挤开周围的元素,导致全页重排。
FLIP 的优势:
所有的位移和缩放都由 GPU 处理(transform 不触发重排)。
浏览器只需要在动画开始前计算两次布局(F 和 L),动画过程中完全是合成器层面的位移。
额外拓展
element.animate()
element.animate() 是原生 JavaScript Web Animations API (WAAPI) 的核心方法。它允许你在 JS 中以高性能的方式创建动画,这比使用 CSS 类名切换动画更灵活,又比使用 requestAnimationFrame 手写每一帧更简单。
基本语法
element.animate(keyframes, options)- keyframes (关键帧数组): 定义动画的步骤,类似于 CSS 的 @keyframes。
- options (配置对象): 定义动画的时间、速度、重复次数等
常用的 Options 属性
- duration: 动画持续时间(毫秒)
- easing: 动画的缓动函数(如 "ease", "linear", "cubic-bezier(0.34, 1.56, 0.64, 1)")
- fill: 定义动画结束后的状态(如 "forwards", "backwards", "both", "none")
- iterations: 动画重复的次数(可以是数字或 Infinity)
- delay: 动画开始前的延迟时间(毫秒)
- direction: 动画的播放方向(如 "normal", "reverse", "alternate", "alternate-reverse")
- playbackRate: 动画的播放速度(默认值为 1)
- ...
图层提升
“图层提升”(Layer Promotion),或者叫“复合层提升”(Composite Layer Promotion),是浏览器渲染原理中的一个核心概念,直接关系到网页动画的性能(FPS)。
简单来说,就是浏览器为了让某个元素动得更流畅,把它单独拎出来放到一个新的“显卡图层”上进行绘制,避免干扰到其他元素。
在使用 FLIP 动画时,浏览器往往会把动画元素提升到一个独立的复合图层(composite layer)。这样 GPU 可以更专注地处理动画合成,而主线程则尽量避免被布局计算和重绘拖慢——这也是 FLIP 通常比直接做布局动画更流畅的原因。
下面将按照我的理解,分几点来解释图层提升的原理:
1)通俗比喻:透明幻灯片
想象你在画一幅画(渲染网页):
- 普通渲染(无图层提升):相当于在一张白纸上画画。你想移动画里的“小人”,就得擦掉重画,或者把旧位置涂白再画到新位置——慢,容易卡。
- 图层提升:相当于把背景画在白纸上,把“小人”画在一张**透明的幻灯片(图层)**上,再叠在白纸上。
- 当你要移动“小人”时,只需要移动这张“幻灯片”,不需要重画背景;这件事交给 GPU 做会快很多。
2)浏览器渲染流水线:为什么 transform 更快?
标准的渲染流程大致是:
- Layout(重排/回流):计算元素的位置和大小
- Paint(重绘):把像素画出来(颜色、文字、阴影等)
- Composite(合成):把各个图层叠在一起显示到屏幕上
两种常见情况对比:
- 没有独立图层(普通文档流):
- 改
left/top往往会触发 Layout + Paint - CPU 需要重新计算并绘制受影响的区域,像“在纸上擦了重画”
- 改
- 被提升为独立图层:
- 改
transform/opacity通常可以跳过 Layout + Paint,直接走 Composite - 浏览器告诉 GPU:“把这张纹理往右移 100px”,GPU 处理很快,CPU 压力更小
- 改
3)如何触发图层提升?(按需使用)
并不是所有元素都会自动变成独立图层(图层会占显存/内存,太多会炸)。常见触发方式:
- 使用 3D/透视相关的 transform(最常用)
transform: translate3d(0, 0, 0);
transform: translateZ(0);will-change(明确告诉浏览器“我准备动了”)
will-change: transform;- 正在进行
opacity/transform动画- 当你的
dom.animate(...)开始运行时,浏览器检测到你在动transform,通常会出于性能考虑自动提升该元素
- 当你的
- 其他情况:
<video>、<canvas>、WebGL(3D 上下文)等
4)为什么要避免“不必要的提升”?
回到上述封装的 flipAnimate 函数:如果位置没变,你却依然执行了 dom.animate(...)(即使从 (0,0) 动到 (0,0)):
- 浏览器可能仍然认为你要做动画
- 于是给元素分配显存、创建新图层
- 把元素内容从主文档“抠”出来绘制到新图层(这可能引发一次重绘)
- 动画结束后再把图层合并回去
对肉眼完全看不出变化的动作来说,这一套就是纯浪费。因此 dx === 0 && dy === 0 return 的意义是:“没事,不用动,别折腾内存与合成树。”
5)举个例子:hover 光晕动画
- 反面教材(滥用提升):给页面上 1000 个列表项都加
will-change: transform- 后果:Chrome 可能开出大量显存图层,内存飙升、手机发烫,甚至崩溃
- 优雅做法(按需提升):只在真正需要动画的时机(hover 到某个 item / 触发 FLIP 位移)再提升
总结
图层提升是一种用内存(显存)换时间(CPU 计算)的策略;因此要“按需使用”,避免无意义的提升造成额外开销。