
一句话: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 && <Modal />} → open 变 false → Modal 的 DOM **立刻**被移除
→ ⭐ 没有任何「时间窗」可以播退场动画。
✅ 三种解法:
① 【延迟卸载】把「是否渲染」与「是否可见」拆成两个状态,
等动画结束再真正卸载(本节的实现)
② 【CSS 原生】:`@starting-style` + `transition-behavior: allow-discrete`
(对 `<dialog>`/popover 这类「display 切换」的场景很香)
③ 【`<Activity>`(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<ReturnType<typeof setTimeout> | 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(
<>
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)',
// ⭐ 遮罩也跟着淡入淡出(用两个类名联动)
opacity: transition.isEntering ? 0 : 1,
transition: 'opacity 240ms ease',
pointerEvents: open ? 'auto' : 'none', // ⭐ 退场期间不接收点击
}}
onClick={onClose}
/>
<div className={`modal-content ${transition.className}`} role="dialog" aria-modal="true">
{children}
</div>
</>,
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<T extends HTMLElement>(
/** 依赖变化时播放动画(如「排序键」或「列表长度」) */
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<T | null>(null);
const prevRectsRef = useRef<Map<string, DOMRect>>(new Map());
/** ⭐ First:在「每次渲染后(布局前)」记录当前所有元素的位置 */
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const shouldAnimate = prevRectsRef.current.size > 0; // 首次不播(没有「前一帧」)
const next = new Map<string, DOMRect>();
const els = Array.from(container.querySelectorAll<HTMLElement>(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<Item[]>(initial);
const [sortMode, setSortMode] = useState<'custom' | 'asc' | 'desc'>('custom');
const [removed, setRemoved] = useState<string[]>([]); // ⭐ 退场动画中的项
// ⭐ 用「所有会造成位置变化的东西」当 FLIP 的依赖
const flipRef = useFlip<HTMLUListElement>(
`${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 (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button onClick={() => sort('asc')}>按名称升序</button>
<button onClick={() => sort('desc')}>按名称降序</button>
<button onClick={() => { setSortMode('custom'); setItems(initial); }}>重置</button>
</div>
{/* ⭐ FLIP 容器 */}
<ul ref={flipRef} style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 8 }}>
{items.map((item, index) => {
const isLeaving = removed.includes(item.id);
return (
<li
// ⭐⭐ FLIP 依赖的「稳定标识」(不要用 index)
data-flip-id={item.id}
key={item.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', border: '1px solid #e5e5e5', borderRadius: 8,
background: '#fff',
// ⭐ 退场动画:opacity + transform(只走合成分)
opacity: isLeaving ? 0 : 1,
transform: isLeaving ? 'translateX(-12px) scale(.96)' : undefined,
transition: 'opacity 200ms ease, transform 200ms ease',
// ⚠️ 关键:FLIP 会用 transform 做位移,所以这里不要再写 transform 的 transition
// (否则两者会打架)—— 退场用「内层元素」做更安全
willChange: isLeaving ? 'opacity, transform' : undefined,
}}
>
<span style={{ flex: 1 }}>{item.label}</span>
<button onClick={() => move(index, Math.max(0, index - 1))} disabled={index === 0}>上移</button>
<button onClick={() => move(index, Math.min(items.length - 1, index + 1))} disabled={index === items.length - 1}>下移</button>
<button onClick={() => remove(item.id)}>删除</button>
</li>
);
})}
</ul>
{items.length === 0 && <p style={{ color: '#888' }}>列表空了</p>}
</div>
);
}
⭐⭐⭐ 这个示例演示了「两种动画如何协作」——这是真实项目的常态:
【FLIP 负责】「排序 / 移动」时的位置过渡
【CSS transition 负责】「删除 / 新增」时的淡出淡入
⚠️⚠️ 两者的冲突点:**都用 `transform`**
如果 FLIP 正在写 `transform`(位移),而你又给同一个元素写了
「`transform: scale(.96)` 的退场动画」→ 两者互相覆盖 → 结果很怪。
✅ 三种解法:
① 「位置动画」放外层、「消失动画」放内层(分层做)
② 退场只用 `opacity`(不用 transform)
③ 用动画库(它内部做了「属性级的合并」)
// ✅ 解法 ① 的写法:分层(外层 FLIP 位移,内层退场淡出)
<li data-flip-id={item.id} key={item.id} style={{ /* 外层只被 FLIP 控制 */ }}>
<div style={{
opacity: isLeaving ? 0 : 1,
transform: isLeaving ? 'scale(.96)' : 'none',
transition: 'opacity 200ms ease, transform 200ms ease',
}}>
{/* 内容 */}
</div>
</li>
⭐⭐ 关于「依赖 `dep` 该传什么」(FLIP 用错依赖就白做):
✅ 应该传「任何会造成位置变化的东西」:
· 列表顺序(排序键)——`items.map(i => i.id).join(',')`
· 筛选/分页参数
· 布局模式(列表 ↔ 网格)
· 容器尺寸(如果需要,可以在 resize 时也触发)
❌ 不要传「每次渲染都变的东西」:
· 对象/数组字面量(每次新引用 → 每次都播动画 → 抖动)
· `items`(如果它的引用每次渲染都变)
⭐ 实用判据:「**如果这个值没变,元素的位置就不该变**」。
五、本篇特有的坑
// ① 用「条件渲染」做退场动画(组件一卸载动画没机会播)
{open && <Modal />} // ❌ 没有时间窗
// ✅ useMountTransition(延迟卸载)或 <Activity> 或库
// ② 挂载时直接写终态样式(没有「起始值」→ 不播过渡)
// ✅ 分两帧(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。学习了。。。。
网站不错!!!!
简洁实用,好文章!