
一句话: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`
(对 `
// ✅ 解法 ①: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
⭐⭐⭐ 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
}
六、面试延伸
- 「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)。
- 「
@starting-style解决什么问题?」
解决「刚挂载的元素没法播过渡」这个老问题:新插入 DOM 的元素「第一帧就是终态」→ 没有起始值可过渡 → 动画不播。以前必须用 JS「先设初始态,下一帧(rAF)再改成终态」(本篇 useMountTransition 就是这么做的)。@starting-style 让你直接声明「元素第一次渲染时的样式」,浏览器会把「starting style → 计算样式」当作一次过渡,于是纯 CSS 就能播进场动画。配合 transition-behavior: allow-discrete,display: none → block 与 overlay 也能过渡,于是连退场动画都能纯 CSS 完成(对 <dialog>/popover 特别香)。⚠️ 兼容性需要关注(2024 年才铺开),实践上可以用「JS 分两帧」作为统一兜底。
- 「什么是 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)。
- 「FLIP 实现里最容易写错的地方?」
四个:① 时机——必须 useLayoutEffect(用 useEffect 会「先画出错位的一帧再动画」→ 闪烁);② 读写分离——「读一个 → 写一个 → 再读下一个」会触发多次强制同步布局(见 1.14),应该「先把所有 rect 读完,再统一写 transform」;③ ⭐ 「Invert → Play」之间要让浏览器「消化一次样式变更」——只写一次 requestAnimationFrame 时,某些浏览器会把「设置 transform + 立刻移除」合并成一次 → 动画不播(✅ 用两次 rAF,或读一次 getComputedStyle(el).transform 强制刷新);④ 标识要用稳定 id(data-flip-id 用业务 id 而不是 index,否则排序后「标识错位 → 动画乱飞」),依赖 dep 也要传对(传「任何会造成位置变化的东西」,别传每次渲染都变的对象)。
- 「FLIP 和『元素的淡出动画』能同时用吗?」
能,但要小心 transform 冲突:FLIP 通过持续写 transform 做位移,而淡出动画常常也写 transform(如 scale(.96))→ 两者互相覆盖,结果会「很怪」(位移被打断或缩放被冲掉)。三种解法:① ⭐ 分层——「位置动画」放外层元素(由 FLIP 控制),「消失动画」放内层元素(如 scale + opacity),两者互不干扰;② 退场只用 opacity(不碰 transform);③ 用动画库(它内部做了「属性级的合并」)。⭐ 这在「列表同时支持排序与删除」时是必然遇到的问题——所以「分层」是最省心的实践。
- 「
height: auto为什么不能过渡?怎么解决?」
因为 CSS 过渡需要「两个可插值的具体值」,而 auto 不是长度值 → 无法插值。四种解法:① ⭐ grid-template-rows: 0fr → 1fr——现代方案,能真正按内容高度过渡(要求子元素 overflow: hidden);② max-height: 0 → 一个足够大的值——简单,但「动画时长与实际高度不匹配」(内容矮时「早就到了却还在等」);③ JS 测量实际高度后写具体值(精确但要测量 + 处理「内容变化」);④ interpolate-size: allow-keywords(新特性,直接让 auto 可参与插值)。⭐ 实践中「展开收起」优先用 grid-template-rows 方案。
- 「动画与无障碍要注意什么?」
⭐ prefers-reduced-motion——对「前庭功能障碍」用户,强烈的位移/缩放动画会引起不适。要求:① 用媒体查询把动画时间降到极低(animation-duration: 0.01ms),或完全跳过动画(如 position: absolute 那种位移);② ⭐ 在 JS 里也要检测——matchMedia('(prefers-reduced-motion: reduce)'),如果是 reduce 就跳过 FLIP / 滚动驱动动画(而不是只把时长设小,因为「瞬间跳变」仍然可能是问题);③ 动画不应「影响可操作性」——退场期间要 pointer-events: none;④ 「加载动画」不应阻塞内容(用 aria-busy 或 role="status" 传达状态)。
- 「动画库怎么选?什么时候不该用库?」
选库的判据:① 需要「布局动画 / 共享元素过渡(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)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!