FLIP 动画这几年几乎成了前端动画绕不开的话题,核心名字却简单得有些反直觉:First、Last、Invert、Play。它解决的问题特别直白——让一个改变尺寸、位置的 DOM 元素,动画跑起来又顺又不卡。但光看效果不过瘾,真要在项目里落地,得从浏览器渲染原理讲到工程级实践,把每一步的为什么弄清楚,调试时才不会懵。这篇文章适合正在做交互原型、维护动画库,或者被重排卡顿折磨过的前端开发者,也适合想弄懂高性能动画原理的同学。
1. 为什么动画会卡顿:先补浏览器渲染原理
1.1 浏览器渲染管线里的昂贵工作
浏览器把一个 DOM 元素显示到屏幕上,中间要经过好几道工序。首先要拿到 HTML 解析出来的 DOM 树,然后把 CSS 选择器匹配到的样式逐条生效,得到每个元素的最终样式;接着进入布局阶段,浏览器会计算元素在页面上的确切尺寸和位置;再往后是绘制阶段,把边框、背景、文字这些视觉内容画出来;最后交给合成阶段,把已经画好的各个图层按层级叠加,输出到屏幕上。
这段管线听起来挺常规,但它对动画的代价完全不同。如果动画期间改变了元素的 width、height、top、left 这类几何属性,浏览器每一帧都可能需要重新跑一遍布局和绘制。比如一个盒子的宽度从 100px 变成 300px,布局阶段要重新计算它周围的元素怎么排,绘制阶段要重新生成这块区域的像素,连续 30 帧都这么做,主线程很快就满了。
这里可以把整个流程类比成装修房子。布局就像先丈量客厅尺寸、决定沙发放哪;绘制就像调好漆、往墙上刷颜色。你买来新的沙发改了一下朝向,整间屋子都要重新量一遍、重新刷一遍,代价当然大。而我们在动画里真正想优化的,就是尽量别让浏览器频繁做这种“量屋子、刷墙”的重活。
1.2 合成器属性是浏览器特意开的后门
好在浏览器并不是只会蛮干,它对个别属性留了专门的优化路径。transform 和 opacity 属于“合成器属性”,意思是元素位置和透明度的变化可以在独立的合成层上处理,不需要主线程重新计算布局和绘制,只要把图层交给 GPU 做位移、缩放和透明叠加就行。
这就像你已经把完整房间的照片打印在了一张透明膜上,现在只需要把这层膜从桌子的左边平移到右边,而不是重新打开施工图、重新铺一遍墙纸。GPU 擅长这类矩阵运算,一个 translate 或 scale 的动画在它那里就是几个矩阵相乘,成本低得多,主线程也腾得出来处理点击、滚动和业务代码。
这个思路也是 FLIP 动画的底层依据:如果你能把元素的位置和尺寸变化伪装成 transform 变化,那么动画就能避开昂贵的布局和绘制。注意,这里说的是“伪装”,因为 transform 不会真正改变文档流里的布局位置和尺寸,它只是在视觉上做了一个偏移和缩放。这个特性在 FLIP 里会被反复利用,也是理解代码逻辑的关键。
1.3 不同动画方式的代价对比
把常见做法放在一起看就清楚得多,我给它们按触发阶段做了个对比:
| 动画方式 | 是否触发布局 | 是否触发绘制 | 主线程开销 | 适合动画吗 |
|---|---|---|---|---|
| 改 width / height | 是 | 是 | 高,连续帧容易卡 | 不推荐 |
| 改 top / left | 是 | 是 | 高,依赖布局重排 | 有条件的极小场景可用 |
| 改 transform | 否 | 否 | 低,合成层处理 | 推荐 |
| 改 opacity | 否 | 否 | 低,合成层处理 | 推荐 |
| 改 clip-path | 视情况 | 视情况 | 中到高 | 谨慎 |
所以高性能动画的结论很朴素:能用 transform 解决的,就不要直接去动布局属性。但问题来了,很多交互本质上就是“尺寸发生变化”,比如展开的卡片、伸缩的侧边栏,这时候单纯用 transform 没法直接把布局尺寸变成动画。FLIP 的作用就是桥接这两者——它先用真实布局把尺寸变成最终目标,再用 transform 把元素“拉回”起点,最后补一段顺滑的位移。
2. FLIP 原理拆解:First、Last、Invert、Play
2.1 First 和 Last:两次状态测量
FLIP 的第一步是在元素改变之前记录它的状态,第二步是改变之后记录状态。通常是调用 getBoundingClientRect() 拿到元素的 left、top、width、height,因为这些值正好覆盖了位置和尺寸两个关键维度。
这里有个细节可以多说一句:getBoundingClientRect 返回的是相对视口的位置,而不是相对文档的位置。如果你页面里用了滚动,不同时刻测量出的 top 可能受滚动高度影响,但 FLIP 关心的是两次测量之间的差值,只要两帧处于相同滚动位置,差值就能抵消这个问题。当然,如果在动画过程中滚动位置变了,最后的位移会出现偏差,这点我会在后面的排查章节专门讲。
First 的测量必须发生在任何 DOM 变化之前,Last 的测量必须发生在 DOM 已经变化之后。听起来很简单,但浏览器有个特性容易坑人——布局是异步的。你改了样式之后,浏览器不会立刻重新布局,而是要等一个合适的时机。如果这时候你立刻再读一次布局属性,浏览器会被迫中断当前任务,先把排队的布局工作全部执行掉,这就是所谓的“强制同步布局”。
所以在写 FLIP 时,最容易犯的错误是在回调函数之后马上测量 Last,结果测得的是旧状态,或者测得的新状态内部自相矛盾。稳妥的做法是在回调后强制读取一次布局属性,让浏览器先完成布局,再拿到准确的新坐标。这个同步布局确实有开销,但 FLIP 只在状态切换的那一帧做一次集中测量,而不是在动画帧里持续做,所以总体可以接受。
2.2 Invert:把终点伪装成起点
拿到了 firstRect 和 lastRect 之后,立即算出两者之间的差值。我们希望元素视觉上还停在原来的位置,但实际的布局位置已经到了终点。差值是:
deltaX = firstRect.left - lastRect.left
deltaY = firstRect.top - lastRect.top
scaleX = firstRect.width / lastRect.width
scaleY = firstRect.height / lastRect.height
可以看出,位移量是“起点减终点”,缩放比是“起点宽度除以终点宽度”。把这个差值放到 transform 上,元素就被“拽回”了起点。这里我强烈建议设置 transform-origin 为 top left,因为这样缩放的原点就在左上角,translate 的计算和 scale 的计算可以各自独立,不容易算错。如果保留默认的中心点缩放,最后的坐标还要把中心偏移算进去,手写容易翻车。
另一个容易踩坑的是 transform 的顺序。CSS 的 transform 相当于多个矩阵依次作用,从左到右会把前一个变换继续应用到后一个变换的结果上。直观理解就是先写 translate 再写 scale 时,translate 的像素值不会被 scale 放大;先写 scale 再写 translate 的话,translate 的像素值也会被缩放,计算位移时就得再除以缩放比例。为了省心和准确,推荐写成 translate(...px, ...px) scale(...),并且配合 top left 的原点。
2.3 Play:用合成器属性把反向变回来
Invert 完成之后,元素虽然视觉上还在起点,但 transform 记录的是一个反向偏移。接下来要做的就是把反向 transform 清掉,让元素从起点位置回到真实终点位置。因为 transform 是合成器属性,这个过渡过程只会触发合成层操作,动画帧率就会非常稳定。
具体执行时,可以把 transition 设置为 transform 0.3s ease,然后把 transform 的值清成空字符串。浏览器检测到 transform 从反向值变为初始值,就会在这两个值之间插入过渡动画。整段时间里没有布局重排,也没有绘制重刷,主线程几乎无事可做,60fps 不是问题。
有一个非常重要的收尾动作:动画结束后要把 transform 真正清空,不能一直留着 translate 或 scale。transform 只是视觉上的欺骗,文档流里元素的真实位置和大小已经变成 lastRect 了。如果不清空,后续再测量这个元素,getBoundingClientRect 会把 transform 也算进去,下一轮 FLIP 的差值就会算错。更严重的是,transform 默认会创建层叠上下文和包含块,可能影响 fixed 定位子元素和 z-index 层级。
2.4 FLIP 适合什么场景,不适合什么场景
把四个步骤串起来看,FLIP 特别适合那些“起点和终点状态都可以明确测量”的场景:列表增删后自动排序、折叠面板展开收缩、拖拽排序后的归位、路由切换时的页面转场。这些场景的共同点是元素真实地移动了位置或改变了尺寸,但动画效果却要表现得像是一小段 transform 过渡。
反过来,如果动画本身已经是用 transform 来写,比如一个按钮的缩放 hover 效果,那就不需要 FLIP,直接用 CSS transition 更合适。FLIP 的最大价值是连接“真实布局变化”和“合成器动画”这两个原本不相干的世界,是一种桥接手法,而不是万能动画方案。它也不适合做路径复杂、需要逐帧控制的动画,那种需求更适合用 Web Animations API 或者逐帧 rAF 来做插值。
3. 手写 FLIP:从一个最小实现到工程化封装
3.1 一个能跑的最小实现
先写一个足够简单、能看清逻辑的版本。它接收一个 run 函数,调用之前测量所有标记元素,调用之后测量第二次,然后计算差值并应用反向 transform,最后触发 transition。
function flip(run, options = {}) { const duration = options.duration ?? 300; const easing = options.easing ?? 'ease'; const firstMap = new Map(); document.querySelectorAll('[data-flip]').forEach((el) => { firstMap.set(el, el.getBoundingClientRect()); }); run(); const lastMap = new Map(); document.querySelectorAll('[data-flip]').forEach((el) => { lastMap.set(el, el.getBoundingClientRect()); }); firstMap.forEach((firstRect, el) => { const lastRect = lastMap.get(el); if (!lastRect) return; const deltaX = firstRect.left - lastRect.left; const deltaY = firstRect.top - lastRect.top; const scaleX = firstRect.width / lastRect.width; const scaleY = firstRect.height / lastRect.height; el.style.transition = 'none'; el.style.transformOrigin = 'top left'; el.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`; }); // 强制浏览器完成一次布局,确保 transform 生效 document.body.offsetHeight; firstMap.forEach((firstRect, el) => { el.style.transition = `transform ${duration}ms ${easing}`; el.style.transform = ''; }); }这段代码有几个地方值得琢磨。Map 的 key 直接存元素,避免用数组索引跟踪,顺序变化时不会串位。第一次测量之后立刻调用 run,第二次测量放到循环前面,集中读取,而不是每个元素单独又读又写,这样可以减少强制同步布局的次数。中间那个 document.body.offsetHeight 是为了强制刷新布局,虽然名字看着像是在读一个无关属性,但浏览器为了返回这个值,会先把排队的布局任务做完。
3.2 经典场景实战:列表展开收缩
FLIP 最常被提及的场景之一就是列表的展开收缩。假设有一个可折叠的分组,点击时高度从 0 变成 auto,或者从一个固定高度变成内容高度。直接用 height 加 transition 来做,浏览器会在动画每一帧里重排并重绘,数据一多就卡。用 FLIP 的思路就不一样:先把高度设置为最终值,让浏览器完成布局,再用 transform 把视觉状态倒推到展开前,然后再补回原位。
实际代码只需要在前面的 flip 函数基础上改 run 的内容。比如一个模拟目录结构:
<div class="group"> <button class="toggle">展开分组</button> <ul class="list"> <li>第一项</li> <li>第二项</li> </ul> </div>点击按钮时,把 list 的 display 从 none 改成 block,或者把 max-height 从 0 改成实际内容的高度。接着调用 flip 函数,run 里只做这个 DOM 状态切换,然后在 flip 内部自动完成 start、end、invert、play。这里要注意,display: none 的元素无法通过 getBoundingClientRect 拿到真实尺寸,所以更稳妥的做法是用 max-height 0 到 max-height 1000px,或者先把元素放到可视区域外测量,再回来做 FLIP。
我自己在模拟项目 X 里实现“侧栏抽屉展开”时,就遇到过 display 从 none 变 block 的坑。改成下面这种方案后就顺了:把内容放进一个普通 div,里面用固定高度包裹,外层高度设为 0 并加 overflow: hidden,展开时再设置外层高度为内容的实际高度。这时测量 Last 就能拿到真实的宽高,FLIP 的差值也自然正确。
3.3 工程级封装:异步更新、rAF 与取消机制
真正到了生产环境,情况比上面的 demo 复杂得多。首先是数据驱动的框架:DOM 不是靠同步调用生成的,而是数据 update 之后由框架异步渲染。这时你在 run 之后立刻做 Last 测量,拿到的很可能是旧 DOM。解决方案是在 run 之后用 nextTick 或者等一个微任务,确保框架已经更新完视图,再执行测量。如果你发现 transform 没有生效,第一个要怀疑的就是这里。
其次是动画编排,多个元素之间可能要设置 stagger,也就是每个元素稍微延迟一点开始。可以在 flip 内给每个元素计算一个 delay,比如 index * 50ms。注意 transition 里 delay 和 duration 是两码事,不要混在一起。最后还要考虑的是 cancel 机制:用户快速连续点击时,上一个动画可能还没有结束,此时旧 transform 在过渡中,新状态又插进来,元素会跳一下。工程级封装需要加一个动画令牌,每次开始前校验令牌,如果上一次动画未完成,就先用 getAnimations() 取消元素上的动画,把 transform 重置到当前布局状态,再开始新一次 FLIP。
下面是更进一步封装的核心思路,我通常写成工具类而不是普通函数:
class FlipController { constructor(root = document) { this.root = root; this.token = 0; } async play(updateFn, options = {}) { const token = ++this.token; const elements = [...this.root.querySelectorAll('[data-flip]')]; const firstMap = elements.map((el) => [el, el.getBoundingClientRect()]); await updateFn(); if (token !== this.token) return; const lastMap = elements.map((el) => [el, el.getBoundingClientRect()]); firstMap.forEach(([el, firstRect], i) => { const lastRect = lastMap[i]?.[1]; if (!lastRect) return; el.getAnimations().forEach((anim) => anim.cancel()); el.style.transition = 'none'; el.style.transformOrigin = 'top left'; el.style.transform = `translate(${firstRect.left - lastRect.left}px, ${firstRect.top - lastRect.top}px) scale(${firstRect.width / lastRect.width}, ${firstRect.height / lastRect.height})`; }); requestAnimationFrame(() => { if (token !== this.token) return; firstMap.forEach(([el, firstRect], i) => { const lastRect = lastMap[i]?.[1]; if (!lastRect) return; el.style.transition = `transform ${options.duration ?? 300}ms ${options.easing ?? 'cubic-bezier(0.25, 0.8, 0.35, 1)'}`; el.style.transform = ''; }); }); } }这个封装里把测量集中成数组,保证 First 和 Last 一一对应。await updateFn 的场景很灵活,调用方可以传入一个 async 函数,里面做框架更新、异步请求或者任意重排操作。token 的存在确保动画只响应最新一次操作,中断的旧动画会被安全丢弃。requestAnimationFrame 确保 transition 是在当前帧已经把 Invert 写进去之后才设置的,不会出现 transition 从旧状态直接开始的尴尬情况。
4. 常见问题与排查技巧实录
4.1 文字抖动、模糊和拉伸
我在实际项目里遇到最多的就是动画期间文字模糊、抖动。原因多半是 scale 把文字栅格化了,或者 transform 移动到了小数像素位置。scale 动画过程中,文字会在中间状态被拉伸成非整数倍,浏览器对位图的插值结果看起来就有点糊。
几个可以试的办法。一是给动画中的元素加 will-change: transform,让浏览器提前分配合成层,降低动态创建层的开销;二是把属性尽量取整,translate 的 x 和 y 可以用 Math.round 处理,减少半像素渲染;三是如果只做位移不需要缩放,就尽量只保留 translate,不要为了视觉效果强行加 scale;四是如果文字必须跟尺寸一起过渡,可以考虑外层元素做 FLIP 缩放,内层元素做一个反向缩放来抵消拉伸,这样文字在视觉上始终接近正常比例。
4.2 子元素溢出、容器裁剪问题
FLIP 变换以 scale 模拟尺寸变化时,子元素经常会被父容器裁掉,尤其是父容器设置了 overflow: hidden 或圆角之后。原理是 scale 只是把整个盒子画小了,但内容没有真的重排,超出新边界的部分会被剪裁。动画结束后布局尺寸已经变成最终值,内容反而正常了,所以问题只在动画过渡到一半的时间段里集中爆发。
如果只是视觉上有几毫秒的头晕感,问题不大。但如果裁切非常明显,可以采用“双层结构”:外层元素负责位置和尺寸的 FLIP,内层元素负责内容渲染,FLIP 过程中给内层加一个反向 scale 来抵消外层的缩放。这个做法的计算量稍微复杂一点,但视觉质量很稳定,现在不少动画库内部的尺寸 FLIP 过渡用的都是类似思路。
4.3 动画中断、重复触发错乱
用户连点按钮时,FLIP 最容易爆出位置跳跃。想象一下,第一个动画才走了一半,transform 还是某个中间值,你这时候又把元素重置为 Invert 状态,原先的中间值和新的 Invert 值完全对不上,浏览器就会硬生生把元素从旧位置拽到新位置,视觉上就是一闪而过。
解决思路是动画开始前把元素上未完成的动画全部取消,或者先把 transform 清空再测量。Web Animations API 提供了 element.getAnimations() 和 anim.cancel(),配合动画令牌可以在新动画开始时彻底清理旧状态。如果团队里还在用老代码,也会用 transitionend 事件来标记动画是否完成,但那个方案在元素隐藏或快速重排时经常漏触发,不如直接取消动画可靠。
4.4 窗口尺寸变化、字体加载后测量不准
FLIP 的 Last 测量依赖一个稳定的环境,但浏览器窗口尺寸变化、字体加载、图片加载都会改变布局。假设用户正在看一个展开动画,突然滚动条出现或消失,视口宽度变化,布局重新计算,之前测好的 lastRect 可能就不对了,动画终点会偏离真实位置,甚至出现闪烁。
应对方式有两类。一类是让 FLIP 在布局稳定之后重新测量,比如监听 resize 事件,用防抖再执行一次,或者在 document.fonts.ready 之后再做动画;另一类是尽量让触发动画的元素处于自约束状态,宽高不依赖外部视口。开发中如果发现“换了个窗口宽度动画就歪了”,多半就是测量时机没控制好。实测下来,ResizeObserver 比 window.resize 更精确,因为它可以锁定到具体容器变化,而不是全局视口变化。
做一个简单的问题速查表,方便大家直接查:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 文字抖动 | 小数像素、scale 插值 | 对齐到整数、加 will-change、反向缩放 |
| 内容被裁剪 | overflow hidden 与 scale 冲突 | 双层结构、动画期间放宽 overflow |
| 重复点击跳动 | 前一次动画未清理 | 取消旧动画、动画令牌 |
| 终点不对 | Last 测量时机太早 | 使用 nextTick / rAF 后测量 |
| 窗口变化后错位 | 视口重排影响坐标 | resize 后重新测量、监听容器变化 |
5. 工程级落地:选型、度量与团队协作
5.1 自己写还是用现成动画库
FLIP 看起来代码不多,但工程级落地时会牵扯到测量时机、动画调度、子元素变形、断点恢复等问题。是维护一套自己写的工具库,还是直接用一个成熟的动画库,我认为取决于团队对性能控制和定制深度的需求。
自己写的好处是逻辑完全可控,没有额外依赖,代码量也不少,但坑得自己踩一遍,动画库那些已经打磨好的细节,比如连续动画调度、滚动牵连、子元素反向补偿,你得重新实现。成熟的动画库把大部分常见场景封装好了,你可以用很短的声明式代码完成列表排序和布局过渡,但代价是体积、学习成本和潜在的内部黑盒。如果只是偶尔用一两次,直接封装一个工具函数或者引入成熟库都行;如果整个项目里到处都需要列表布局动画,我会先把团队里一段公共的 FlipController 维护好,稳定之后再考虑是否替换为更完整的能力。
5.2 性能度量与回归观测
把 FLIP 写进项目之后,不要只靠肉眼确认“看起来挺顺”,要用数据量化主线程上的压力。打开浏览器的 Performance 面板,录制一段动画过程,观察 Main 任务的火焰图里有没有大块的 Layout 和 Paint 任务。如果动画期间经常出现长任务超过 50ms,说明还有东西在拖后腿,需要回去检查 Last 测量是否集中、transition 是否写到了 transform 上、有没有别的属性被意外带动重排。
我习惯在动画工具里打两个日志,一个记录强制同步布局的时间,一个记录 requestAnimationFrame 和前一个 rAF 的时间差。如果 rAF 的间隔经常超过 16ms,说明主线程确实忙不过来,这时不一定是 FLIP 本身的问题,而是页面上有太多动画或者业务代码在跟渲染抢时间片。把这些度量留在开发环境里,发布前跑一轮,能防止后续业务代码改动把动画性能悄悄拉垮。动画性能的退化通常不是突然发生的,而是慢慢累积的,所以回归观测比一次性的优化更重要。
5.3 团队协作时的几个约定
工程级 FLIP 落到团队里,我会强制约定三件事。第一,所有参与 FLIP 的元素必须有统一的>
医院安全运营的大模型落地:从告警研判到人机协同闭环
医院的安全运营,一直是安全行业里比较特殊的一类场景。业务科室凌晨三点还在跑批,影像系统几十个T的数据说走就走,设备科新接的呼吸机连的还是老交换机,信息科手里却只有三四个安全运维人员。这种环境下,想靠堆人力和传…
Excel合并单元格后内容不居中?从原理到批量处理一次解决
合并单元格后内容不居中,这个事儿看着小,但几乎每个用Excel的人都栽过跟头。有人辛辛苦苦把表头合并好了,结果字死死贴在左下角,怎么看怎么别扭;有人点了“合并后居中”,换台电脑打开又变了样;还…
力扣1046最后一块石头的重量:C++优先队列与大小堆实战解析
力扣1046这道“最后一块石头的重量”,我常把它当作面试里考察大小堆和优先队列的入门练兵场。题目本身很直白:你有一堆石头,每次从里面拎出最重的两块,按规则粉碎一轮,剩下的差再丢回去,直到剩一块或全部消…
软考程序员37天高效备考路线图:真题倒推+场景化锚点
1. 这份笔记不是“速成宝典”,而是我用37天实测打磨出的程序员备考路线图 “初级软考-程序员”这六个字,对刚毕业的学生、转行想进IT基础岗的朋友、或者在小公司做基础开发但没系统梳理过知识体系的从业者来说,不是一张纸,而是一道…
BEV感知与在线建图如何实现无高精地图自动驾驶
1. 地图曾是自动驾驶的“导航员”,现在却成了“备选方案”“高精地图是L3级自动驾驶的入场券”——这句话在2018年前后几乎写进了所有车企的技术白皮书里。当时某新势力品牌发布首款量产车时,工程师在内部分享会上放了一张对比图:左边是依赖厘…
HMM、CTC、RNN-T:语音识别对齐机制的演进之路
很多刚接触语音识别的同学,第一次看到“HMM、CTC、RNN-T”这三个词并列都会愣一下:一个上世纪七十年代起家的老牌模型,一个靠空标签把端到端训练变得可行的损失函数,一个现在流式识别里最常听到的架构,为什么要放在同一…