拯救動畫卡頓之 FLIP
本文作者爲奇舞團前端開發工程師
前置知識
什麼是 FPS
FPS是瀏覽器的每秒的渲染幀數,也就是瀏覽器切換畫面的次數,大多數設備的刷新率都是 60FPS,一般來說FPS越低頁面就會越卡頓。
什麼是像素管道?
像素管道是瀏覽器單個幀的渲染流水線,如果其中有某些環節執行過程過長就會導致卡頓
-
JavaScript。通常來說,阻塞的發起都是來自於 JS ,這不是說不用 JS,而是要正確的使用 JS 。首先,JS 線程的運行本身就是阻塞 UI 線程的(暫不考慮 Web Worker)。從純粹的數學角度而言,每幀的預算約爲 16.7 毫秒(1000 毫秒 / 60 幀 = 16.66 毫秒 / 幀)。但因爲瀏覽器需要花費時間將新幀繪製到屏幕上,只有 ~10 毫秒來執行 JS 代碼,過長時間的同步執行 JS 代碼肯定會導致超過 10ms 這個閾值,其次,頻繁執行一些代碼也會過長的佔用每幀渲染的時間。此外,用 JS 去獲取一些樣式還會導致強制同步佈局。
-
樣式計算(Style)。此過程是根據匹配選擇器(例如
.headline或.nav > .nav__item)計算出哪些元素應用哪些 CSS 規則的過程,這個過程不僅包括計算層疊樣式表中的權重來確定樣式,也包括內聯的樣式,來計算每個元素的最終樣式。 -
佈局(Layout)。在知道對一個元素應用哪些規則之後,瀏覽器即可開始計算該元素要佔據的空間大小及其在屏幕的位置。網頁的佈局模式意味着一個元素可能影響其他元素,一般來說如果修改了某個元素的大小或者位置,則需要檢查其他所有元素並重排(re-flow)整個頁面。
-
繪製(Paint)。繪製是填充像素的過程。它涉及繪出文本、顏色、圖像、邊框和陰影,基本上包括元素的每個可視部分。繪製一般是在多個表面(通常稱爲層)上完成的,繪製包括兩個步驟:1) 創建繪圖調用的列表, 2) 填充像素,後者也被稱作柵格化。
-
合成(Composite)。由於頁面的各部分可能被繪製到多個層上,因此它們需要按正確順序繪製到屏幕上,才能正確地渲染頁面。尤其對於與另一元素重疊的元素來說,這點特別重要,因爲一個錯誤可能使一個元素錯誤地出現在另一個元素的上層。
上述的五個階段並不是一定都會執行到的,這五個階段中涉及到了老生常談的兩個概念:重排跟重繪,雖然初次渲染布局跟繪製必不可少,但是後期我們可以控制避免通過這兩個管道:以下是當我們修改不同的樣式屬性時,會觸發的幾種幀流程:
從上圖中能看到 JS 階段以及 Style 和 Composite 階段 是不可避免的,因爲需要 JS 來引發樣式的改變,Style 來計算更改後最終的樣式,Composite 來合成各個層最終進行顯示,能跳過的步驟只有佈局跟繪製,我們知道,執行的階段越少,耗時就越少,每秒的渲染幀數就會越高,那麼能不能直接跳過這兩個步驟直接到合成呢?答案是肯定的,如下的屬性只會觸發合成階段:transform、opacity、pointer-events、perspective (透視效果)、curosr、orphans 設置當元素內部發生分頁時必須在頁面底部保留的最少行數(用於打印或打印預覽)、widows(設置當元素內部發生分頁時必須在頁面頂部保留的最少行數(用於打印或打印預覽))。
FLIP
綜上所述,當我們寫動畫的時候如果用height margin padding left等會觸發重排的屬性,相較於只用transform或者opacity會帶來更多的性能開銷,一旦這個計算時長超過 1 個動畫幀 (一般是 60 幀每秒, 也就是說超過 16.7ms), 那麼這幀動畫將不會繪製,產生頁面卡頓。FLIP技術,就是一種讓動畫只利用到transform或者opacity的技巧,FLIP 是 First, Last, Invert, Play 的簡稱。
概念
First
對應動畫的 Start 階段,用 element.getBoundingClientRect()記錄初始位置。
Last
對應動畫的 End 階段,先執行觸發 layout 變動的代碼,同樣的用element.getBoundingClientRect()記錄元素的終止位置。
Invert
現在元素處於 End 位置,利用 transform 做一個逆運算,讓添加了 transform 的元素迴歸到初始位置。
Play
真正需要執行動畫時,將 transform 置爲 None
上述階段可能有人會困惑,getBoundingClientRect不是也會觸發重排嗎?但是需要注意的是我們的重心是在動畫階段,要保障的是動畫階段的流暢,更何況用戶在網頁上進行交互時,比如 click,touch,從交互結束到感知到程序的相應大約需要 100ms 的生理反應時間。我們在用戶交互後要做 100ms 內準備好動畫就好了,這些動畫準備計算就是 getBoundingClientRect(或 getComputedStyle) 等的計算。
實踐
現在實現一個簡單的從左到右的循環滾動動畫
const playAnimate = (el) => {
if (!el) return;
// 記錄初始位置,對應 FLIP的FIRST
const pos = tagWrapperRef.current?.getBoundingClientRect();
const { left: initLeft } = pos;
// 設置元素樣式爲動畫結束時的目標位置
tagWrapperRef.current.classList.add('scroll-to-end');
// 記錄終止位置 對應FLIP的LAST
const endPos = tagWrapperRef.current?.getBoundingClientRect();
const { left: endLeft } = endPos;
// 計算初始位置跟終止位置的偏差
const deltaLeft = initLeft - endLeft;
tagWrapperRef.current.animate(
[
// 動畫開始時,利用 `transform` 做一個逆運算,讓添加了 `transform` 的元素迴歸到初始位置。
{
transform: `translate(${deltaLeft}px,0)`,
},
{
transform: 'none',
},
],
{
duration: totalTime * 1000,
easing: 'linear',
iterations: Infinity,
}
);
}
.scroll-to-end{
left:100%
}
採用FLIP的形式成功的避開了動畫過程中更改left。我們注意到:我們可以在動畫開始前預先用 API 計算元素在動畫終止時候的位置,只要知道了終態跟初始狀態,就能迅速計算出要達到終態該怎麼移動,由此避開一些複雜的計算,由此我們也可以得出:除了用來做性能優化之外,FLIP也能用於簡化某些場景下動畫的實現過程,如以下幾個場景:
-
元素宿主元素的變化,比如將元素從 A 移動到 B
-
容器大小變化
-
圖片展開和收縮效果
-
項目刪除和添加時填充空白區域的效果
-
網格項的重新排序 舉個例子,如第三個場景,如若添加或者刪除的卡片的大小是未知的,如果用常規的方式移動其他卡片將變得困難,而如果使用
FLIP,問題將迎刃而解
// Last
if (updateType === 0) {
// 增加卡片
newListData = this.state.listData.slice(0, activeIndex).concat({ index: cardIndex++
}, this.state.listData.slice(activeIndex))
} else {
// 刪除卡片
newListData = this.state.listData.filter((value, index) => index !== activeIndex)
}
// Invert
// 0 增加 1 刪除
const updateIndex = updateType === 0 ? 1 : 0
activeList.forEach((item, index) => {
rect = item.getBoundingClientRect()
invertArr[index + updateIndex][0] = invertArr[index + updateIndex][0] - rect.left
invertArr[index + updateIndex][1] = invertArr[index + updateIndex][1] - rect.top
...
使用 FLIP 形式需要注意什麼
FLIP中的前三個階段也就是前期的準備工作需要在繪製這個步驟之前,也就是時間需要儘可能的控制在之前提到的 100ms 以內,否則的話 渲染的過程中可能出現閃爍,但是我們怎麼才能抓住繪製前這個時機呢?本人用 React 比較多,以 React 爲例:答案是useLayoutEffect,它接受一個回調函數,這個函數會在dom更新後、重繪之前同步的執行
比如,我們實現一個點擊方塊互換位置的動畫(詳見 https://codepen.io/jlkiri/pen/oNjaMrK)
const Flipper = () => {
const [ids, setIds] = React.useState(["square-1", "square-2"]);
const rects = React.useRef(new Map()).current;
const swap = ([a, b]) => [b, a];
React.useEffect(() => {
const squares = document.querySelectorAll(".square");
// Cache position and size once on initial render
for (const square of squares) {
rects.set(square.id, square.getBoundingClientRect());
}
}, []);
React.useLayoutEffect(() => {
const squares = document.querySelectorAll(".square");
for (const square of squares) {
// Get previous size and position from cache
const cachedRect = rects.get(square.id);
if (cachedRect) {
const nextRect = square.getBoundingClientRect();
// Invert
const translateX = cachedRect.x - nextRect.x;
// Cache the next size and position
rects.set(square.id, nextRect);
// Play
square.animate(
[
{ transform: `translateX(${translateX}px)` },
{ transform: `translateX(0px)` }
],
1000
);
}
}
}, ids);
return (
<div class>
{ids.map((id, i) => {
return (
<div id={id} onClick={() => setIds(swap(ids))} className={`square`}>
{id}
</div>
);
})}
</div>
);
};
ReactDOM.render(<Flipper />, document.querySelector("#root"));
以上可以用如下的流程圖概括:useEffect總是在useLayoutEffect以及瀏覽器繪製之後執行,但是注意到這裏我們僅僅在第一次渲染之後執行了,而這裏的useLayoutEffect在之後的每一次渲染之後都會執行。
- 避免多次觸發重排 可能會有的人 “讀”“寫” 一起進行。
錯誤的做法:循環遍歷元素並使用 getBoundingClientRect 讀取它們的位置,然後立即使用 animate 爲它們設置動畫。
正確的做法:批量的讀取與寫入
最後
Invert這一步驟可能有些許麻煩,今年 5 月份作者提供了插件 Flip Plugin
參考
-
https://aerotwist.com/blog/flip-your-animations/
-
https://css-tricks.com/animating-layouts-with-the-flip-technique/
-
https://github.com/fi3ework/blog/issues/9
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/la10K7yp77xsSOqUKQkl3A