拯救動畫卡頓之 FLIP

本文作者爲奇舞團前端開發工程師

前置知識

什麼是 FPS

FPS是瀏覽器的每秒的渲染幀數,也就是瀏覽器切換畫面的次數,大多數設備的刷新率都是 60FPS,一般來說FPS越低頁面就會越卡頓。

什麼是像素管道?

像素管道是瀏覽器單個幀的渲染流水線,如果其中有某些環節執行過程過長就會導致卡頓

上述的五個階段並不是一定都會執行到的,這五個階段中涉及到了老生常談的兩個概念:重排跟重繪,雖然初次渲染布局跟繪製必不可少,但是後期我們可以控制避免通過這兩個管道:以下是當我們修改不同的樣式屬性時,會觸發的幾種幀流程:

從上圖中能看到 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也能用於簡化某些場景下動畫的實現過程,如以下幾個場景:

// 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 形式需要注意什麼

比如,我們實現一個點擊方塊互換位置的動畫(詳見 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

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/la10K7yp77xsSOqUKQkl3A