1. FLIP 是什么——先看它解决的问题
布局动画在网页里是个很微妙的东西。视觉上你只是想让某个元素从 A 点挪到 B 点,或者从一行变成两行,代码里却要处理一整套浏览器的渲染机制。直接用top/left或者width/height做过渡动画,结果往往不理想——要么一顿一顿的,要么干脆直接跳到终点,用户体验非常生硬。这是因为这些属性一旦变化,浏览器就得重新计算布局,也就是常说的回流(reflow),后续还得重绘(repaint),每一步都开销巨大,动画自然就掉帧了。
FLIP 策略就是为了解决这个痛点被提出的,它的核心思路特别直白:既然改布局属性会引发昂贵的回流,那能不能只让元素“看起来”在动,实际上靠合成层把位移、缩放这些效果交给 GPU 处理?答案就是 FLIP 四步法:First、Last、Invert、Play。这套思路最早由 Paul Lewis 推广,如今已经在各大前端团队的项目里用得非常成熟了。我在这篇文章里会把它拆开揉碎,先讲原理,再带你把核心代码写出来,最后结合几个真实场景讲讲我踩过的坑和排查技巧。整个过程不需要你有多深的图形学背景,只要会 JavaScript 和 CSS 的 transform 操作,就能上手。
适合谁看?刚接触前端动画、被“卡顿”折磨过的开发者,或者已经在团队里做动效规范、想在布局变化这件事上找到一套通用方案的人,都能从这篇里拿到可直接落地的代码和思路。我会尽量用“人话”解释每个环节的为什么,而不是只丢给你一段封装好的函数。
2. 四阶段拆解——从 First 到 Play,每一步在干什么
2.1 First 与 Last:记录前后状态,但别急着改
FLIP 的第一步是记录元素的初始位置,也就是 First:把元素的当前几何数据存下来,通常包括x、y、scaleX、scaleY、width、height这些。获取这些数据的标准方式是getBoundingClientRect(),它返回的是元素相对于视口的位置和尺寸,对于动画计算来说正好。
第二步是 Last,记录元素在布局变化之后的最终位置。这里有个容易忽略的细节:改变布局的代码(比如加 class、改样式、重新排序 DOM)必须先执行,然后要强制读取一次布局数据,才能拿到正确的 Last 值。问题在于,浏览器对样式计算和布局都做了异步批量处理,如果你在修改样式之后立即调用getBoundingClientRect(),浏览器会强制同步刷新布局,这本身就是一次成本不低的操作。所以需要让修改布局和执行动画的计算不在同一帧里完成,最常用的手法就是用requestAnimationFrame包一层。
注意:修改完布局后,如果你在同一个同步任务里直接调
getBoundingClientRect(),浏览器为了给出准确结果,会同步触发一次 layout,这一下的开销在复杂页面上不容小觑。正确做法是先改完布局,在下一帧回调里再测量 Last 值。
我一般会这样组织两个阶段:
// 1. First: 记录初始状态 const first = element.getBoundingClientRect(); // 2. 触发布局变化, 例如添加一个类 element.classList.add('expanded'); // 3. Last: 等下一帧再记录最终状态 requestAnimationFrame(() => { const last = element.getBoundingClientRect(); // 后面再算差异、做动画 });2.2 Invert:反算差值,这是整个算法的灵魂
拿到 First 和 Last 之后,就轮到最巧妙的 Invert 阶段了。这一步要做的是计算两个状态之间的变化量,然后把元素“欺骗”回原来的位置。具体的做法是这样:先算出deltaX = first.left - last.left、deltaY = first.top - last.top,再把transform设置成translate(deltaX, deltaY)。此时元素虽然在布局上已经处于终点位置,但通过 transform 的位移,它在视觉上仍然停留在起点。关键就在这里:transform 的变化不会触发重排,只会在合成阶段处理,所以开销远小于修改 top/left。
同样的思路也适用于尺寸变化。如果两个状态的宽度不同,可以算出scaleX = first.width / last.width,然后把它加进 transform 里,让元素在缩放前和缩放后看起来“什么都没变”。我这里说“看起来”,是因为实际上它已经被布局系统放到最终位置了,只是视觉上被 transform 拉回了起点,后续动画要做的,就是把这个 transform 变成“无”。
2.3 Play:把 transform 动画到 0,动画就完成了
最后一步 Play,是把上一步的临时 transform 平滑地过渡到none,让元素从“视觉起点”一路滑到“真实终点”。因为这段动画只涉及 transform,浏览器可以在合成线程上完成,帧率通常非常稳定。
实现方式有两种,一种是用 CSS transition,给元素加上transition: transform 0.3s cubic-bezier(...),然后把 transform 清零;另一种是用 WAAPI(Web Animations API)的element.animate(),这种方式更灵活,可以随时取消或监听结束事件。我用得比较多的是 WAAPI,因为它的动画回调机制更清晰,而且方便做批量管理:
element.animate( [ { transform: `translate(${deltaX}px, ${deltaY}px)` }, { transform: 'none' } ], { duration: 300, easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)' } );到这一帧,布局已经稳定在最终状态,transform 只是视觉上的偏移,动画结束后浏览器不会产生“回弹”或闪烁。这就是 FLIP 的全部执行路径——听起来不复杂,但真正在项目里用顺,需要想清楚很多边界情况。
3. 手写一个核心实现——从零搭出可复用的 FLIP 工具
3.1 最简版本代码结构
在写完整工具之前,先看一个最精简的实现。这个版本适合理解 FLIP 的主干逻辑,不处理太多边界,但足够让你跑通一个最基本的使用场景:列表里两个元素交换位置。
function flip(element, { duration = 300, easing = 'cubic-bezier(0.25, 0.8, 0.25, 1)' } = {}) { const first = element.getBoundingClientRect(); // 这里由外部在本次调用后改变布局 // 比如把元素在 DOM 里移动位置 requestAnimationFrame(() => { const last = element.getBoundingClientRect(); const deltaX = first.left - last.left; const deltaY = first.top - last.top; const scaleX = first.width / last.width ; const scaleY = first.height / last.height; element.animate( [ { transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`, transformOrigin: 'top left' }, { transform: 'none', transformOrigin: 'top left' } ], { duration, easing } ); }); }这段代码已经覆盖了 FLIP 的核心四个阶段。transformOrigin设为top left是为了让缩放方向符合直觉,否则元素会朝中心缩放,视觉上会偏离你预期的对齐位置。
3.2 批量处理:多个元素同时动
真实项目里很少只有一个元素在动,更常见的场景是一组元素同时发生变化。比如排序后的列表,可能两三个元素都换了位置,此时如果给每个元素单独做一套 First/Last 记录,代码会变得很臃肿。我会用一个数组把多个元素统一管理:
function flipAll(elements, { duration = 300 } = {}) { const firstMap = new Map(); elements.forEach((el) => { firstMap.set(el, el.getBoundingClientRect()); }); // 在此处执行布局变化 requestAnimationFrame(() => { elements.forEach((el) => { const first = firstMap.get(el); const last = el.getBoundingClientRect(); const deltaX = first.left - last.left; const deltaY = first.top - last.top; const scaleX = first.width / last.width; const scaleY = first.height / last.height; el.animate( [ { transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`, transformOrigin: 'top left' }, { transform: 'none', transformOrigin: 'top left' } ], { duration, easing: 'ease-out' } ); }); }); }3.3 进入和离开动画的处理
上面两个版本针对的是“元素还在场,只是位置或尺寸变了”。还有一种常见情况是元素被新增或删除,这时的处理思路稍有不同:
- 新增元素:Flirst 状态不存在,元素是突然出现的。可以在元素插入后,先把它设为透明度 0,然后配合
opacity过渡,让元素从无到有地显现。如果一定要做位移效果,可以给一个初始偏移量,比如从下方translateY(20px)滑入。这个偏移量不是基于 First 位置,而是你自定义的入场效果。 - 删除元素:最麻烦的地方在于,DOM 元素一旦移除,
getBoundingClientRect()就取不到值了。常规做法是先替它占位,或者用绝对定位把它拖出文档流,保持它仍然可见,记录 Last(也就是它本来的位置)之后,再给它做“离场”动画:创建一个副本放到原位置,播放 translate 或 opacity 动画,结束后再真正移除。
这里有一个实用的占位技巧:在列表项删除之前,先给它的父容器设一个和原元素一样高度宽度的占位块,等动画结束再移除占位块。好处是其他元素的布局不会发生突然的“跳动”,视觉上会更连贯。
4. 实战场景——让布局变化真正“丝滑”起来
4.1 展开折叠卡片
展开折叠大概是 FLIP 最经典的用处之一。我做一个accordion组件时,最初尝试给height设置transition,结果是展开速度一快就明显掉帧,因为 height 变化会连锁影响整个文档流的排布。后来改用 FLIP,思路变成了这样:
- 展开前记录卡片的当前位置和尺寸。
- 动态修改卡片内容区的高度(比如从 0 改成 500px),此时布局发生了变化。
- 用 FLIP 把尺寸变化做成 scaleY 的动画,让卡片感觉是“撑开”的,而不是生硬地跳变。
不过这里有一个特别容易踩的坑:scaleY 动画会让内部文字在动画过程中被拉伸变形。如果你只是纯色背景或者图片内容,拉伸问题不明显,但一有文字就会显得劣质。我的替代方案是:对卡片的“外框”使用 transform 动画,内容区则用clip-path或overflow: hidden配合固定的内部布局,这样外部看起来尺寸在变,内部文字不受拉伸影响。
还有一种方案是只对高度做过渡,但配合will-change提示浏览器优化,只是这种方式在复杂页面上仍然不如 FLIP 稳。
4.2 网格重排与响应式变化
响应式布局下,当窗口从宽屏缩窄到手机尺寸时,网格列数通常会从 4 列变成 2 列,卡片的位置会发生整体性的移动。这种情况用 FLIP 再合适不过。我第一次在 Dashboard 页面里试这个时,发现一个有意思的问题:窗口尺寸连续变化时,每触发一次断点,就会执行一次 FLIP 动画,结果导致卡片在缩放窗口的过程中不断“滑来滑去”,体验反而更差。
解决方法是只在断点切换完成之后做动画,或者在窗口调整结束后统一执行一次 FLIP,这可以通过防抖(debounce)来实现。归根结底,FLIP 适合的是“确定性的布局变化”,而不是用户持续拖拽造成的连续重排。
4.3 拖拽排序
拖拽排序是 FLIP 的另一个大显身手的地方。当卡片被拖到新的位置时,其他卡片会腾出空位:它们的位置发生了瞬间变化,如果直接跳变会显得非常生硬。做法是:在拖拽过程中,每当目标位置变化时,把所有受影响的卡片执行一次 FLIP,让它们以动画方式“滑”到新位置。
这里还要兼顾被拖拽元素本身:它通常被设为position: fixed或绝对定位,脱离文档流,直接跟随鼠标;释放时再把它的位置映射回文档流,并同时触发其他元素的 FLIP。整个过程配合得好的话,效果很接近原生 App 的手感,完全不像网页。
4.4 列表排序的按钮操作
一个更简单的场景:列表里有两个按钮,点击“上移”“下移”让某个条目换位置。这种场景用 FLIP 的收益非常明显——排序算法改好 DOM 后,只需要做一次flipAll,就能让所有移动的条目同时滑到位,代码量少且效果整齐。我第一次实践这个场景的时候,发现之前手动算top/left的过渡动画不但代码冗长,还会因为事件帧和动画帧不同步出现抖动。FLIP 用 transform 统一解决之后,这类问题几乎绝迹。
5. 细节、性能与兼容性——那些会影响成败的关键点
5.1 用 ResizeObserver 替代手动测量
如果布局变化的触发源不只是你自己的代码,比如用户改变了浏览器窗口宽度、字体缩放或者其他不确定因素,那么手动记录 First/Last 的时机就不太靠谱。这时候可以用ResizeObserver监听容器尺寸,尺寸一变就自动重新做 FLIP。ResizeObserver 的兼容性在现代浏览器里已经很好了,是比纯靠事件监听更稳的方案。
5.2 别让 transform 和其他效果打架
如果你在元素上原本就有持续的 transform 动画,比如一个循环的浮动效果,那么在 Invert 阶段给元素设置 transform 会把它原来的 transform 覆盖掉。处理方式是先把原来的 transform 存起来,在 Play 阶段结束后恢复,或者在动画过程中把原来的 transform 合并进偏移量。听起来简单,实际操作时很容易漏,尤其在团队协作的代码里,其他成员不一定知道你这里有 FLIP。
提示:FLIP 动画执行期间,尽量不要给同一元素同时叠加多个动画控制源。WAAPI 的
element.animate()不会自动和其他动画协调,多次调用可能会导致后加的动画无视之前的。
5.3 降低测量损耗
虽然 FLIP 把动画阶段的性能问题解决了,但 First/Last 的记录始终需要读取布局。单次getBoundingClientRect()并不贵,但如果你有一千个元素要记录,累积的布局读取就会造成卡顿。这里有两个办法:
- 只测量可见区域的元素,配合
IntersectionObserver做懒处理。 - 不必每个元素单独测量,如果它们共享同一套变化规则,可以做“代理测量”:只测量一个参照元素,其余元素用相对偏移推算。
在实际项目中,第一种做法已经能解决大部分问题。特别是长列表,第一屏之外的元素并不需要动画,它们直接等待布局稳定就行了。
5.4 子像素和缩放导致的边缘缝隙
宽度的比例在计算时经常会出现小数点,比如 0.33333px 这种。如果两个元素并排,缩放动画过程中可能产生一条细细的缝隙,看起来像“漏油”。解决办法是尽量用整数像素存储 First/Last,或者在缩放结束后对元素做一次will-change: transform的强制合成处理,把边缘柔化掉。亲密接触的两个元素,还可以在动画期间给它们加一个同色背景或border-radius,掩盖缝隙。
5.5 兼容性与降级策略
FLIP 本身的 API 都是基于老的 DOM 和 CSS 能力:getBoundingClientRect、requestAnimationFrame、transform、transition,这些在现代浏览器里没有任何门槛。真正要留意的是 WAAPI 的element.animate()在旧版 Safari 里的支持情况。如果需要兼容很老的浏览器,可以先检测element.animate是否存在,不存在时退化为直接设置样式不播放动画,或者用 CSS transition 作为替代。这种降级策略成本很低,但能给用户兜底体验,值得在封装工具时就内置进去。
6. 常见问题与排查经验——我在项目里踩过的坑
6.1 动画一帧闪烁,像是先跳到了终点
这个现象通常发生在 Play 阶段之前,元素被一眼看到处于“终点位置”。排查顺序如下:先确认 First 和 Last 的测量时机是否处于同一帧,尤其注意布局变化是不是由异步任务触发的;再看 Invert 阶段有没有真的给元素设置 transform。如果你用的是 CSS transition 方案,记得在切换 class 的时候,旧 class 里的transition属性要先清掉,否则浏览器可能在改样式时直接播放了一个未预期的过渡。
6.2 动画结束后元素轻微抖动或位置偏移
这是最容易让人抓狂的问题。我遇到过的情况是,我在 Play 阶段的transform里用了百分比或calc(),导致浏览器计算出非整数的像素值,缩放过程中产生了舍入误差。解决方法是把动画的最终帧明确写成transform: none,并且在动画结束事件里清理内联样式。另一个常见原因是元素内部有异步加载的内容(比如图片),First 记录时图片还没加载完,布局尺寸不准,等图片加载完,元素位置已经变了。处理这个问题可以在图片加载完成后再用 ResizeObserver 触发一次 FLIP 校准。
6.3 卡顿问题排查清单
如果已经用了 FLIP 却还是卡,可以从这几个方向排查:
- 是否有父容器的
overflow: hidden且父容器有border-radius,导致合成层被反复光栅化。 - 元素是否在动画期间改变了
display或position,这会打断合成器的优化。 - 是否触发了大量元素的同步布局读取,也就是多处调用
getBoundingClientRect()而没有批量处理。 - 动画期间是否有频繁的文字重排,比如宽度变化导致每一行的折行点全部变化,这种文字重排很难靠 FLIP 完全避免,只能配合固定宽度或者
white-space: nowrap做约束。
6.4 表格形式的速查对照
为了实用,我把关键问题的现象、原因和常用解法整理成一张表,方便你放到项目文档里。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 动画前闪烁到终点 | First/Last 测量时机不对 | 用 rAF 分隔布局修改和测量 |
| 动画结束位置偏移 | 最终帧不是transform: none | 动画结束时清理内联样式 |
| 文字被拉伸变形 | 直接 scaleY 外层容器 | 改用 clip-path 或固定内部布局 |
| 元素之间有缝隙 | 缩放比例非整数像素 | 动画期间加同色背景遮罩 |
| 滚轮连续触发重排动画 | 未做防抖 | 断点或滚动结束后再执行 FLIP |
| font 加载后布局跳变 | First 记录时字体未载入 | 用 ResizeObserver 二次校准 |
6.5 我打磨出来的一个封装习惯
最后说一个我自己的习惯:会把 FLIP 抽成一个独立的工具函数,所有需要使用的地方只传入“布局变化前的回调”和“需要动画的元素集合”,内部统一处理测量、rAF、WAAPI 调用和降级策略。这样业务代码里几乎看不到 FLIP 的逻辑,全部语义化到“让这些元素平滑过渡到新位置”。实际运行时,我能很方便地加统一的调试日志,看看每次动画的成本。这个习惯在我们项目里被其他人接手时也获得了不错的反馈,因为大家不需要理解 FLIP 的实现也能正确使用。
FLIP 不是动画银弹,它解决的是“布局驱动动画”这一特定问题,但对于纵深方向的卡片切换、复杂拖拽排序这类场景,它确实把性能问题解决得既优雅又通用。遇到突发布局变化时,先想想能不能用 FLIP 把变化“藏”进合成层的过渡里,往往比纠结怎么优化重排成本要高效得多。