CSS

前言

一个合理的动画是良好用户体验中必不可少的一部分。平常是怎样写动画的?CSS 中的 animationtransition,还有 requestAnimationFrame?相信大家写动画的时候心里也是在万马奔腾。从一个另辟蹊径的角度来探索一个动画实现。

示例

请看下面的示例:
高级动画实现思路 - 图1
这是一个可添加的数字的随机乱序列表。首先想一想,第一直觉可能会这样做:将这些数字的 DOM 节点用绝对定位来布局,数字变化后计算 top、left 的值,再配合 transition 实现该动画。这种方式看似简单,其实内部要维护各种位置信息,所有坐标都需要手动管理,相当繁杂,非常不利于后期扩展。如果这些节点换成高度不固定的图片,那计算量可想而知。
那有没有一种更好的方式实现呢?肯定的,接下来介绍一个金光闪闪的概念:FLIP。
提前预览:https://minjieliu.github.io/react-flip-demo

FLIP

FLIP 其实是几个单词的缩写:即 First、Last 、Invert 、Play。
分解一下:

First

涉及动画的元素的初始状态(比如位置、缩放、透明等)。

Last

涉及动画的元素的最终状态。

Invert

这一步为核心,即找出这个元素是如何变化的。例如该元素在 FirstLast 之间向右移动了 50px,就需要在 X 方向 translateX(-50px),使元素看起来在 First 位置。
这里有一个知识点值得注意,DOM 元素属性的改变(比如 left、right、transform 等),会被集中起来延迟到浏览器下一帧统一渲染,所以可以得到一个这样的中间时间点:DOM 位置信息改变了,而浏览器还没渲染。也就意味着在一定的时间内,能获取 DOM 改变后的位置,但在浏览器中位置还未改变。经测试,这个过程超过 10ms 就显得不稳定了。因此 setTimeout(fn, 0)、 React useEffect 和 Vue $nextTick 都可以实现 Invert 过程。
Play
即从 Invert 回到最终状态,有了两个点的位置信息,中间的过渡动画就可以使用 transition 实现。本文采用 Web Animation API 实现,动画执行过程中不会添加 CSS 到 DOM 上,相当干净。
高级动画实现思路 - 图2

实现

这里主要使用 React 方式实现该效果,其他框架原理都一样可参考。
一个列表,将子元素 5 列为一行:

  1. .list {
  2. display: flex;
  3. flex-wrap: wrap;
  4. width: 400px;
  5. }
  6. .item {
  7. display: flex;
  8. align-items: center;
  9. justify-content: center;
  10. width: 80px;
  11. height: 80px;
  12. border: 1px solid #eee;
  13. }
  14. function ListShuffler() {
  15. const [data, setData] = useState([0, 1, 2, 3, 4, 5]);
  16. const listRef = useRef<HTMLDivElement>(null);
  17. return (
  18. <div className={styles.list} ref={listRef}>
  19. {data.map((item) => (
  20. <div key={item} className={styles.item}>
  21. {item}
  22. </div>
  23. ))}
  24. </div>
  25. );
  26. }

首先,需要记录 First 和 Last 的位置信息,并用来计算 Invert 偏移差,因此用 Map 对象来存储最合适不过了,有了这个方法,就可以用它来生成前后快照:

  1. function createChildElementRectMap(nodes: HTMLElement | null | undefined) {
  2. if (!nodes) {
  3. return new Map();
  4. }
  5. const elements = Array.from(nodes.childNodes) as HTMLElement[];
  6. // 使用节点作为 Map 的 key 存储当前快照,下次直接用 node 引用取值,相当方便
  7. return new Map(elements.map((node) => [node, node.getBoundingClientRect()]));
  8. }

点击添加的时候记录 First 快照:

  1. // 使用 ref 存储 DOM 之前的位置信息
  2. const lastRectRef = useRef<Map<HTMLElement, DOMRect>>(new Map());
  3. function handleAdd() {
  4. // 添加一条到顶部,让后面节点运动
  5. setData((prev) => [prev.length, ...prev]);
  6. // 并存储改变前的 DOM 快照
  7. lastRectRef.current = createChildElementRectMap(listRef.current);
  8. }

接下来 DOM 更新后还需要改变后的快照,在 React 中,无论是 useEffect 还是 useLayoutEffect 这里都可以拿到:

  1. useLayoutEffect(() => {
  2. // 改变后的 DOM 快照,此时 UI 并未更新
  3. const currentRectMap = createChildElementRectMap(listRef.current);
  4. }, [data]);

现在就可以把之前的快照进行遍历,实现 Invert 并 Play:

  1. // 遍历之前的快照
  2. lastRectRef.current.forEach((prevRect, node) => {
  3. // 前后快照的 DOM 引用一样,可以直接获取
  4. const currentRect = currentRectMap.get(node);
  5. // Invert
  6. const invert = {
  7. left: prevRect.left - currentRect.left,
  8. top: prevRect.top - currentRect.top,
  9. };
  10. const keyframes = [
  11. {
  12. transform: `translate(${invert.left}px, ${invert.top}px)`,
  13. },
  14. { transform: 'translate(0, 0)' },
  15. ];
  16. // Play 执行动画
  17. node.animate(keyframes, {
  18. duration: 800,
  19. easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)',
  20. });
  21. });

大功告成!这里每个节点有单独的动画,各个节点之间互不冲突。也就是说无论节点位置多么复杂,处理起来都能从容应对。
比如图片乱序只需要从 lodash 引入 shuffle 修改数据就可以完美实现展现。

  1. import { shuffle } from 'lodash-es';
  2. function shuffleList() {
  3. setData(shuffle);
  4. // 并存储改变前的 DOM 快照
  5. lastRectRef.current = createChildElementRectMap(listRef.current);
  6. }

以上总体思路就是 First -> Last -> Invert -> Play 的一个变换过程。预览下:
高级动画实现思路 - 图3
发现没有,每次做完操作都需要手动更新快照,作为开发者不能忍,要懒到极致,好好封装一下。
直白需求:

  1. 数据变化后自动执行动画
  2. 可以不关心任何动画逻辑
  3. 不要限制 DOM 结构
  4. 用法要简单
  5. 性能要好

开干!
在 React 更新模型中,执行顺序为:setState -> render -> layoutEffect。因此可以把 setState 生成快照的步骤放到 render 中,从而与操作解耦。(如果放到 useLayoutEffect 中动画频繁会出现位置计算不准确的问题)

  1. useMemo(() => {
  2. // render 时立即执行
  3. lastRectRef.current.forEach((item) => {
  4. item.rect = item.node.getBoundingClientRect();
  5. });
  6. }, [data]);

加上之前 useLayoutEffect 那部分逻辑,可以抽到一个独立组件中(Flipper),用 flipKey 来控制,只要 flipKey 变化就执行动画,即实现 1、2 两点。
Flipper.tsx

  1. export default function Flipper({ flipKey, children }: FlipperProps) {
  2. const lastRectRef = useRef<Map<number, FlipItemType>>(new Map());
  3. const uniqueIdRef = useRef(0);
  4. // 通过 ref 创建函数,传递 context 避免引起穿透渲染
  5. const fnRef = useRef<IFlipContext>({
  6. add(flipItem) {
  7. lastRectRef.current.set(flipItem.flipId, flipItem);
  8. },
  9. remove(flipId) {
  10. lastRectRef.current.delete(flipId);
  11. },
  12. nextId() {
  13. return (uniqueIdRef.current += 1);
  14. },
  15. });
  16. useMemo(() => {
  17. lastRectRef.current.forEach((item) => {
  18. item.rect = item.node.getBoundingClientRect();
  19. });
  20. }, [flipKey]);
  21. useLayoutEffect(() => {
  22. const currentRectMap = new Map<number, DOMRect>();
  23. lastRectRef.current.forEach((item) => {
  24. currentRectMap.set(item.flipId, item.node.getBoundingClientRect());
  25. });
  26. lastRectRef.current.forEach(() => {
  27. // 之前的 FLIP 代码
  28. });
  29. }, [flipKey]);
  30. return <FlipContext.Provider value={fnRef}>{children}</FlipContext.Provider>;
  31. }

最开始的方式是通过原生方法遍历 DOM,因此只能限制子节点一个层级,并且操作方式也脱离的 React 的编写模型,加以改进可以使用 Context 来通信存储:
FlipContext.ts

  1. import React, { createContext } from 'react';
  2. export type FlipItemType = {
  3. // 子组件的唯一标识
  4. flipId: number;
  5. // 子组件通过 ref 获取的节点
  6. node: HTMLElement;
  7. // 子组件的位置快照
  8. rect?: DOMRect;
  9. };
  10. export interface IFlipContext {
  11. // mount 后执行 add
  12. add: (item: FlipItemType) => void;
  13. // unout 后执行 remove
  14. remove: (flipId: number) => void;
  15. // 自增唯一 id
  16. nextId: () => number;
  17. }
  18. export const FlipContext = createContext(
  19. undefined as unknown as React.MutableRefObject<IFlipContext>,
  20. );

最后则是要实现采集每个动画元素的节点。将动画的节点使用自定义组件 Flipped 包裹并 cloneElement(children { ref }) 劫持 ref,mount 时将子组件 ref 添加到 Context,unmount 时则移除。react-photo-view 的封装方式也是如此。即实现 3、4 两点。
Flipped.tsx

  1. import React, {
  2. cloneElement,
  3. memo,
  4. useContext,
  5. useLayoutEffect,
  6. useRef,
  7. } from 'react';
  8. import { FlipContext } from './FlipContext';
  9. export interface FlippedProps {
  10. children: React.ReactElement;
  11. innerRef?: React.RefObject<HTMLElement>;
  12. }
  13. function Flipped({ children, innerRef }: FlippedProps) {
  14. // Flipper.tsx 将 ref 通过 Context 传递,避免穿透渲染
  15. const ctxRef = useContext(FlipContext);
  16. const ref = useRef<HTMLElement>(null);
  17. const currentRef = innerRef || ref;
  18. useLayoutEffect(() => {
  19. const ctx = ctxRef.current;
  20. const node = currentRef.current;
  21. // 生成唯一 ID
  22. const flipId = ctx.nextId();
  23. if (node) {
  24. // mount 后添加节点
  25. ctx.add({ flipId, node });
  26. }
  27. return () => {
  28. // unmout 后删除节点
  29. ctx.remove(flipId);
  30. };
  31. }, []);
  32. return cloneElement(children, { ref: currentRef });
  33. }
  34. export default memo(Flipped);

好了,看一下如何使用,一共就两个 API,从原本的 JSX 只需包裹一下就有动画了:

  1. <Flipper flipKey={data}>
  2. <div className={styles.list}>
  3. {data.map((item) => (
  4. <Flipped key={item}>
  5. <div className={styles.item}>{item}</div>
  6. </Flipped>
  7. ))}
  8. </div>
  9. </Flipper>

是不是超简单!最后,还剩性能问题一个非常重要的指标。因为每个节点都是独立的动画,数据量大了之后渲染肯定卡顿。经过测试,5000 个 DIV 节点的数字数组的随机动画完成更新时间为大约 2 秒,这是很不能接受的。可以只允许屏幕内的节点有动画,其他节点就跳过,只需要稍微判断一下两个状态都不在屏幕内就好了,这可以节约 2 / 3 的时间:

  1. const isLastRectOverflow =
  2. rect.right < 0 ||
  3. rect.left > innerWidth ||
  4. rect.bottom < 0 ||
  5. rect.top > innerHeight;
  6. const isCurrentRectOverflow =
  7. currentRect.right < 0 ||
  8. currentRect.left > innerWidth ||
  9. currentRect.bottom < 0 ||
  10. currentRect.top > innerHeight;
  11. if (isLastRectOverflow && isCurrentRectOverflow) {
  12. return;
  13. }
  14. // node.animate() ...

记得之前 react-beautiful-dnd 库刚出来的时候拖拽动画迷倒了不少人。但是现在有了 FLIP 再配合 react-dnd 就可以轻松实现此类动画,功能上就更是属于碾压状态。而 react-motion 之类的动画库实现该动画就繁杂很多,因为它用的是绝对定位控制的类型。下面的例子仅仅用刚封装的 Flipper 包裹了一下:
高级动画实现思路 - 图4

以下是源码:
https://github.com/MinJieLiu/react-flip-demo 其中里面的 Flipper 组件目录可以直接拷贝到项目中使用,100 来行代码相当轻量 🤭。
注意:Web Animation 只兼容 Chrome 75 以上,兼容古董浏览器可以考虑 Web Animations API polyfill

现成的方案

如果有更复杂的动画需求,自己不想动手,可以看看这个,支持更多特性
react-flip-toolkit 一款有 3.4K Star FLIP 的库。实现了所能想到的功能。
交错效果:
高级动画实现思路 - 图5
嵌套比例变换:
高级动画实现思路 - 图6
路由动画:
高级动画实现思路 - 图7
以及更多