陌上人如玉
公子世无双

1.15 React 中的动画

1.15 React 中的动画

一句话:React 里做动画的真正难点只有两个——「卸载动画」(组件一卸载,DOM 立刻消失,没机会播动画)与「位置变化动画」(元素从 A 位置跳到 B 位置,没有过渡)——前者用「延迟卸载 + 动画结束回调」,后者用「FLIP」;而「单个元素的入场/状态切换」交给 CSS transition / @starting-style 就够了。

一、三条路:先选对工具

方案 适合 优点 代价
CSS transition / animation 状态切换、hover、入场、循环 ⭐ 跑在合成线程(主线程忙也不掉帧,见 5.17) 需要「有个状态变化」才能过渡
Web Animations API(el.animate) 需要 JS 控制时序/中断/编排 可 pause/reverse/currentTime/await finished 手写 keyframes
React 状态 + 类名切换 由 React 状态驱动的过渡 声明式、好调试 ⚠️ 「卸载」时没有时间窗(见下节)
动画库(framer-motion / react-spring / auto-animate) 布局动画、手势、编排、AnimatePresence 省事、功能全 体积(几 KB ~ 几十 KB)+ 学习成本
⭐⭐ 一个「怎么选」的快速判据:

   · 「改 hover / 展开收起 / 淡入淡出」→ **CSS transition**(最省,走合成)
   · 「打开/关闭弹窗、列表项增删(需要卸载动画)」→ **延迟卸载 + CSS**,或直接上库
   · 「列表重排、网格位置变化」→ **FLIP**
   · 「拖拽跟随、手势反馈」→ **WAAPI 或 rAF + transform**
   · 「复杂的编排(多个元素依次入场、spring 物理曲线)」→ **库**

二、核心难点一:卸载动画

⭐⭐⭐ 为什么「卸载动画」在 React 里特别别扭:

   React 的卸载是「同步、立刻」的:
     {open && }    →  open 变 false → Modal 的 DOM **立刻**被移除
   → ⭐ 没有任何「时间窗」可以播退场动画。

   ✅ 三种解法:
     ① 【延迟卸载】把「是否渲染」与「是否可见」拆成两个状态,
        等动画结束再真正卸载(本节的实现)
     ② 【CSS 原生】:`@starting-style` + `transition-behavior: allow-discrete`
        (对 ``/popover 这类「display 切换」的场景很香)
     ③ 【``(React 19 实验)】:把子树「隐藏但不卸载」
        → 天然支持退场动画 + 保留 state(见 4.13)
// ✅ 解法 ①:useMountTransition —— 延迟卸载
import { useState, useEffect, useRef, useCallback } from 'react';

export function useMountTransition(
  open: boolean,
  durationMs = 240,
  options: { unmountOnExit?: boolean } = { unmountOnExit: true }
) {
  const [shouldRender, setShouldRender] = useState(open);
  const [isEntering, setIsEntering] = useState(false);        // ⭐ 正在进场(用于加类名)
  const timerRef = useRef | null>(null);

  useEffect(() => {
    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }

    if (open) {
      // ⭐ 打开:立刻渲染,并在「下一帧」标记为「已完成进场」
      setShouldRender(true);
      setIsEntering(true);
      // 下一帧再移除「进场中」标记 → CSS 从「初始态」过渡到「终态」
      const raf = requestAnimationFrame(() => {
        timerRef.current = setTimeout(() => setIsEntering(false), durationMs);
      });
      return () => cancelAnimationFrame(raf);
    }

    // ⭐ 关闭:先开始退场动画,等动画结束再真正卸载
    if (!shouldRender) return;
    setIsEntering(true);
    timerRef.current = setTimeout(() => {
      setShouldRender(false);
      setIsEntering(false);
    }, durationMs);
  }, [open, durationMs, shouldRender]);

  useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);

  return {
    shouldRender,                 // ⭐ 控制「是否渲染」
    isEntering,                   // ⭐ 控制「用哪一组类名/样式」
    // 便捷类名(配合下面的 CSS)
    className: open ? (isEntering ? 'is-entering' : 'is-open') : (isEntering ? 'is-leaving' : 'is-closed'),
  };
}
/* ✅ 配合的 CSS(用 data 属性或类名切换两态) */
.modal-content {
  opacity: 1;
  transform: translateY(0) scale(1);
  transition: opacity 240ms ease, transform 240ms cubic-bezier(.2, .8, .2, 1);
}

/* 进场起点:不可见 + 位移 */
.modal-content.is-entering {
  opacity: 0;
  transform: translateY(8px) scale(0.98);
}

/* 退场终点:同起点(用「方向相反」的位移也可以) */
.modal-content.is-leaving {
  opacity: 0;
  transform: translateY(8px) scale(0.98);
}
// ✅ 用法
function Modal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
  const transition = useMountTransition(open, 240);
  const container = usePortalContainer('modal');       // 见 1.12

  // ⭐ 只有「该渲染」时才渲染(DOM 还在,动画能播)
  if (!transition.shouldRender || !container) return null;

  return createPortal(
    <>
      
{children}
, container ); }
⭐⭐⭐ 这个实现里有四个「必须处理」的细节(漏一个体验就崩):

① 【「关闭」时不能立刻卸载】
   否则动画根本没有播放的机会(DOM 已经没了)。

② 【「退场期间」要禁用交互】
   `pointerEvents: 'none'` —— 否则用户能在「正在消失的弹窗」上点到按钮(误操作)。

③ 【进场要「分两帧」】
   如果「挂载时就把最终态直接写上去」,CSS transition 没有「起始值」可过渡。
   ⭐ 所以流程是「挂载时用初始态 → 下一帧(rAF)改成终态」。
   ⚠️ 更优雅的原生写法是 `@starting-style`(见下)。

④ 【动画时长要与 CSS 一致】
   用 `setTimeout(durationMs)` 判断「动画结束」是**脆的**(CSS 改了时长、或用 spring 曲线就不同步)。
   ⭐ 更可靠的是监听 `transitionend`/`animationend`(但要处理「被打断」「多个属性」的情况)。
/* ⭐ 解法 ②:CSS 原生「进场/退场」——@starting-style + allow-discrete */
.dialog {
  /* 初始(关闭)状态 */
  opacity: 0;
  transform: translateY(8px) scale(0.98);
  display: none;

  /* ⭐ allow-discrete 让「离散属性(display/overlay)」也能过渡 */
  transition:
    opacity 240ms ease,
    transform 240ms cubic-bezier(.2, .8, .2, 1),
    display 240ms allow-discrete,
    overlay 240ms allow-discrete;
}

.dialog[open] {
  opacity: 1;
  transform: none;
  display: block;
}

/* ⭐ @starting-style:告诉浏览器「元素第一次渲染时的起始样式」 */
/*    → 这让「刚挂载的元素」也能有 transition(不需要 JS 分两帧) */
@starting-style {
  .dialog[open] {
    opacity: 0;
    transform: translateY(8px) scale(0.98);
  }
}
⭐⭐ `@starting-style` 解决了「**刚挂载的元素没法过渡**」这个老问题:

   【以前】新挂载的元素「第一帧就是终态」→ 没有起始值 → 不播过渡
     → 必须用 JS「先设初始态,下一帧改终态」(本节的 useMountTransition)
   【现在】`@starting-style` 直接声明「第一次渲染时的样式」
     → 浏览器会把「starting style → 计算样式」当作一次过渡 → 自动播进场动画
     ⭐ 纯 CSS、不需要 JS 分两帧

   ⚠️ 兼容性:Chrome 117+ / Safari 17.5+ / Firefox 129+(2024 年铺开)
     → 需要降级(可以用 `CSS.supports('@starting-style')` 不好检测,
        实践上直接用「JS 分两帧」作为兜底更稳)

⭐ `transition-behavior: allow-discrete` 的意义:
   `display: none → block` 这类「离散属性」以前无法过渡(立刻跳变);
   加上 `allow-discrete` 后,浏览器会「在过渡期间保持 display 为 block,
   过渡结束才真正切成 none」→ ⭐ 于是「退场动画」也能纯 CSS 实现。
   ⚠️ 必须与 `transition` 一起声明,且要显式写 `display 240ms allow-discrete`。

三、核心难点二:位置变化 → FLIP

⭐⭐⭐ FLIP 的原理(四个字母就是四个步骤):

   【F】First:记录元素「变化前」的位置(getBoundingClientRect)
   【L】Last :让 DOM 更新到「变化后」的位置,然后读新位置
   【I】Invert:⭐ 用 transform 把元素「视觉上移回原位」
               (此时元素在「新位置」,但看起来还在「旧位置」)
   【P】Play :移除 transform(配合 transition)→ 元素平滑「移动」到新位置

   ⭐ 为什么用 transform 而不是「动画 left/top」?
     因为 transform 只走**合成阶段**(不触发 Layout/Paint),
     而 left/top 每帧都要重排(见 5.17)。这是 FLIP「流畅」的根本原因。

   ⭐ 为什么必须「在 DOM 更新后、浏览器绘制前」读 Last?
     → 所以要在 `useLayoutEffect` 里做(`useEffect` 会「先画出错位的一帧」)。
// ✅ FLIP 的完整实现
import { useLayoutEffect, useRef } from 'react';

/**
 * useFlip:在「DOM 结构变化后」自动播放位置过渡动画。
 * 用法:
 *   ① 给每个「会移动的元素」一个稳定的 data-flip-id
 *   ② 用 useFlip(listKey) 包裹容器(listKey 变化时播放动画)
 */
export function useFlip(
  /** 依赖变化时播放动画(如「排序键」或「列表长度」) */
  dep: unknown,
  options: { durationMs?: number; easing?: string; selector?: string } = {}
) {
  const { durationMs = 300, easing = 'cubic-bezier(.2, .8, .2, 1)', selector = '[data-flip-id]' } = options;
  const containerRef = useRef(null);
  const prevRectsRef = useRef>(new Map());

  /** ⭐ First:在「每次渲染后(布局前)」记录当前所有元素的位置 */
  useLayoutEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const shouldAnimate = prevRectsRef.current.size > 0;      // 首次不播(没有「前一帧」)
    const next = new Map();

    const els = Array.from(container.querySelectorAll(selector));

    // ⭐⭐ ① 读(First):批量读旧位置(避免「读-写-读」的抖动,见 1.14)
    const currentRects = els.map((el) => el.getBoundingClientRect());

    if (shouldAnimate) {
      // ⭐ ② 反演 + 播放
      els.forEach((el, i) => {
        const id = el.dataset.flipId!;
        const prev = prevRectsRef.current.get(id);
        const curr = currentRects[i]!;

        if (!prev) return;                                     // 新元素 → 不反向位移(可以另做入场动画)

        const dx = prev.left - curr.left;
        const dy = prev.top - curr.top;
        const dw = prev.width / Math.max(curr.width, 0.001);
        const dh = prev.height / Math.max(curr.height, 0.001);

        // 位移/缩放都为 0 → 没动 → 跳过
        if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && Math.abs(dw - 1) < 0.01 && Math.abs(dh - 1) < 0.01) {
          return;
        }

        // ⭐ I(Invert):无过渡地把元素「视觉上移回原位」
        el.style.transition = 'none';
        el.style.transformOrigin = 'top left';
        el.style.transform = `translate(${dx}px, ${dy}px) scale(${dw}, ${dh})`;

        // ⭐ P(Play):下一帧移除 transform,让 transition 接管
        //    ⚠️ 必须用「两次 rAF」或在同一帧里读写分离,否则浏览器可能合并掉这次变化
        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            el.style.transition = `transform ${durationMs}ms ${easing}`;
            el.style.transform = '';
          });
        });

        // ⭐ 动画结束后清理(避免残留的 transition 影响后续)
        const cleanup = () => {
          el.style.transition = '';
          el.style.transformOrigin = '';
          el.removeEventListener('transitionend', cleanup);
        };
        el.addEventListener('transitionend', cleanup, { once: true });
      });

      // ⚠️ 兜底:transitionend 在「被打断」时可能不触发
      setTimeout(() => {
        els.forEach((el) => { el.style.transition = ''; el.style.transformOrigin = ''; });
      }, durationMs + 50);
    }

    // ③ 记录本次位置,供下次比较
    els.forEach((el, i) => next.set(el.dataset.flipId!, currentRects[i]!));
    prevRectsRef.current = next;

    // ⚠️ 依赖用「渲染相关的东西」:DOM 结构、排序键、分页等
  }, [dep, durationMs, easing, selector]);

  return containerRef;
}
⭐⭐⭐ FLIP 实现里「三个最容易写错」的点:

① 【必须在 `useLayoutEffect` 里做】
   因为要「在 DOM 更新后、浏览器绘制前」读新位置。
   用 `useEffect` 会「先画出错位的一帧,再动画」→ 闪烁。

② 【「读写分离」——先把所有 rect 读完,再统一写 transform】
   如果「读一个 → 写一个 → 再读下一个」,会触发多次**强制同步布局**(见 1.14)。

③ 【「Invert → Play」之间需要「让浏览器消化一次样式变更」】
   最常见做法是「两次 `requestAnimationFrame`」(或用 `getComputedStyle` 强制刷新)。
   如果只写一次 rAF,某些浏览器会把「设置 transform + 立刻移除」合并成一次 → 动画不播。

四、完整实现:一个「可排序列表 + FLIP」的完整示例(230 行)

// ============ 一个「排序 + 增删 + FLIP 动画」的完整列表 ============
import { useState, useCallback, useLayoutEffect, useRef } from 'react';

interface Item { id: string; label: string; }

export function SortableList({ initial }: { initial: Item[] }) {
  const [items, setItems] = useState(initial);
  const [sortMode, setSortMode] = useState<'custom' | 'asc' | 'desc'>('custom');
  const [removed, setRemoved] = useState([]);       // ⭐ 退场动画中的项

  // ⭐ 用「所有会造成位置变化的东西」当 FLIP 的依赖
  const flipRef = useFlip(
    `${sortMode}-${items.map((i) => i.id).join(',')}`
  );

  const move = (from: number, to: number) => {
    setItems((prev) => {
      const next = prev.slice();
      const [moved] = next.splice(from, 1);
      if (!moved) return prev;
      next.splice(to, 0, moved);
      return next;
    });
  };

  const remove = (id: string) => {
    // ⭐ 两阶段删除:先进入「退场态」,动画结束后真正移除
    setRemoved((r) => [...r, id]);
    setTimeout(() => {
      setItems((prev) => prev.filter((i) => i.id !== id));
      setRemoved((r) => r.filter((x) => x !== id));
    }, 200);
  };

  const sort = (mode: 'asc' | 'desc') => {
    setSortMode(mode);
    setItems((prev) =>
      prev.slice().sort((a, b) => (mode === 'asc' ? 1 : -1) * a.label.localeCompare(b.label, 'zh'))
    );
  };

  return (
    
{/* ⭐ FLIP 容器 */}
    {items.map((item, index) => { const isLeaving = removed.includes(item.id); return (
  • {item.label}
  • ); })}
{items.length === 0 &&

列表空了

}
); }
⭐⭐⭐ 这个示例演示了「两种动画如何协作」——这是真实项目的常态:

   【FLIP 负责】「排序 / 移动」时的位置过渡
   【CSS transition 负责】「删除 / 新增」时的淡出淡入

   ⚠️⚠️ 两者的冲突点:**都用 `transform`**
     如果 FLIP 正在写 `transform`(位移),而你又给同一个元素写了
     「`transform: scale(.96)` 的退场动画」→ 两者互相覆盖 → 结果很怪。
     ✅ 三种解法:
       ① 「位置动画」放外层、「消失动画」放内层(分层做)
       ② 退场只用 `opacity`(不用 transform)
       ③ 用动画库(它内部做了「属性级的合并」)
// ✅ 解法 ① 的写法:分层(外层 FLIP 位移,内层退场淡出)
  • {/* 内容 */}
  • ⭐⭐ 关于「依赖 `dep` 该传什么」(FLIP 用错依赖就白做):
    
       ✅ 应该传「任何会造成位置变化的东西」:
          · 列表顺序(排序键)——`items.map(i => i.id).join(',')`
          · 筛选/分页参数
          · 布局模式(列表 ↔ 网格)
          · 容器尺寸(如果需要,可以在 resize 时也触发)
    
       ❌ 不要传「每次渲染都变的东西」:
          · 对象/数组字面量(每次新引用 → 每次都播动画 → 抖动)
          · `items`(如果它的引用每次渲染都变)
    
       ⭐ 实用判据:「**如果这个值没变,元素的位置就不该变**」。

    五、本篇特有的坑

    // ① 用「条件渲染」做退场动画(组件一卸载动画没机会播)
    {open && }                                            // ❌ 没有时间窗
    // ✅ useMountTransition(延迟卸载)或  或库
    
    // ② 挂载时直接写终态样式(没有「起始值」→ 不播过渡)
    // ✅ 分两帧(rAF)或用 `@starting-style`
    
    // ③ 退场期间没有禁用交互(用户能点到「正在消失」的按钮)
    style={{ pointerEvents: open ? 'auto' : 'none' }}               // ✅
    
    // ④ 用 `setTimeout(duration)` 判断动画结束(与 CSS 改时长不同步)
    // ✅ 监听 `transitionend`(但要注意「被打断」「多属性」),或用 `el.animate().finished`
    setTimeout(() => setOpen(false), 300);                          // ⚠️ 脆
    
    // ⑤ 动画中「被打断」(用户快速开/关)时状态错乱
    // ⚠️ 上一个 timeout 还在跑 → 覆盖了新状态
    // ✅ 每次切换都 `clearTimeout`,或用「动画实例」取消(`anim.cancel()`)
    
    // ⑥ FLIP 写在 `useEffect` 里(先画出错位一帧)
    // ✅ `useLayoutEffect`
    
    // ⑦ FLIP 用 `index` 当标识(排序后「标识」错位 → 动画乱飞)
    data-flip-id={`item-${index}`}                                  // ❌
    // ✅ 用「业务 id」
    
    // ⑧ FLIP 的「反演 → 播放」只写了一次 rAF(浏览器把两次样式合并,动画不播)
    requestAnimationFrame(() => { el.style.transition = '...'; el.style.transform = ''; });  // ⚠️
    // ✅ 两次 rAF,或用 `getComputedStyle(el).transform` 强制刷新
    
    // ⑨ FLIP 与「元素的 transform 退场动画」互相覆盖
    // ✅ 分层(外层 FLIP、内层退场),或退场只用 opacity
    
    // ⑩ 「读-写-读-写」造成布局抖动(FLIP 里最容易犯)
    els.forEach((el) => { el.style.transform = `translateX(${el.offsetLeft}px)`; });   // ❌
    // ✅ 先把所有 rect 读完,再统一写
    
    // ⑪ 用 `left/top/width/height` 做动画(每帧重排)
    transition: 'left 300ms'                                        // ❌
    // ✅ `transform` / `opacity`
    
    // ⑫ 动画元素没有 `will-change`(或一直有)
    // ✅ 「动画前加、结束后移除」(见 5.17)
    
    // ⑬ 长列表里「每个元素都播动画」(几百个元素同时过渡 → 卡)
    // ⚠️ 尤其「同时改变高度/位置」时
    // ✅ 只对「真正变化的元素」播(FLIP 里比较 dx/dy 差值,为 0 就跳过)
    
    // ⑭ 用 `height: auto` 做展开动画(无法过渡)
    transition: 'height 300ms';  /* auto → 具体值 */                 // ⚠️ 不生效
    // ✅ 方案:
    //    · `grid-template-rows: 0fr → 1fr`(现代方案,能过渡!)
    //    · `max-height: 0 → 一个足够大的值`(简单但有「时间不精确」的问题)
    //    · JS 测量实际高度后写具体值
    //    · `interpolate-size: allow-keywords`(新特性,让 auto 可过渡)
    
    // ⑮ 用「CSS 动画 + 状态类」但忘了「重放动画」(同名动画不会重播)
    // ⚠️ 元素已经有 `animation: shake` → 再加类不会重播
    // ✅ 「先移除类 → 强制 reflow → 再加类」,或用 `el.animate()`(WAAPI 可以重放)
    
    // ⑯ WAAPI 动画结束后「元素回到原位」(没设 `fill`)
    el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 });   // ⚠️ 结束后回到「无动画」状态
    // ✅ `fill: 'forwards'`(保持终态)或 `fill: 'both'`
    
    // ⑰ `transitionend` 会「冒泡」(父元素的监听会被子元素触发)
    // ✅ 判断 `e.target === el`,或用 `{ once: true }` + 精确监听
    
    // ⑱ `transitionend` 对「多属性」会触发多次
    // ⚠️ 你以为「动画结束」了,其实只结束了第一个属性
    // ✅ 判断 `e.propertyName`,或改用 WAAPI 的 `finished` Promise
    
    // ⑲ 在「减少动态效果」的用户设置下仍然强播动画
    @media (prefers-reduced-motion: reduce) {
      * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
    }                                                              // ✅ 无障碍要求
    // ⭐ 或者用 JS 检测 `matchMedia('(prefers-reduced-motion: reduce)')` 并跳过 FLIP
    
    // ⑳ 「动画库」与「React 18 并发」的兼容问题(旧库在 StrictMode 下重复执行)
    // ✅ 用「已适配 React 18+」的库(framer-motion / react-spring v9.4+ / dnd-kit)
    /* ⑭ 的完整处理(用 grid-template-rows 做「真正的展开动画」) */
    .collapsible {
      display: grid;
      grid-template-rows: 0fr;
      transition: grid-template-rows 280ms cubic-bezier(.2, .8, .2, 1);
    }
    .collapsible[data-open='true'] {
      grid-template-rows: 1fr;
    }
    .collapsible > * {
      overflow: hidden;      /* ⭐ 必须(否则内容会「溢出」而不被裁剪) */
    }
    /* ⭐ 这个方案能「真正按内容高度过渡」,比 max-height 精确 */
    // ⑮ 的完整处理(重放 CSS 动画)
    function replayAnimation(el: HTMLElement, className: string) {
      el.classList.remove(className);
      void el.offsetWidth;             // ⭐ 强制 reflow(让浏览器「忘记」之前的动画状态)
      el.classList.add(className);
    }
    
    // ⭐ 更好的方式:WAAPI(可以重放、可以取消、可以 await)
    async function shake(el: HTMLElement) {
      const anim = el.animate(
        [{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, { transform: 'translateX(6px)' }, { transform: 'translateX(0)' }],
        { duration: 260, easing: 'ease-in-out' }
      );
      await anim.finished;              // ⭐ 想 await 就 await
    }

    六、面试延伸

    1. 「React 里怎么做『卸载动画』?」

    难点在于「React 的卸载是同步立刻的」——组件一卸载,DOM 就没了,没有时间窗播退场动画。三种解法:① ⭐ 延迟卸载——把「是否渲染」与「是否可见」拆成两个状态:关闭时先「标记为退场中」(DOM 还在,用类名切换到退场样式),等 transitionend(或 setTimeout)后再真正卸载;② CSS 原生——transition-behavior: allow-discrete 让 display 这类离散属性也能参与过渡(过渡期间保持 display: block,结束后才切 none),配合 @starting-style 声明「首次渲染时的起始样式」;③ <Activity>(React 19 实验)——把子树「隐藏但不卸载」,天然支持退场动画且保留 state(见 4.13)。实现要点:必须监听 transitionend 而不是死等 setTimeout(否则 CSS 改时长就不同步),并且退场期间要禁用交互(pointer-events: none)。

    1. 「@starting-style 解决什么问题?」

    解决「刚挂载的元素没法播过渡」这个老问题:新插入 DOM 的元素「第一帧就是终态」→ 没有起始值可过渡 → 动画不播。以前必须用 JS「先设初始态,下一帧(rAF)再改成终态」(本篇 useMountTransition 就是这么做的)。@starting-style 让你直接声明「元素第一次渲染时的样式」,浏览器会把「starting style → 计算样式」当作一次过渡,于是纯 CSS 就能播进场动画。配合 transition-behavior: allow-discrete,display: none → block 与 overlay 也能过渡,于是连退场动画都能纯 CSS 完成(对 <dialog>/popover 特别香)。⚠️ 兼容性需要关注(2024 年才铺开),实践上可以用「JS 分两帧」作为统一兜底。

    1. 「什么是 FLIP?为什么要用它?」

    FLIP = First, Last, Invert, Play:① First——记录元素变化前的位置;② Last——让 DOM 更新到新位置(React 渲染新顺序)并读新位置;③ Invert——⭐ 用 transform 把元素视觉上移回原位(此时它「在新位置,但看起来还在旧位置」);④ Play——移除 transform 并让 transition 接管 → 元素平滑「移动」到新位置。为什么用它:因为「位置变化」本身没有可过渡的属性(元素是「从 A 跳到 B」),而 FLIP 把它变成「一次 transform 过渡」;且 transform 只走合成阶段(不触发 Layout/Paint,见 5.17)——这就是它流畅的根本原因。关键约束:必须在 useLayoutEffect 里做(要在「DOM 更新后、浏览器绘制前」读 Last),且要「读写分离」(先批量读 rect 再统一写 transform)。

    1. 「FLIP 实现里最容易写错的地方?」

    四个:① 时机——必须 useLayoutEffect(用 useEffect 会「先画出错位的一帧再动画」→ 闪烁);② 读写分离——「读一个 → 写一个 → 再读下一个」会触发多次强制同步布局(见 1.14),应该「先把所有 rect 读完,再统一写 transform」;③ ⭐ 「Invert → Play」之间要让浏览器「消化一次样式变更」——只写一次 requestAnimationFrame 时,某些浏览器会把「设置 transform + 立刻移除」合并成一次 → 动画不播(✅ 用两次 rAF,或读一次 getComputedStyle(el).transform 强制刷新);④ 标识要用稳定 id(data-flip-id 用业务 id 而不是 index,否则排序后「标识错位 → 动画乱飞」),依赖 dep 也要传对(传「任何会造成位置变化的东西」,别传每次渲染都变的对象)。

    1. 「FLIP 和『元素的淡出动画』能同时用吗?」

    能,但要小心 transform 冲突:FLIP 通过持续写 transform 做位移,而淡出动画常常也写 transform(如 scale(.96))→ 两者互相覆盖,结果会「很怪」(位移被打断或缩放被冲掉)。三种解法:① ⭐ 分层——「位置动画」放外层元素(由 FLIP 控制),「消失动画」放内层元素(如 scale + opacity),两者互不干扰;② 退场只用 opacity(不碰 transform);③ 用动画库(它内部做了「属性级的合并」)。⭐ 这在「列表同时支持排序与删除」时是必然遇到的问题——所以「分层」是最省心的实践。

    1. 「height: auto 为什么不能过渡?怎么解决?」

    因为 CSS 过渡需要「两个可插值的具体值」,而 auto 不是长度值 → 无法插值。四种解法:① ⭐ grid-template-rows: 0fr → 1fr——现代方案,能真正按内容高度过渡(要求子元素 overflow: hidden);② max-height: 0 → 一个足够大的值——简单,但「动画时长与实际高度不匹配」(内容矮时「早就到了却还在等」);③ JS 测量实际高度后写具体值(精确但要测量 + 处理「内容变化」);④ interpolate-size: allow-keywords(新特性,直接让 auto 可参与插值)。⭐ 实践中「展开收起」优先用 grid-template-rows 方案。

    1. 「动画与无障碍要注意什么?」

    ⭐ prefers-reduced-motion——对「前庭功能障碍」用户,强烈的位移/缩放动画会引起不适。要求:① 用媒体查询把动画时间降到极低(animation-duration: 0.01ms),或完全跳过动画(如 position: absolute 那种位移);② ⭐ 在 JS 里也要检测——matchMedia('(prefers-reduced-motion: reduce)'),如果是 reduce 就跳过 FLIP / 滚动驱动动画(而不是只把时长设小,因为「瞬间跳变」仍然可能是问题);③ 动画不应「影响可操作性」——退场期间要 pointer-events: none;④ 「加载动画」不应阻塞内容(用 aria-busy 或 role="status" 传达状态)。

    1. 「动画库怎么选?什么时候不该用库?」

    选库的判据:① 需要「布局动画 / 共享元素过渡(layout animation)」→ framer-motion(layout 属性自动做 FLIP)、auto-animate(零 API,包一层就自动动画);② 需要「物理曲线/spring」→ react-spring;③ 需要「拖拽 + 排序」→ dnd-kit(自带排序动画)。不该用库的场景:① 只是「hover 过渡」「淡入淡出」——CSS 几行就够,别引几十 KB;② 需要极致性能(如高频 gesture)——手写 rAF + transform 更可控;③ 老项目/旧 React 版本——很多库对 React 18 并发与 StrictMode 的适配是后来补的。⭐ 一个实用原则:「先用 CSS 撑到位,等真的需要『布局动画/手势编排』再引库」。

    一句话速记

    React 动画的两个真正难点是「卸载动画」(卸载立刻移除 DOM,没有时间窗 → 用「延迟卸载 + transitionend」或「allow-discrete + @starting-style」或「<Activity>」)与「位置变化动画」(元素从 A 跳到 B → 用 FLIP);@starting-style 解决「刚挂载的元素没法过渡」(以前要靠 JS 分两帧),transition-behavior: allow-discrete 让 display 也能过渡(于是退场可纯 CSS);FLIP = First/Last/Invert/Play,⭐ 必须在 useLayoutEffect 里做、读写分离、Invert→Play 之间要「消化一次样式变更」(两次 rAF)、标识用稳定业务 id;选型上「单个元素的入场/状态切换用 CSS transition(走合成线程)、列表重排/网格变化用 FLIP、复杂编排/手势用库」;FLIP 与「transform 退场动画」会互相覆盖 → 用「分层」解决;height: auto 不可过渡 → 用 grid-template-rows: 0fr → 1fr;必做 prefers-reduced-motion 无障碍降级;动画只改 transform/opacity(见 5.17)。

    赞(0) 打赏
    未经允许不得转载:陌上寒 » 1.15 React 中的动画

    评论 抢沙发

    觉得文章有用就打赏一下文章作者

    非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

    微信扫一扫

    支付宝扫一扫