大家写交互动效时,最容易遇到一种尴尬:布局变了,动画却卡了。
比如:列表排序、卡片展开、Tab 切换、按钮滑块移动……如果我们直接去动画 top/left/width/height,浏览器往往要频繁 Layout(重排)→ Paint(重绘),轻则掉帧,重则“整页抖”。
这篇文章我们用一个简单但非常工程化的策略——FLIP,把“昂贵的布局动画”转成“便宜的 transform 动画”,让动效又稳又顺。💡
目录
什么是 FLIP?
FLIP 是 First / Last / Invert / Play 的缩写,是一种实现 高性能布局过渡动画 的策略。
核心思路一句话概括:
✅ 我们先让布局“直接变到最终态”,再用
transform把元素“拉回起点”,最后播放transform回到 0 的动画。
为什么这样做更快?⚠️
- 改
width/height/top/left/margin往往会触发 Layout(并可能连带整片区域重绘)。 - 改
transform/opacity通常只走 Composite(合成),更多由 GPU 参与,帧率更稳。
FLIP 四步法:First / Last / Invert / Play
我们把它拆成四步,写代码时基本就是照着这个流程走:
- First(初始态):读取元素当前几何信息(位置/尺寸)。
- Last(最终态):先更新 DOM 状态/布局,再读取更新后的几何信息。
- Invert(反转):计算从 First 到 Last 的差值,用
transform把元素“反向挪回去”。 - Play(播放):在下一帧移除反向 transform,让它自然过渡到
transform: none。
💡小提示:getBoundingClientRect() 会触发一次布局读取(Layout),因此 FLIP 的关键是把读取次数控制在必要的两次(First + Last)。
一个最小可运行示例
先来一个“能跑就行”的 FLIP,帮助大家建立直觉。
<div id="box" style="width:100px; height:100px; background:#2563eb; 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", () => {
// 1) First
const firstRect = box.getBoundingClientRect()
// 2) State:直接切到最终布局
toggled = !toggled
box.style.marginLeft = toggled ? "200px" : "0px"
box.style.marginTop = toggled ? "200px" : "0px"
// 3) Last
const lastRect = box.getBoundingClientRect()
// 4) Invert
const dx = firstRect.left - lastRect.left
const dy = firstRect.top - lastRect.top
box.style.transition = "none"
box.style.transform = `translate(${dx}px, ${dy}px)`
// 5) Play
requestAnimationFrame(() => {
box.style.transition = "transform 0.5s ease"
box.style.transform = "translate(0, 0)"
})
})
✅ 你会发现:我们并没有“动画布局属性”,而是让布局瞬间完成,然后只动画 transform。
实战:用 FLIP 做一个 switch 滑块
接下来上点更像业务的:一个 switch 按钮。
点击时,容器的 justify-content 从 flex-start 切到 flex-end,滑块会跟着布局跳到另一侧。FLIP 的目标是:让它“看起来不是跳过去的,而是滑过去的”。✅
为了写样式更爽,我这里用 TailwindCSS(你也可以用纯 CSS)。
<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()
// State:更新布局/样式(此处会导致滑块的最终位置变化)
const currentJustify = toggleBtn.style.justifyContent || getComputedStyle(toggleBtn).justifyContent
const isStart = currentJustify === "flex-start" || currentJustify === "normal"
toggleBtn.style.justifyContent = isStart ? "flex-end" : "flex-start"
toggleBtn.classList.toggle("bg-black", !isStart)
toggleBtn.classList.toggle("bg-green-500", isStart)
// Last
const lastRect = indicator.getBoundingClientRect()
// Invert
const dx = firstRect.left - lastRect.left
const dy = firstRect.top - lastRect.top
// Play:用 WAAPI 播放 transform
indicator.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], {
duration: 300,
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
})
}
💡这里我们用的是原生 element.animate()(WAAPI)。它很适合“短小、一次性、无需写 CSS keyframes”的动效。
封装:把 FLIP 变成一个函数
当你在项目里写第二次、第三次 FLIP 时,就会开始嫌它啰嗦。
更工程化的做法是:把 First/Last/Invert/Play 封装起来,业务侧只关心“怎么更新状态”。✅
/**
* FLIP 动画辅助函数
* @param {Element} dom - 需要执行动画的 DOM 元素
* @param {() => void} updateState - 更新 DOM 状态的回调函数(修改样式、DOM 结构等)
* @param {number | KeyframeAnimationOptions} [options] - 动画配置,支持传入数字(duration)或对象
* @returns {Animation | void} - 返回 Animation,若无需动画则返回 void
*/
export default function flipAnimate(dom, updateState, options = {}) {
const defaultOptions = {
duration: 300,
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
fill: "both",
}
const finalOptions =
typeof options === "number" ? { ...defaultOptions, duration: options } : { ...defaultOptions, ...options }
// First
const firstRect = dom.getBoundingClientRect()
// State
updateState()
// Last
const lastRect = dom.getBoundingClientRect()
// Invert
const dx = firstRect.left - lastRect.left
const dy = firstRect.top - lastRect.top
if (dx === 0 && dy === 0) return
// Play
return dom.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], finalOptions)
}
回到刚才的 switch,我们可以把业务代码压缩得非常干净:
const toggleBtn = document.getElementById("toggleBtn")
const indicator = document.getElementById("indicator")
toggleBtn.onclick = () => {
flipAnimate(indicator, () => {
const currentJustify = toggleBtn.style.justifyContent || getComputedStyle(toggleBtn).justifyContent
const isStart = currentJustify === "flex-start" || currentJustify === "normal"
toggleBtn.style.justifyContent = isStart ? "flex-end" : "flex-start"
toggleBtn.classList.toggle("bg-black", !isStart)
toggleBtn.classList.toggle("bg-green-500", isStart)
})
}
⚠️注意:原草稿里这里传的是 btn,但变量并不存在;实际应该对“会移动的元素”执行 FLIP,也就是 indicator。
为什么 FLIP 值得“折腾”?
大家可能会想:
“我就动画一下
height,真的有这么严重吗?”
在简单页面可能还好,但一旦进入真实业务(列表、瀑布流、复杂组件树),布局动画的成本会迅速放大:
- 浏览器压力大:每一帧都可能触发 Layout,主线程忙到飞起。
- 影响面更大:元素尺寸变化会挤压兄弟节点,导致更多区域参与重排/重绘。
而 FLIP 的优势很明确 ✅:
- 动画过程主要是 transform 合成,对主线程更友好。
- 浏览器通常只需要在动画前后各读取一次布局(First + Last)。
拓展:WAAPI 与图层提升
element.animate()(WAAPI)
element.animate() 是原生 Web Animations API(WAAPI) 的核心入口。
适用场景:
- ✅ 动画由 JS 控制(比如 FLIP 算出来的
dx/dy) - ✅ 不想写 CSS
@keyframes - ✅ 需要拿到
Animation对象(暂停/取消/finished 等)
常见 options:
duration:持续时间(ms)easing:缓动(如cubic-bezier(...))fill:结束状态(forwards/backwards/both/none)iterations/delay/direction/playbackRate
图层提升(Layer Promotion)
图层提升可以理解成:浏览器把某个元素“单独拎到一个可合成图层”,让它的 transform/opacity 动画更容易稳定在高帧率。
一个通俗类比 💡:
- 背景画在纸上
- 角色画在透明胶片上
- 移动角色时,只移动胶片,不用重画背景
常见触发方式:
transform: translateZ(0)/translate3d(0,0,0)will-change: transform(⚠️按需使用,别给一堆元素常驻)- 元素正在进行
transform/opacity动画(浏览器可能自动提升)
为什么封装里要做 dx === 0 && dy === 0 的提前返回?✅
- 如果明明没动还强行动画,浏览器可能依然创建合成层、分配显存、做无意义的合成开销。
- 提前返回能避免“不必要的图层提升”,让性能更稳。
小结
- FLIP 的本质:把“布局变化”变成“transform 动画”。
- 最关键的动作:只读两次布局(First + Last),动画只动
transform。 - 适合场景:排序/插入/展开折叠/布局切换等“元素最终位置由布局决定”的动效。