Vue 官网过渡动画里有一段很炫的效果。乍一看手写逻辑会很复杂,文档里出现了 FLIP。顺着这条线能在 Aerotwist 的 flip-your-animations 上找到思路,再一步步做出类似效果。图片素材引自知乎《有个漂亮女朋友是种怎样的体验?》,侵删。

本文最终效果:

预览:http://sl1673495.gitee.io/flip-animation
| 字母 |
含义 |
在本文里 |
| First |
动画前的初始状态(位置、透明度等) |
getBoundingClientRect 记下旧图 left/top |
| Last |
动画后的最终状态 |
改数据后 DOM 已变、浏览器尚未绘制 |
| Invert |
先把元素“倒”回动画前的视觉位置 |
translate 回偏移量 |
| Play |
再播到真正的新位置 |
Web Animation API 的 animate |
一、FLIP 四步
First
即将做动画的元素的初始状态(位置、透明度等)。
Last
即将做动画的元素的最终状态。
Invert
假设图片初始 左: 0, 上:0,最终 左:100, 上100,等于向右下走了 100px。不要先算终点再命令它从 0,0 走到 100,100,而是先让元素自己移动过去(Vue 里用数据驱动,数组前面追加几张图,旧图就被挤下去)。
关键点:DOM 属性变化(left、right、transform 等)会被攒到下一帧统一渲染。于是存在一个时间点:DOM 状态已经变了,浏览器还没画。可以先让 Vue 改 DOM,此时已能读到变更后的位置。
一行两张图,初始 [img1, img2],头部追加后变成 [img3, img4, img1, img2],img1/img2 被挤到下一行。img1 从 0,0 到 100,100,浏览器还没画,先 img1.style.transform = translate(-100px, -100px),把它 Invert 回位移前。
Play
倒置后再让它回到 0, 0 即可。本文用 Web Animation API 做最后的 Play(MDN:Web Animation)。
二、实现
图片用 column-count: 4 排成 4 列:
.wrap {
display: flex;
flex-wrap: wrap;
column-count: 4;
}
<div v-else class="wrap">
<div class="img-wrap" v-for="src in imgs" :key="src">
<img ref="imgs" class="img" :src="src" />
</div>
</div>
关键是往 imgs 追加元素后,做流畅的路径动画。先实现 add:
async add() {
const newData = this.getSister()
await preload(newData)
}
随机取几张图,new Image 预加载防止空白,再算旧图位置:
function getRects(doms) {
return doms.map((dom) => {
const rect = dom.getBoundingClientRect()
const { left, top } = rect
return { left, top }
})
}
// 当前已有的图片
const prevImgs = this.$refs.imgs.slice()
const prevPositions = getRects(prevImgs)
记下旧位置后追加:
this.imgs = newData.concat(this.imgs)
Vue 异步渲染,改 imgs 不会立刻动 DOM,要用 nextTick:回调进 microTask。microTask 一定发生在浏览器重绘前。
先执行 this.imgs = newData.concat(this.imgs),触发依赖更新,Vue 把本次 DOM 更新推进 microTask,队列是 [changeDOM]。再 nextTick(callback),变成 [changeDOM, callback]。所以 nextTick 里一定能拿到最新 DOM。
保存过 prevImgs,nextTick 里再 getRects 就是旧图片的新位置:
async add() {
// 最新 DOM 状态
this.$nextTick(() => {
// 再调用同样的方法获取最新的元素位置
const currentPositions = getRects(prevImgs)
},
有了新旧位置就可以 Invert 再 Play:
prevImgs.forEach((imgRef, imgIndex) => {
const currentPosition = currentPositions[imgIndex]
const prevPosition = prevPositions[imgIndex]
// 倒置后的位置,虽然图片移动到最新位置了,但你先给我回去,等着我来让你做动画。
const invert = {
left: prevPosition.left - currentPosition.left,
top: prevPosition.top - currentPosition.top,
}
const keyframes = [
// 初始位置是倒置后的位置
{
transform: `translate(${invert.left}px, ${invert.top}px)`,
},
// 图片更新后本来应该在的位置
{ transform: "translate(0)" },
]
const options = {
duration: 300,
easing: "cubic-bezier(0,0,0.32,1)",
}
const animation = imgRef.animate(keyframes, options)
})
完整实现:
async add() {
const newData = this.getSister()
await preload(newData)
const prevImgs = this.$refs.imgs.slice()
const prevPositions = getRects(prevImgs)
this.imgs = newData.concat(this.imgs)
this.$nextTick(() => {
const currentPositions = getRects(prevImgs)
prevImgs.forEach((imgRef, imgIndex) => {
const currentPosition = currentPositions[imgIndex]
const prevPosition = prevPositions[imgIndex]
const invert = {
left: prevPosition.left - currentPosition.left,
top: prevPosition.top - currentPosition.top,
}
const keyframes = [
{
transform: `translate(${invert.left}px, ${invert.top}px)`,
},
{ transform: "translate(0)" },
]
const options = {
duration: 300,
easing: "cubic-bezier(0,0,0.32,1)",
}
const animation = imgRef.animate(keyframes, options)
})
})
},
三、源码与适用面
https://github.com/sl1673495/flip-animation
FLIP 不只做位移,透明度、宽高一样能做。电商里点商品图,从图原来的位置放大成整页,就是这类思路。
只要知道动画前、动画后的状态,就能流畅过渡,而且此时 DOM 是干净的——不是硬编码强迫它从 0,0 走到 100,100。
一句话总结:FLIP 是先让 DOM 走到终点,再在绘制前用 transform 倒回去,最后用动画“假装”走过去,React 和 Vue 的列表过渡都是这套。