在 Vue 的官网中的过渡动画章节中,能够看到一个很酷炫的动画效果html
乍一看,让咱们手写出这个逻辑应该是很是复杂的,先看看本文最后要实现的效果吧,和这个案例是很是相似的。前端
也能够直接进预览网址里看:vue
http://sl1673495.gitee.io/fli...git
图片素材依然引用自知乎问题《有个漂亮女友是种怎样的体验?》,侵删。github
拿到了这个需求,第一直觉是怎么作?假设第一行第一个图片移动到了第二行第三列,是否是要计算出第一行的高度,再计算出第二行前两个元素的宽度,而后从初始的坐标点经过 CSS 或者一些动画 API 移动过去?这样作是能够,可是在图片不定高不定宽,而且一次要移动不少图片状况下,这个计算方法就很是复杂了。而且这种状况下,图片的坐标都须要咱们手动管理,很是不利于维护和扩展。web
换种思路,能不能直接很天然的把 DOM 元素经过原生 API 添加到 DOM 树中,而后让浏览器帮咱们好这个终点值,最后咱们再动画位移过去?数组
在文档里咱们发现一个名词:FLIP
,这给了咱们一个线索,是否是用这个玩意就能够写出这个动画呢?浏览器
答案是确定的,顺着这个线索找到 Aerotwist
社区里的一篇文章:flip-your-animations,以这篇文章为切入点,一步步来实现一个相似的效果。dom
FLIP
到底是什么东西呢?先看下它的定义:异步
即将作动画的元素的初始状态(好比位置、透明度等等)。
即将作动画的元素的最终状态。
这一步比较关键,假设咱们图片的初始位置是 左: 0, 上:0
,元素动画后的最终位置是 左:100, 上100
,那么很明显这个元素是向右下角运动了 100px
。
可是,此时咱们不按照常规思惟去先计算它的最终位置,而后再命令元素从 0, 0
运动到 100, 100
,而是先让元素本身移动过去(好比在 Vue 中用数据来驱动,在数组前面追加几个图片,以前的图片就本身移动到下面去了)。
这里有一个关键的知识点要注意了,也是我在以前的文章《深刻解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调》中提到过的:
DOM 元素属性的改变(好比 left
、right
、 transform
等等),会被集中起来延迟到浏览器的下一帧统一渲染,因此咱们能够获得一个这样的中间时间点:DOM 状态(位置信息)改变了,而浏览器还没渲染。
有了这个前置条件,咱们就能够保证先让 Vue 去操做 DOM 变动,此时浏览器还未渲染,咱们已经能获得 DOM 状态变动后的位置了。
说的具体点,假设咱们的图片是一行两个排列,图片数组初始化的状态是 [img1, img2
,此时咱们往数组头部追加两个元素 [img3, img4, img1, img2]
,那么 img1
和 img2
就天然而然的被挤到下一行去了。
假设 img1
的初始位置是 0, 0
,被数据驱动致使的 DOM 改变挤下去后的位置是 100, 100
,那么此时浏览器尚未渲染,咱们能够在这个时间点把 img1.style.transform = translate(-100px, -100px)
,让它 先 Invert 倒置回位移前的位置。
倒置了之后,想要让它作动画就很简单了,再让它回到 0, 0
的位置便可,本文会采用最新的 Web Animation API
来实现最后的 Play
。
首先图片渲染很简单,就让图片经过简单的排成 4 列便可:
.wrap { display: flex; flex-wrap: wrap; } .img { width: 25%; } <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
预加载这些图片,防止渲染一堆空白图片到屏幕上。
而后定义一个计算一组 DOM 元素位置的函数 getRects
,利用 getBoundingClientRect
能够得到最新的位置信息,这个方法在接下来获取图片元素旧位置和新位置时都要使用。
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
这个 API,这个 API 把你传入的回调函数放进了 microTask
队列,正如上文提到的事件循环的文章里所说,microTask
队列的执行必定发生在浏览器从新渲染前。
因为先调用了 this.imgs = newData.concat(this.imgs)
这段代码,触发了 Vue 的响应式依赖更新,此时 Vue 内部会把本次 DOM 更新的渲染函数先放到 microTask
队列中,此时的队列是[changeDOM]
。
调用了 nextTick(callback)
后,这个callback
函数也会被追加到队列中,此时的队列是 [changeDOM, callback]
。
这下聪明的你确定就明白了,为何 nextTick
的回调函数里必定能获取到最新的 DOM 状态。
因为咱们以前保存了图片元素节点的数组 prevImgs
,因此在 nextTick
里调用一样的 getRect
方法获取到的就是旧图片的最新位置了。
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) }) }) },
如今咱们想要实现官网 demo 中的 shuffle
效果,有了追加图片逻辑的铺垫,是否是已经以为思路如泉涌了?没错,即便图片被打乱的再厉害,只要咱们有「图片开始时的位置」和「图片结束时的位置」,那就能够轻松作到路径动画。
如今咱们须要作的是把动画的逻辑抽离出来,咱们分析一下整条链路:
保存旧位置 -> 改变数据驱动视图更新 -> 得到新位置 -> 利用 FLIP 作动画
其实外部只须要传入一个 update
方法告诉咱们如何去更新图片数组,就能够把这个逻辑彻底抽象到一个函数里去。
scheduleAnimation(update) { // 获取旧图片的位置 const prevImgs = this.$refs.imgs.slice() const prevSrcRectMap = createSrcRectMap(prevImgs) // 更新数据 update() // DOM更新后 this.$nextTick(() => { const currentSrcRectMap = createSrcRectMap(prevImgs) Object.keys(prevSrcRectMap).forEach((src) => { const currentRect = currentSrcRectMap[src] const prevRect = prevSrcRectMap[src] const invert = { left: prevRect.left - currentRect.left, top: prevRect.top - currentRect.top, } const keyframes = [ { transform: `translate(${invert.left}px, ${invert.top}px)`, }, { transform: "" }, ] const options = { duration: 300, easing: "cubic-bezier(0,0,0.32,1)", } const animation = currentRect.img.animate(keyframes, options) }) }) }
那么追加图片和乱序的函数就变得很是简单了:
// 追加图片 async add() { const newData = this.getSister() await preload(newData) this.scheduleAnimation(() => { this.imgs = newData.concat(this.imgs) }) }, // 乱序图片 shuffle() { this.scheduleAnimation(() => { this.imgs = shuffle(this.imgs) }) }
https://github.com/sl1673495/...
FLIP 不光能够作位置变化的动画,对于透明度、宽高等等也同样能够很轻松的实现。
好比电商平台中常常会出现一个动画,点击一张商品图片后,商品从它原本的位置慢慢的放大成了一张完整的页面。
FLIP
的思路掌握后,只要你知道元素动画前的状态和元素动画后的状态,你均可以轻松的经过「倒置状态」后,让它们作一个流畅的动画后到达目的地,而且此时的 DOM 状态是很干净的,而不是经过大量计算的方式强迫它从 0, 0
位移到 100, 100
,而且让 DOM 样式上留下 transform: translate(100px, 100px)
相似的字样。
利用 Web Animation API
可让咱们用 JavaScript 更加直观的描述咱们须要元素去作的动画,想象一下这个需求若是用 CSS 来作,咱们大概会这样去完成这个需求:
const currentImgStyle = currentRect.img.style currentImgStyle.transform = `translate(${invert.left}px, ${invert.top}px)` currentImgStyle.transitionDuration = "0s" this._reflow = document.body.offsetHeight currentRect.img.classList.add("move") currentImgStyle.transform = currentRect.img.style.transitionDuration = "" currentRect.img.addEventListener("transitionend", () => { currentRect.img.classList.remove("move") })
这是选择用比较原生的方式去控制 CSS 样式实现的 FLIP 动画,这段代码让我以为不舒服的点在于:
class
的增长和删除来和 CSS 来进行交互,总体流程不太符合直觉。document.body.offsetHeight
这样的方式触发 强制同步布局
,比较 hack 的知识点。this._reflow = document.body.offsetHeight
这样的方式向元素实例上增长一个没有意义的属性,防止被 Rollup 等打包工具 tree-shaking
误删。 比较 hack 的知识点 +1。而利用 Web Animation API
的代码则变得很是符合直觉和易于维护:
const keyframes = [ { transform: `translate(${invert.left}px, ${invert.top}px)`, }, { transform: "" }, ] const options = { duration: 300, easing: "cubic-bezier(0,0,0.32,1)", } const animation = currentRect.img.animate(keyframes, options)
关于兼容性问题,W3C 已经提供了 Web Animation API Polyfill
,能够放心大胆的使用。
期待在不久的将来,咱们能够抛弃旧的动画模式,迎接这种更新更好的 API。
但愿这篇文章能让对动画发愁的你有一些收获,谢谢!
1.若是本文对你有帮助,就点个赞支持下吧,你的「赞」是我创做的动力。
2.关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿共同交流和进步。