FLIP 动画策略:让你的布局变化更加丝滑

5 阅读6分钟

大家写交互动效时,最容易遇到一种尴尬:布局变了,动画却卡了

比如:列表排序、卡片展开、Tab 切换、按钮滑块移动……如果我们直接去动画 top/left/width/height,浏览器往往要频繁 Layout(重排)→ Paint(重绘),轻则掉帧,重则“整页抖”。

这篇文章我们用一个简单但非常工程化的策略——FLIP,把“昂贵的布局动画”转成“便宜的 transform 动画”,让动效又稳又顺。💡


目录


什么是 FLIP?

FLIPFirst / Last / Invert / Play 的缩写,是一种实现 高性能布局过渡动画 的策略。

核心思路一句话概括:

✅ 我们先让布局“直接变到最终态”,再用 transform 把元素“拉回起点”,最后播放 transform 回到 0 的动画。

为什么这样做更快?⚠️

  • width/height/top/left/margin 往往会触发 Layout(并可能连带整片区域重绘)。
  • transform/opacity 通常只走 Composite(合成),更多由 GPU 参与,帧率更稳。

FLIP 四步法:First / Last / Invert / Play

我们把它拆成四步,写代码时基本就是照着这个流程走:

  1. First(初始态):读取元素当前几何信息(位置/尺寸)。
  2. Last(最终态):先更新 DOM 状态/布局,再读取更新后的几何信息。
  3. Invert(反转):计算从 First 到 Last 的差值,用 transform 把元素“反向挪回去”。
  4. 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-contentflex-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
  • 适合场景:排序/插入/展开折叠/布局切换等“元素最终位置由布局决定”的动效。