前端動畫必知必會:React 和 Vue 都在用的 FLIP 思想實戰
前言
在vue的官網中的過渡動畫章節中,可以看到一個很酷炫的動畫效果
乍一看,讓我們手寫出這個邏輯應該是非常複雜的,先看看本文最後要實現的效果吧,和這個案例是非常類似的。
預覽
分析需求
拿到了這個需求,第一直覺是怎麼做?假設第一行第一個圖片移動到了第二行第三列,是不是要計算出第一行的高度,再計算出第二行前兩個元素的寬度,然後從初始的座標點通過css或者一些動畫 API 移動過去?這樣做是可以,但是在圖片不定高不定寬,並且一次要移動很多圖片情況下,這個計算方法就非常複雜了。並且這種情況下,圖片的座標都需要我們手動管理,非常不利於維護和擴充套件。
換種思路,能不能直接很自然的把 DOM 元素通過原生 API 新增到 DOM 樹中,然後讓瀏覽器幫我們好這個終點值,最後我們再動畫位移過去?
在文件裡我們發現一個名詞:FLIP,這給了我們一個線索,是不是用這個玩意就可以寫出這個動畫呢?
答案是肯定的
FLIP
FLIP究竟是什麼東西呢?先看下它的定義:
First
即將做動畫的元素的初始狀態(比如位置、透明度等等)。
Last
即將做動畫的元素的最終狀態。
Invert
這一步比較關鍵,假設我們圖片的初始位置是左: 0, 上:0,元素動畫後的最終位置是左:100, 上100,那麼很明顯這個元素是向右下角運動了100px。
但是,此時我們不按照常規思維去先計算它的最終位置,然後再命令元素從0, 0運動到100, 100,而是先讓元素自己移動過去(比如在vue中用資料來驅動,在陣列前面追加幾個圖片,之前的圖片就自己移動到下面去了)。
這裡有一個關鍵的知識點要注意了。
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倒置回位移前的位置。
Play
倒置了以後,想要讓它做動畫就很簡單了,再讓它回到0, 0的位置即可,本文會採用最新的Web Animation API來實現最後的Play。
秒收目錄站https://www.tomove.com.cn
實現
首先圖片渲染很簡單,就讓圖片通過簡單的排成 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 元素,此時新加入圖片的 DOM 已經在 DOM 樹中,但是螢幕還沒有發生繪製,呼叫getBoundingClientRect可以觸發「強制同步佈局」,此後的程式碼裡就可以獲取到 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)
})
}
總結
FLIP
FLIP 不光可以做位置變化的動畫,對於透明度、寬高等等也一樣可以很輕鬆的實現。
比如電商平臺中經常會出現一個動畫,點選一張商品圖片後,商品從它本來的位置慢慢的放大成了一張完整的頁面。
FLIP的思路掌握後,只要你知道元素動畫前的狀態和元素動畫後的狀態,你都可以輕鬆的通過「倒置狀態」後,讓它們做一個流暢的動畫後到達目的地,並且此時的 DOM 狀態是很乾淨的,而不是通過大量計算的方式強迫它從0, 0位移到100, 100,並且讓 DOM 樣式上留下transform: translate(100px, 100px)類似的字樣。
Web Animation
利用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。