前端必學的動畫實現思路!
一個合理的動畫是良好用戶體驗中必不可少的一部分。我們平常是怎樣寫動畫的?CSS 中的 animation
和 transition
,還有 requestAnimationFrame
?相信大家寫動畫的時候心裏也是在萬馬奔騰。今天我們從一個另闢蹊徑的角度來探索一個動畫實現。
示例
請看下面的示例:
這是一個可添加的數字的隨機亂序列表。首先想一想,我們第一直覺可能會這樣做:將這些數字的 DOM 節點用絕對定位來佈局,數字變化後計算 top
、left
的值,再配合 transition
實現該動畫。這種方式看似簡單,其實內部要維護各種位置信息,所有座標都需要手動管理,相當繁雜,非常不利於後期擴展。如果這些節點換成高度不固定的圖片,那計算量可想而知。
那有沒有一種更好的方式實現呢?肯定的,接下來介紹一個金光閃閃的概念:FLIP
。
提前預覽:
https://minjieliu.github.io/react-flip-demo[1]
FLIP
FLIP
其實是幾個單詞的縮寫:即 First、Last 、Invert 、Play。
讓我們分解一下:
First
涉及動畫的元素的初始狀態(比如位置、縮放、透明等)。
Last
涉及動畫的元素的最終狀態。
Invert
這一步爲核心,即找出這個元素是如何變化的。例如該元素在 First 和 Last 之間向右移動了 50px,你就需要在 X 方向 translateX(-50px)
,使元素看起來在 First 位置。
這裏有一個知識點值得注意,DOM 元素屬性的改變(比如 left
、right
、transform
等),會被集中起來延遲到瀏覽器下一幀統一渲染,所以我們可以得到一個這樣的中間時間點:DOM 位置信息改變了,而瀏覽器還沒渲染 [2]。也就意味着在一定的時間內,我們能獲取 DOM 改變後的位置,但在瀏覽器中位置還未改變。經測試,這個過程超過 10ms 就顯得不穩定了。因此 setTimeout(fn, 0)
、 React useEffect
和 Vue $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>
);
}
首先,我們需要記錄 First
和 Last
的位置信息,並用來計算 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]);
現在,我們就可以把之前的快照進行遍歷,實現 Invert
並 Play
:
// 遍歷之前的快照
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 的一個變換過程。預覽下:
你發現沒有,每次做完操作都需要手動更新快照,作爲開發者不能忍,我們要懶到極致,好好封裝一下。
直白需求:
-
數據變化後自動執行動畫
-
可以不關心任何動畫邏輯
-
不要限制 DOM 結構
-
用法要簡單
-
性能要好
開幹!
在 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
添加到 Context
,unmount
時則移除。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 的庫。實現了你所能想到的功能。
交錯效果:
嵌套比例變換:
路由動畫:
以及更多
-
Guitar 商城 [11]
-
React-flip-toolkit logo[12]
-
使用 Portals[13]
結語
相信這種動畫思路肯定能大幅度簡化編寫動畫的門檻,想起自己以前傻傻的用絕對定位計算位置,真是可笑可笑~ 😂😂
參考資料
[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