拯救動畫卡頓之 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