新聞中心
一個(gè)合理的動畫是良好用戶體驗(yàn)中必不可少的一部分。我們平常是怎樣寫動畫的?CSS 中的 animation 和 transition,還有 requestAnimationFrame?

創(chuàng)新互聯(lián)公司于2013年創(chuàng)立,是專業(yè)互聯(lián)網(wǎng)技術(shù)服務(wù)公司,擁有項(xiàng)目網(wǎng)站設(shè)計(jì)、成都網(wǎng)站設(shè)計(jì)網(wǎng)站策劃,項(xiàng)目實(shí)施與項(xiàng)目整合能力。我們以讓每一個(gè)夢想脫穎而出為使命,1280元東昌做網(wǎng)站,已為上家服務(wù),為東昌各地企業(yè)和個(gè)人服務(wù),聯(lián)系電話:18980820575
示例
請看下面的示例:
這是一個(gè)可添加的數(shù)字的隨機(jī)亂序列表。首先想一想,我們第一直覺可能會這樣做:將這些數(shù)字的 DOM 節(jié)點(diǎn)用絕對定位來布局,數(shù)字變化后計(jì)算 top、left 的值,再配合 transition 實(shí)現(xiàn)該動畫。這種方式看似簡單,其實(shí)內(nèi)部要維護(hù)各種位置信息,所有坐標(biāo)都需要手動管理,相當(dāng)繁雜,非常不利于后期擴(kuò)展。如果這些節(jié)點(diǎn)換成高度不固定的圖片,那計(jì)算量可想而知。
那有沒有一種更好的方式實(shí)現(xiàn)呢?肯定的,接下來介紹一個(gè)金光閃閃的概念:FLIP。
提前預(yù)覽:
??https://minjieliu.github.io/react-flip-demo???
FLIP
FLIP 其實(shí)是幾個(gè)單詞的縮寫:即 First、Last 、Invert 、Play。
讓我們分解一下:
First
涉及動畫的元素的初始狀態(tài)(比如位置、縮放、透明等)。
Last
涉及動畫的元素的最終狀態(tài)。
Invert
這一步為核心,即找出這個(gè)元素是如何變化的。例如該元素在 First 和 Last 之間向右移動了 50px,你就需要在 X 方向 translateX(-50px),使元素看起來在 First 位置。
這里有一個(gè)知識點(diǎn)值得注意,DOM 元素屬性的改變(比如 left、right、transform 等),會被集中起來延遲到瀏覽器下一幀統(tǒng)一渲染,所以我們可以得到一個(gè)這樣的中間時(shí)間點(diǎn):DOM 位置信息改變了,而瀏覽器還沒渲染[1]。也就意味著在一定的時(shí)間內(nèi),我們能獲取 DOM 改變后的位置,但在瀏覽器中位置還未改變。經(jīng)測試,這個(gè)過程超過 10ms 就顯得不穩(wěn)定了。因此 setTimeout(fn, 0)、 React useEffect 和 Vue $nextTick 都可以實(shí)現(xiàn) Invert 過程。
Play
即從 Invert 回到最終狀態(tài),有了兩個(gè)點(diǎn)的位置信息,中間的過渡動畫就可以使用 transition 實(shí)現(xiàn)。本文采用 Web Animation API[2] 實(shí)現(xiàn),動畫執(zhí)行過程中不會添加 CSS 到 DOM 上,相當(dāng)干凈。
實(shí)現(xiàn)
這里主要使用 React 方式實(shí)現(xiàn)該效果,其他框架原理都一樣可參考。
一個(gè)列表,將子元素 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(null);
return (
{data.map((item) => (
{item}
))}
);
} 首先,我們需要記錄 First 和 Last 的位置信息,并用來計(jì)算 Invert 偏移差,因此用 Map 對象來存儲最合適不過了,有了這個(gè)方法,我們就可以用它來生成前后快照:
function createChildElementRectMap(nodes: HTMLElement | null | undefined) {
if (!nodes) {
return new Map();
}
const elements = Array.from(nodes.childNodes) as HTMLElement[];
// 使用節(jié)點(diǎn)作為 Map 的 key 存儲當(dāng)前快照,下次直接用 node 引用取值,相當(dāng)方便
return new Map(elements.map((node) => [node, node.getBoundingClientRect()]));
}點(diǎn)擊添加的時(shí)候記錄 First 快照:
// 使用 ref 存儲 DOM 之前的位置信息
const lastRectRef = useRef
接下來 DOM 更新后還需要改變后的快照,在 React 中,無論是 useEffect 還是 useLayoutEffect 這里都可以拿到:
useLayoutEffect(() => {
// 改變后的 DOM 快照,此時(shí) UI 并未更新
const currentRectMap = createChildElementRectMap(listRef.current);
}, [data]);現(xiàn)在,我們就可以把之前的快照進(jìn)行遍歷,實(shí)現(xiàn) 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 執(zhí)行動畫
node.animate(keyframes, {
duration: 800,
easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)',
});
});
大功告成!這里每個(gè)節(jié)點(diǎn)有單獨(dú)的動畫,各個(gè)節(jié)點(diǎn)之間互不沖突。也就是說無論節(jié)點(diǎn)位置多么復(fù)雜,處理起來都能從容應(yīng)對。
比如圖片亂序只需要從 lodash 引入 shuffle 修改數(shù)據(jù)就可以完美實(shí)現(xiàn)展現(xiàn)。
import { shuffle } from 'lodash-es';
function shuffleList() {
setData(shuffle);
// 并存儲改變前的 DOM 快照
lastRectRef.current = createChildElementRectMap(listRef.current);
}以上總體思路就是 First -> Last -> Invert -> Play 的一個(gè)變換過程。預(yù)覽下:
你發(fā)現(xiàn)沒有,每次做完操作都需要手動更新快照,作為開發(fā)者不能忍,我們要懶到極致,好好封裝一下。
直白需求:
- 數(shù)據(jù)變化后自動執(zhí)行動畫
- 可以不關(guān)心任何動畫邏輯
- 不要限制 DOM 結(jié)構(gòu)
- 用法要簡單
- 性能要好
開干!
在 React 更新模型中,執(zhí)行順序?yàn)椋簊etState -> render -> layoutEffect。因此可以把 setState 生成快照的步驟放到 render 中,從而與操作解耦。(如果放到 useLayoutEffect 中動畫頻繁會出現(xiàn)位置計(jì)算不準(zhǔn)確的問題)
useMemo(() => {
// render 時(shí)立即執(zhí)行
lastRectRef.current.forEach((item) => {
item.rect = item.node.getBoundingClientRect();
});
}, [data]);加上之前 useLayoutEffect 那部分邏輯,我們可以抽到一個(gè)獨(dú)立組件中(Flipper),用 flipKey 來控制,只要 flipKey 變化就執(zhí)行動畫,即實(shí)現(xiàn) 1、2 兩點(diǎn)。
Flipper.tsx
export default function Flipper({ flipKey, children }: FlipperProps) {
const lastRectRef = useRef最開始的方式是通過原生方法遍歷 DOM,因此我們只能限制子節(jié)點(diǎn)一個(gè)層級,并且操作方式也脫離的 React 的編寫模型,加以改進(jìn)可以使用 Context 來通信存儲:
FlipContext.ts
import React, { createContext } from 'react';
export type FlipItemType = {
// 子組件的唯一標(biāo)識
flipId: number;
// 子組件通過 ref 獲取的節(jié)點(diǎn)
node: HTMLElement;
// 子組件的位置快照
rect?: DOMRect;
};
export interface IFlipContext {
// mount 后執(zhí)行 add
add: (item: FlipItemType) => void;
// unout 后執(zhí)行 remove
remove: (flipId: number) => void;
// 自增唯一 id
nextId: () => number;
}
export const FlipContext = createContext(
undefined as unknown as React.MutableRefObject,
); 最后則是要實(shí)現(xiàn)采集每個(gè)動畫元素的節(jié)點(diǎn)。將動畫的節(jié)點(diǎn)使用自定義組件 Flipped 包裹并 cloneElement(children { ref }) 劫持 ref,mount 時(shí)將子組件 ref 添加到 Context,unmount 時(shí)則移除。react-photo-view[3] 的封裝方式也是如此。即實(shí)現(xiàn) 3、4 兩點(diǎn)。
Flipped.tsx
import React, {
cloneElement,
memo,
useContext,
useLayoutEffect,
useRef,
} from 'react';
import { FlipContext } from './FlipContext';
export interface FlippedProps {
children: React.ReactElement;
innerRef?: React.RefObject;
}
function Flipped({ children, innerRef }: FlippedProps) {
// Flipper.tsx 將 ref 通過 Context 傳遞,避免穿透渲染
const ctxRef = useContext(FlipContext);
const ref = useRef(null);
const currentRef = innerRef || ref;
useLayoutEffect(() => {
const ctx = ctxRef.current;
const node = currentRef.current;
// 生成唯一 ID
const flipId = ctx.nextId();
if (node) {
// mount 后添加節(jié)點(diǎn)
ctx.add({ flipId, node });
}
return () => {
// unmout 后刪除節(jié)點(diǎn)
ctx.remove(flipId);
};
}, []);
return cloneElement(children, { ref: currentRef });
}
export default memo(Flipped); 好了,看一下如何使用,一共就兩個(gè) API,從原本的 JSX 只需包裹一下就有動畫了:
{data.map((item) => (
{item}
))}
是不是超簡單!最后,還剩性能問題一個(gè)非常重要的指標(biāo)。因?yàn)槊總€(gè)節(jié)點(diǎn)都是獨(dú)立的動畫,數(shù)據(jù)量大了之后渲染肯定卡頓。經(jīng)過測試,5000 個(gè) DIV 節(jié)點(diǎn)的數(shù)字?jǐn)?shù)組的隨機(jī)動畫完成更新時(shí)間為大約 2 秒,這是很不能接受的。我們可以只允許屏幕內(nèi)的節(jié)點(diǎn)有動畫,其他節(jié)點(diǎn)就跳過,只需要稍微判斷一下兩個(gè)狀態(tài)都不在屏幕內(nèi)就好了,這可以節(jié)約 2 / 3 的時(shí)間:
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[4] 庫剛出來的時(shí)候拖拽動畫迷倒了不少人。但是現(xiàn)在有了 FLIP 再配合 react-dnd[5] 就可以輕松實(shí)現(xiàn)此類動畫,功能上就更是屬于碾壓狀態(tài)。而 react-motion[6] 之類的動畫庫實(shí)現(xiàn)該動畫就繁雜很多,因?yàn)樗玫氖墙^對定位控制的類型。下面的例子僅僅用剛封裝的 Flipper 包裹了一下:
以下是源碼:
https://github.com/MinJieLiu/react-flip-demo 其中里面的 Flipper 組件目錄可以直接拷貝到項(xiàng)目中使用,100 來行代碼相當(dāng)輕量 ????。
注意:Web Animation 只兼容 Chrome 75 以上,兼容古董瀏覽器可以考慮 Web Animations API polyfill[7]。
現(xiàn)成的方案
什么?你有更復(fù)雜的動畫需求,自己不想動手,可以看看這個(gè),支持更多特性
react-flip-toolkit[8] 一款有 3.4K Star FLIP 的庫。實(shí)現(xiàn)了你所能想到的功能。
交錯(cuò)效果:
嵌套比例變換:
以及更多
- Guitar 商城[9]
- React-flip-toolkit logo[10]
- 使用 Portals[11]
結(jié)語
相信這種動畫思路肯定能大幅度簡化編寫動畫的門檻,想起自己以前傻傻的用絕對定位計(jì)算位置,真是可笑可笑~
本文題目:前端必學(xué)的動畫實(shí)現(xiàn)思路!
文章出自:http://www.dlmjj.cn/article/djjsjhp.html


咨詢
建站咨詢
