在 Vue 的官網中的過渡動畫章節中,能夠看到一個很酷炫的動畫效果html
乍一看,讓咱們手寫出這個邏輯應該是很是複雜的,先看看本文最後要實現的效果吧,和這個案例是很是相似的。前端
也能夠直接進預覽網址裏看:vue
sl1673495.gitee.io/flip-animat…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)
})
}
複製代碼
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.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。