前端必學的動畫實現思路!

一個合理的動畫是良好用戶體驗中必不可少的一部分。我們平常是怎樣寫動畫的?CSS 中的 animationtransition,還有 requestAnimationFrame?相信大家寫動畫的時候心裏也是在萬馬奔騰。今天我們從一個另闢蹊徑的角度來探索一個動畫實現。

示例

請看下面的示例:

這是一個可添加的數字的隨機亂序列表。首先想一想,我們第一直覺可能會這樣做:將這些數字的 DOM 節點用絕對定位來佈局,數字變化後計算 topleft 的值,再配合 transition 實現該動畫。這種方式看似簡單,其實內部要維護各種位置信息,所有座標都需要手動管理,相當繁雜,非常不利於後期擴展。如果這些節點換成高度不固定的圖片,那計算量可想而知。

那有沒有一種更好的方式實現呢?肯定的,接下來介紹一個金光閃閃的概念:FLIP

提前預覽:

https://minjieliu.github.io/react-flip-demo[1]

FLIP

FLIP 其實是幾個單詞的縮寫:即 First、Last 、Invert 、Play。

讓我們分解一下:

First

涉及動畫的元素的初始狀態(比如位置、縮放、透明等)。

Last

涉及動畫的元素的最終狀態。

Invert

這一步爲核心,即找出這個元素是如何變化的。例如該元素在 FirstLast 之間向右移動了 50px,你就需要在 X 方向 translateX(-50px),使元素看起來在 First 位置。

這裏有一個知識點值得注意,DOM 元素屬性的改變(比如 leftrighttransform 等),會被集中起來延遲到瀏覽器下一幀統一渲染,所以我們可以得到一個這樣的中間時間點:DOM 位置信息改變了,而瀏覽器還沒渲染 [2]。也就意味着在一定的時間內,我們能獲取 DOM 改變後的位置,但在瀏覽器中位置還未改變。經測試,這個過程超過 10ms 就顯得不穩定了。因此 setTimeout(fn, 0)React useEffectVue $nextTick 都可以實現 Invert 過程。

Play

即從 Invert 回到最終狀態,有了兩個點的位置信息,中間的過渡動畫就可以使用 transition 實現。本文采用 Web Animation API[3] 實現,動畫執行過程中不會添加 CSS 到 DOM 上,相當乾淨。

實現

這裏主要使用 React 方式實現該效果,其他框架原理都一樣可參考。

一個列表,將子元素 5 列爲一行:

.list {
  display: flex;
  flex-wrap: wrap;
  width: 400px;
}

.item {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 80px;
  height: 80px;
  border: 1px solid #eee;
}
function ListShuffler() {
  const [data, setData] = useState([0, 1, 2, 3, 4, 5]);

  const listRef = useRef<HTMLDivElement>(null);

  return (
    <div className={styles.list} ref={listRef}>
      {data.map((item) =(
        <div key={item} className={styles.item}>
          {item}
        </div>
      ))}
    </div>
  );
}

首先,我們需要記錄 FirstLast 的位置信息,並用來計算 Invert 偏移差,因此用 Map 對象來存儲最合適不過了,有了這個方法,我們就可以用它來生成前後快照:

function createChildElementRectMap(nodes: HTMLElement | null | undefined) {
  if (!nodes) {
    return new Map();
  }
  const elements = Array.from(nodes.childNodes) as HTMLElement[];
  // 使用節點作爲 Map 的 key 存儲當前快照,下次直接用 node 引用取值,相當方便
  return new Map(elements.map((node) =[node, node.getBoundingClientRect()]));
}

點擊添加的時候記錄 First 快照:

// 使用 ref 存儲 DOM 之前的位置信息
const lastRectRef = useRef<Map<HTMLElement, DOMRect>>(new Map());

function handleAdd() {
  // 添加一條到頂部,讓後面節點運動
  setData((prev) =[prev.length, ...prev]);
  // 並存儲改變前的 DOM 快照
  lastRectRef.current = createChildElementRectMap(listRef.current);
}

接下來 DOM 更新後還需要改變後的快照,在 React 中,無論是 useEffect 還是 useLayoutEffect 這裏都可以拿到:

useLayoutEffect(() ={
  // 改變後的 DOM 快照,此時 UI 並未更新
  const currentRectMap = createChildElementRectMap(listRef.current);
}[data]);

現在,我們就可以把之前的快照進行遍歷,實現 InvertPlay

// 遍歷之前的快照
lastRectRef.current.forEach((prevRect, node) ={
  // 前後快照的 DOM 引用一樣,可以直接獲取
  const currentRect = currentRectMap.get(node);

  // Invert
  const invert = {
    left: prevRect.left - currentRect.left,
    top: prevRect.top - currentRect.top,
  };

  const keyframes = [
    {
      transform: `translate(${invert.left}px, ${invert.top}px)`,
    },
    { transform: 'translate(0, 0)' },
  ];

  // Play 執行動畫
  node.animate(keyframes, {
    duration: 800,
    easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)',
  });
});

大功告成!這裏每個節點有單獨的動畫,各個節點之間互不衝突。也就是說無論節點位置多麼複雜,處理起來都能從容應對。

比如圖片亂序只需要從 lodash 引入 shuffle 修改數據就可以完美實現展現。

import { shuffle } from 'lodash-es';

function shuffleList() {
  setData(shuffle);
  // 並存儲改變前的 DOM 快照
  lastRectRef.current = createChildElementRectMap(listRef.current);
}

以上總體思路就是 First -> Last -> Invert -> Play 的一個變換過程。預覽下:

你發現沒有,每次做完操作都需要手動更新快照,作爲開發者不能忍,我們要懶到極致,好好封裝一下。

直白需求:

  1. 數據變化後自動執行動畫

  2. 可以不關心任何動畫邏輯

  3. 不要限制 DOM 結構

  4. 用法要簡單

  5. 性能要好

開幹!

在 React 更新模型中,執行順序爲:setState -> render -> layoutEffect。因此可以把 setState 生成快照的步驟放到 render 中,從而與操作解耦。(如果放到 useLayoutEffect 中動畫頻繁會出現位置計算不準確的問題)

useMemo(() ={
  // render 時立即執行
  lastRectRef.current.forEach((item) ={
    item.rect = item.node.getBoundingClientRect();
  });
}[data]);

加上之前 useLayoutEffect 那部分邏輯,我們可以抽到一個獨立組件中(Flipper),用 flipKey 來控制,只要 flipKey 變化就執行動畫,即實現 1、2 兩點。

Flipper.tsx

export default function Flipper({ flipKey, children }: FlipperProps) {
  const lastRectRef = useRef<Map<number, FlipItemType>>(new Map());
  const uniqueIdRef = useRef(0);

  // 通過 ref 創建函數,傳遞 context 避免引起穿透渲染
  const fnRef = useRef<IFlipContext>({
    add(flipItem) {
      lastRectRef.current.set(flipItem.flipId, flipItem);
    },
    remove(flipId) {
      lastRectRef.current.delete(flipId);
    },
    nextId() {
      return (uniqueIdRef.current += 1);
    },
  });

  useMemo(() ={
    lastRectRef.current.forEach((item) ={
      item.rect = item.node.getBoundingClientRect();
    });
  }[flipKey]);

  useLayoutEffect(() ={
    const currentRectMap = new Map<number, DOMRect>();
    lastRectRef.current.forEach((item) ={
      currentRectMap.set(item.flipId, item.node.getBoundingClientRect());
    });

    lastRectRef.current.forEach(() ={
      // 之前的 FLIP 代碼
    });
  }[flipKey]);

  return <FlipContext.Provider value={fnRef}>{children}</FlipContext.Provider>;
}

最開始的方式是通過原生方法遍歷 DOM,因此我們只能限制子節點一個層級,並且操作方式也脫離的 React 的編寫模型,加以改進可以使用 Context 來通信存儲:

FlipContext.ts

import React, { createContext } from 'react';

export type FlipItemType = {
  // 子組件的唯一標識
  flipId: number;
  // 子組件通過 ref 獲取的節點
  node: HTMLElement;
  // 子組件的位置快照
  rect?: DOMRect;
};

export interface IFlipContext {
  // mount 後執行 add
  add: (item: FlipItemType) => void;
  // unout 後執行 remove
  remove: (flipId: number) => void;
  // 自增唯一 id
  nextId: () => number;
}

export const FlipContext = createContext(
  undefined as unknown as React.MutableRefObject<IFlipContext>,
);

最後則是要實現採集每個動畫元素的節點。將動畫的節點使用自定義組件 Flipped 包裹並 cloneElement(children { ref }) 劫持 ref,mount 時將子組件 ref 添加到 Contextunmount 時則移除。react-photo-view[4] 的封裝方式也是如此。即實現 3、4 兩點。

Flipped.tsx

import React, {
  cloneElement,
  memo,
  useContext,
  useLayoutEffect,
  useRef,
} from 'react';
import { FlipContext } from './FlipContext';

export interface FlippedProps {
  children: React.ReactElement;
  innerRef?: React.RefObject<HTMLElement>;
}

function Flipped({ children, innerRef }: FlippedProps) {
  // Flipper.tsx 將 ref 通過 Context 傳遞,避免穿透渲染
  const ctxRef = useContext(FlipContext);
  const ref = useRef<HTMLElement>(null);
  const currentRef = innerRef || ref;

  useLayoutEffect(() ={
    const ctx = ctxRef.current;
    const node = currentRef.current;
    // 生成唯一 ID
    const flipId = ctx.nextId();

    if (node) {
      // mount 後添加節點
      ctx.add({ flipId, node });
    }

    return () ={
      // unmout 後刪除節點
      ctx.remove(flipId);
    };
  }[]);

  return cloneElement(children, { ref: currentRef });
}

export default memo(Flipped);

好了,看一下如何使用,一共就兩個 API,從原本的 JSX 只需包裹一下就有動畫了:

<Flipper flipKey={data}>
  <div className={styles.list}>
    {data.map((item) =(
      <Flipped key={item}>
        <div className={styles.item}>{item}</div>
      </Flipped>
    ))}
  </div>
</Flipper>

是不是超簡單!最後,還剩性能問題一個非常重要的指標。因爲每個節點都是獨立的動畫,數據量大了之後渲染肯定卡頓。經過測試,5000 個 DIV 節點的數字數組的隨機動畫完成更新時間爲大約 2 秒,這是很不能接受的。我們可以只允許屏幕內的節點有動畫,其他節點就跳過,只需要稍微判斷一下兩個狀態都不在屏幕內就好了,這可以節約 2 / 3 的時間:

const isLastRectOverflow =
  rect.right < 0 ||
  rect.left > innerWidth ||
  rect.bottom < 0 ||
  rect.top > innerHeight;

const isCurrentRectOverflow =
  currentRect.right < 0 ||
  currentRect.left > innerWidth ||
  currentRect.bottom < 0 ||
  currentRect.top > innerHeight;

if (isLastRectOverflow && isCurrentRectOverflow) {
  return;
}

// node.animate() ...

記得之前 react-beautiful-dnd[5] 庫剛出來的時候拖拽動畫迷倒了不少人。但是現在有了 FLIP 再配合 react-dnd[6] 就可以輕鬆實現此類動畫,功能上就更是屬於碾壓狀態。而 react-motion[7] 之類的動畫庫實現該動畫就繁雜很多,因爲它用的是絕對定位控制的類型。下面的例子僅僅用剛封裝的 Flipper 包裹了一下:

以下是源碼:

https://github.com/MinJieLiu/react-flip-demo[8] 其中裏面的 Flipper 組件目錄可以直接拷貝到項目中使用,100 來行代碼相當輕量 🤭。

注意:Web Animation 只兼容 Chrome 75 以上,兼容古董瀏覽器可以考慮 Web Animations API polyfill[9]。

現成的方案

什麼?你有更復雜的動畫需求,自己不想動手,可以看看這個,支持更多特性

react-flip-toolkit[10] 一款有 3.4K Star FLIP 的庫。實現了你所能想到的功能。

交錯效果:

嵌套比例變換:

路由動畫:

以及更多

結語

相信這種動畫思路肯定能大幅度簡化編寫動畫的門檻,想起自己以前傻傻的用絕對定位計算位置,真是可笑可笑~ 😂😂

參考資料

[1]

https://minjieliu.github.io/react-flip-demo: https://minjieliu.github.io/react-flip-demo

[2]

DOM 位置信息改變了,而瀏覽器還沒渲染: https://juejin.cn/post/6844904165462769678

[3]

Web Animation API: https://developer.mozilla.org/zh-CN/docs/Web/API/Animation

[4]

react-photo-view: https://react-photo-view.vercel.app

[5]

react-beautiful-dnd: https://react-beautiful-dnd.netlify.app

[6]

react-dnd: https://react-dnd.github.io/react-dnd/examples/sortable/simple

[7]

react-motion: http://chenglou.github.io/react-motion/demos/demo8-draggable-list

[8]

https://github.com/MinJieLiu/react-flip-demo: https://github.com/MinJieLiu/react-flip-demo

[9]

Web Animations API polyfill: https://github.com/web-animations/web-animations-js

[10]

react-flip-toolkit: https://github.com/aholachek/react-flip-toolkit

[11]

Guitar 商城: https://react-flip-toolkit-demos.surge.sh/guitar

[12]

React-flip-toolkit logo: https://codepen.io/aholachek/pen/ERRpEj

[13]

使用 Portals: https://react-flip-toolkit-demos.surge.sh/portal

[14]

Vue 實現: https://juejin.cn/post/6844904179572424711

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