React 动画实现与性能优化

中等 🟡React 生态
12 个标签
预计阅读时间:85 分钟
React动画CSS性能Framer MotionReact SpringLottie60fpsGPU 加速FLIPrequestAnimationFrame可访问性

React 动画实现与性能优化

动画是提升用户体验的重要手段,精心设计的动画可以引导用户注意力、提供视觉反馈、增强应用的交互感。研究表明,恰当的动效能让用户感知到的加载时间下降约 20%,让操作的"确定感"显著提升。但反过来,一个卡顿掉帧的动画比没有动画更糟糕——它会直接暴露应用的性能短板,让用户产生"卡"的负面印象。React 提供了多种动画实现方式,从简单的 CSS 过渡到复杂的物理动画,开发者需要在"表现力"和"性能"之间做出权衡,根据需求选择合适的方案。

本文将系统梳理 React 动画的完整技术栈:CSS Transition/Animation、状态驱动动画、Framer Motion、React Spring、Lottie,以及贯穿始终的性能优化主线——60fps 的本质、浏览器渲染管线、合成层与 GPU 加速、FLIP 技术、prefers-reduced-motion 等。每个主题都遵循"概念 → 原理 → 代码 → 案例 → 数据 → 坑 → 最佳实践"的结构展开。

先理解目标:60fps 与渲染管线

什么是 60fps:

主流显示器刷新率为 60Hz,即每秒刷新 60 次。这意味着浏览器必须在 1000ms / 60 ≈ 16.67ms 内完成一帧的全部工作(JS 计算 + 样式计算 + 布局 + 绘制 + 合成)。实际上考虑到浏览器自身的开销,留给开发者的预算大约只有 10ms。一旦某一帧超时,就会发生"掉帧"(jank),用户就能感知到卡顿。在 120Hz 的高刷屏(如 iPhone ProMotion、许多安卓旗舰)上,预算进一步压缩到 8.33ms。

为什么重要:

60fps 是流畅动画的分水岭。低于 60fps 时人眼能明显感知到不连贯;低于 30fps 时体验会急剧劣化。理解每帧只有约 16ms 预算,是所有动画性能优化的出发点——我们所有的技巧(用 transform 而非 top、避免强制同步布局、利用合成层)本质上都是为了不让任何一帧超时。

浏览器渲染管线(Pixel Pipeline)原理:

浏览器把一帧的渲染分成五个阶段,每个阶段都可能成为瓶颈:

| 阶段 | 说明 | 触发成本 |

| --- | --- | --- |

| JavaScript | 执行脚本、计算样式变化、触发动画 | 取决于代码 |

| Style(样式计算) | 计算每个元素最终应用哪些 CSS 规则 | 中 |

| Layout(布局/重排) | 计算元素的几何位置和尺寸 | 高,会影响后代 |

| Paint(绘制/重绘) | 把元素绘制成像素位图(分图层) | 中到高 |

| Composite(合成) | 把各图层按正确顺序合成到屏幕 | 低,可交给 GPU |

关键结论:改动不同的 CSS 属性会进入管线的不同阶段。改 width/height/top/left 会触发从 Layout 开始的完整管线;改 color/background 会触发从 Paint 开始;而改 transform/opacity 只需要 Composite 阶段,成本最低,且能交给 GPU 处理。这就是"优先用 transform 和 opacity 做动画"这条黄金法则的底层原因。

动画实现方式详解

CSS 动画 - 高性能基础方案:

CSS 动画是实现动画效果的基础方案,包括 CSS Transitions 和 CSS Animations 两种方式。CSS Transitions 适合简单的状态变化动画,如悬停效果、展开收起等,通过 transition 属性定义过渡效果。CSS Animations 适合复杂的动画序列,通过 @keyframes 定义关键帧动画。CSS 动画的性能优势在于可以利用 GPU 加速,特别是 transform 和 opacity 属性的变化只触发合成层,不会导致重排和重绘。CSS 动画由浏览器的合成线程直接驱动,即使主线程(JS)被阻塞,动画依然能流畅运行,这是它相较 JS 动画的最大优势。

React 状态驱动动画:

使用 useState 和 useEffect 控制动画状态,可以实现复杂的动画逻辑,与业务逻辑紧密结合。通过状态变化触发 CSS 类名切换,实现声明式的动画控制。这种方式适合需要根据数据状态动态变化的动画,如列表项的添加删除、模态框的显示隐藏等。需要注意的是,React 中"卸载即消失",元素一旦从虚拟 DOM 移除就无法播放退场动画,这也是我们需要 AnimatePresence 这类工具的原因。

第三方动画库:

Framer Motion 是 React 生态中最流行的动画库,提供了声明式的动画 API,支持布局动画、手势动画、页面过渡等高级功能。React Spring 是基于物理的动画库,使用真实的物理弹簧模拟,动画效果更自然流畅。Lottie 可以将 Adobe After Effects 导出的 JSON 动画在 Web 端渲染,适合复杂的矢量动画。选择哪个库,取决于你的动画复杂度、包体积预算和团队熟悉程度。

一、CSS Transition 与 Animation

概念定义:

Transition(过渡)描述的是"从状态 A 变到状态 B 时的中间过程",它需要一个触发条件(如 :hover、类名切换)。Animation(动画)通过 @keyframes 定义一段独立的、可以自动播放和循环的关键帧序列,不依赖状态切换即可运行。

原理:

Transition 只有起点和终点两个状态,浏览器自动插值中间帧;Animation 可以定义任意多个关键帧(0% 到 100% 之间),控制更精细,还支持 animation-iteration-count(循环次数)、animation-direction(方向)、animation-fill-mode(填充模式)等。

代码示例:

javascriptCode
// CSS 过渡动画
import './styles.css';

function FadeInComponent({ isVisible }) {
  return (
    <div className={`fade-container ${isVisible ? 'visible' : ''}`}>
      <p>Content fades in and out</p>
    </div>
  );
}

// styles.css
// .fade-container {
//   opacity: 0;
//   transform: translateY(10px);
//   transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
// }
// .fade-container.visible {
//   opacity: 1;
//   transform: translateY(0);
// }

// CSS 关键帧动画
function Spinner() {
  return <div className="spinner" />;
}

// styles.css
// @keyframes spin {
//   from { transform: rotate(0deg); }
//   to { transform: rotate(360deg); }
// }
// .spinner {
//   width: 40px;
//   height: 40px;
//   border: 4px solid #f3f3f3;
//   border-top: 4px solid #3498db;
//   border-radius: 50%;
//   animation: spin 1s linear infinite;
// }

// 多关键帧的脉冲/心跳动画
function PulseButton({ children }) {
  return <button className="pulse-btn">{children}</button>;
}

// styles.css
// @keyframes pulse {
//   0%   { transform: scale(1);    box-shadow: 0 0 0 0 rgba(52,152,219,0.6); }
//   70%  { transform: scale(1.05); box-shadow: 0 0 0 12px rgba(52,152,219,0); }
//   100% { transform: scale(1);    box-shadow: 0 0 0 0 rgba(52,152,219,0); }
// }
// .pulse-btn {
//   animation: pulse 1.8s ease-out infinite;
// }

// 骨架屏加载动画(shimmer)
function SkeletonLine() {
  return <div className="skeleton" />;
}

// styles.css
// @keyframes shimmer {
//   0%   { background-position: -200% 0; }
//   100% { background-position: 200% 0; }
// }
// .skeleton {
//   height: 16px;
//   border-radius: 4px;
//   background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
//   background-size: 200% 100%;
//   animation: shimmer 1.5s ease-in-out infinite;
// }

真实案例:

GitHub 的"Star 按钮"点击后的星标飞入、Twitter/X 的点赞爱心迸发、绝大多数网站的骨架屏(Skeleton Screen)加载态,都是纯 CSS 动画实现的。它们的共同点是:动画简单、需要极致性能、且不希望占用主线程。骨架屏是 CSS 动画的经典应用——LinkedIn 在 2015 年首创后,Facebook、YouTube、Medium 纷纷跟进,因为它比转圈 loading 更能降低用户的焦虑感。

常见坑:

1.transition 作用在 display 上无效——display 不可插值。要做展开收起,应用 max-height 或配合 opacity/transform。
2.从 auto 值(如 height: auto)过渡无效,必须是具体数值。
3.忘记指定过渡属性,写成 transition: all,会导致意外属性也参与过渡并拖累性能。

最佳实践:

明确列出要过渡的属性,不要滥用 transition: all。
优先过渡 transform 和 opacity。
缓动函数用 cubic-bezier 自定义,避免所有动画都是 ease,比如进场用 ease-out(快进慢停)更自然。

二、React 状态驱动动画

概念定义:

通过 React 的状态(useState)控制 DOM 的类名或内联样式,从而声明式地触发 CSS 动画。这是把"动画状态"纳入 React 数据流管理的做法。

为什么重要:

很多动画本质上是数据驱动的:列表项增删、Tab 切换、手风琴展开、表单校验抖动。把这些动画交给状态管理,能让 UI 始终是状态的纯函数映射,符合 React 的心智模型。

代码示例:

javascriptCode
import { useState, useEffect, useRef } from 'react';

// React 状态驱动动画:列表项进场
function AnimatedList({ items }) {
  const [displayItems, setDisplayItems] = useState(items);

  const addItem = (item) => {
    setDisplayItems(prev => [...prev, { ...item, isNew: true }]);
    setTimeout(() => {
      setDisplayItems(prev =>
        prev.map(i => i.id === item.id ? { ...i, isNew: false } : i)
      );
    }, 300);
  };

  return (
    <ul className="animated-list">
      {displayItems.map(item => (
        <li
          key={item.id}
          className={`list-item ${item.isNew ? 'item-enter' : ''}`}
        >
          {item.name}
        </li>
      ))}
    </ul>
  );
}

// 手写退场动画:卸载前先播放动画
function FadeUnmount({ show, children }) {
  const [shouldRender, setShouldRender] = useState(show);

  useEffect(() => {
    if (show) setShouldRender(true);
  }, [show]);

  const onAnimationEnd = () => {
    if (!show) setShouldRender(false);
  };

  if (!shouldRender) return null;

  return (
    <div
      className={`fade ${show ? 'fade-in' : 'fade-out'}`}
      onAnimationEnd={onAnimationEnd}
    >
      {children}
    </div>
  );
}

// styles.css
// @keyframes fadeIn  { from { opacity: 0; } to { opacity: 1; } }
// @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
// .fade-in  { animation: fadeIn  0.25s forwards; }
// .fade-out { animation: fadeOut 0.25s forwards; }

// 表单校验错误抖动
function ShakeInput({ error }) {
  const [shake, setShake] = useState(false);

  useEffect(() => {
    if (error) {
      setShake(true);
      const t = setTimeout(() => setShake(false), 400);
      return () => clearTimeout(t);
    }
  }, [error]);

  return (
    <input className={`input ${shake ? 'shake' : ''}`} />
  );
}

// styles.css
// @keyframes shake {
//   0%, 100% { transform: translateX(0); }
//   20%, 60% { transform: translateX(-8px); }
//   40%, 80% { transform: translateX(8px); }
// }
// .shake { animation: shake 0.4s; }

真实案例:

Ant Design、Material UI 等组件库的 Modal、Drawer、Message 组件,底层都用了这种"延迟卸载 + 类名切换"的模式来实现进场/退场动画。React 官方也曾维护 react-transition-group 库来标准化这个流程。

常见坑:

直接 setState 卸载组件,退场动画来不及播放就消失了——必须用"延迟卸载"技巧。
在动画期间频繁 setState 触发重渲染,导致动画抖动——应把动画状态和业务状态解耦。
setTimeout 未清理导致内存泄漏或状态错乱。

最佳实践:

用 onAnimationEnd/onTransitionEnd 事件替代 setTimeout 硬编码时长,更可靠;对于列表增删动画,优先考虑成熟库(Framer Motion 的 AnimatePresence)而非手写。

三、Framer Motion(现 Motion)

概念定义:

Framer Motion 是目前 React 生态最流行的声明式动画库(现已更名为 Motion)。核心是 motion 组件——把任意 HTML 标签升级为可动画元素,通过 initial/animate/exit 三个 props 描述动画的起点、目标和退场状态。

为什么重要:

它用最少的代码解决了 React 动画最棘手的几个问题:退场动画(AnimatePresence)、布局变化动画(layout prop,底层是 FLIP)、手势交互(drag/whileHover/whileTap)。声明式 API 极大降低了心智负担。

原理:

Framer Motion 内部维护一套独立于 React 渲染的动画引擎,通过 requestAnimationFrame 驱动,直接操作 DOM 的 style,绕开 React 的重渲染,因此性能很好。variants 机制允许父组件统一编排子组件的动画(stagger 交错)。

代码示例:

javascriptCode
import { useState } from 'react';
import { motion, AnimatePresence, useAnimate, useScroll, useTransform } from 'framer-motion';

// Framer Motion 基础用法 + AnimatePresence 退场
function MotionComponent() {
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div>
      <button onClick={() => setIsVisible(!isVisible)}>Toggle</button>
      <AnimatePresence>
        {isVisible && (
          <motion.div
            initial={{ opacity: 0, y: -20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 20 }}
            transition={{ duration: 0.3 }}
          >
            <p>Animated content</p>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

// variants 编排 + stagger 交错动画
const containerVariants = {
  hidden: { opacity: 0 },
  show: {
    opacity: 1,
    transition: { staggerChildren: 0.08, delayChildren: 0.1 }
  }
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  show: { opacity: 1, y: 0 }
};

function StaggeredList({ items }) {
  return (
    <motion.ul variants={containerVariants} initial="hidden" animate="show">
      {items.map(item => (
        <motion.li key={item.id} variants={itemVariants}>
          {item.name}
        </motion.li>
      ))}
    </motion.ul>
  );
}

// Framer Motion 列表动画 + layout 动画(FLIP)
function MotionList({ items }) {
  return (
    <ul>
      <AnimatePresence>
        {items.map((item, index) => (
          <motion.li
            key={item.id}
            initial={{ opacity: 0, x: -50 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0, x: 50 }}
            transition={{ delay: index * 0.1 }}
            layout
          >
            {item.name}
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  );
}

// Framer Motion 手势动画:拖拽 + 悬停 + 点击
function DraggableCard() {
  return (
    <motion.div
      drag
      dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
      dragElastic={0.2}
      whileHover={{ scale: 1.05 }}
      whileTap={{ scale: 0.95 }}
      className="card"
    >
      Drag me!
    </motion.div>
  );
}

// useAnimate:命令式动画序列
function SequenceButton() {
  const [scope, animate] = useAnimate();

  const runSequence = async () => {
    await animate(scope.current, { scale: 1.2 }, { duration: 0.2 });
    await animate(scope.current, { rotate: 360 }, { duration: 0.4 });
    await animate(scope.current, { scale: 1, rotate: 0 }, { duration: 0.2 });
  };

  return <button ref={scope} onClick={runSequence}>Click me</button>;
}

// 滚动驱动动画:视差效果
function ParallaxHeader() {
  const { scrollY } = useScroll();
  const y = useTransform(scrollY, [0, 500], [0, 150]);
  const opacity = useTransform(scrollY, [0, 300], [1, 0]);

  return (
    <motion.h1 style={{ y, opacity }}>
      Scroll to see parallax
    </motion.h1>
  );
}

页面过渡与共享布局:

javascriptCode
// 页面过渡动画
import { motion, AnimatePresence } from 'framer-motion';

const pageVariants = {
  initial: { opacity: 0, x: '-100vw' },
  in: { opacity: 1, x: 0 },
  out: { opacity: 0, x: '100vw' }
};

const pageTransition = {
  type: 'tween',
  ease: 'anticipate',
  duration: 0.5
};

function PageTransition({ children }) {
  return (
    <motion.div
      initial="initial"
      animate="in"
      exit="out"
      variants={pageVariants}
      transition={pageTransition}
    >
      {children}
    </motion.div>
  );
}

// 共享布局动画:layoutId 实现元素在两个位置间"飞行"
function SharedLayoutTabs({ tabs, active, onSelect }) {
  return (
    <div className="tabs">
      {tabs.map(tab => (
        <button key={tab.id} onClick={() => onSelect(tab.id)} className="tab">
          {tab.label}
          {active === tab.id && (
            <motion.div className="underline" layoutId="tab-underline" />
          )}
        </button>
      ))}
    </div>
  );
}

真实案例:

Framer 官网、Linear、Vercel Dashboard 大量使用 Framer Motion。共享布局动画(layoutId)是很多"卡片点击后平滑放大成详情页"效果的实现方式,典型如 Apple Music/Spotify 网页版的专辑卡片展开。

常见坑:

1.AnimatePresence 的直接子元素必须有唯一稳定的 key,否则退场动画不触发。
2.layout 动画对 border-radius、box-shadow 等属性会有形变失真,需要配合 layout="position" 或额外处理。
3.每个 motion 组件都有一定开销,大列表(成百上千项)全用 motion 会拖慢性能,应结合虚拟列表。

最佳实践:

用 variants 集中管理动画状态,避免在每个组件散落 initial/animate。
退场动画包一层 AnimatePresence,并确保 key 稳定。
大列表用 LazyMotion 按需加载特性以减小包体积。

四、React Spring

概念定义:

React Spring 是基于物理弹簧模型的动画库。它不用"时长 + 缓动曲线"来描述动画,而是用物理参数——质量(mass)、张力(tension)、摩擦力(friction)——来模拟真实世界的运动,因此动画天然带有惯性和回弹,更自然。

为什么重要:

基于时间的缓动动画在被打断时会显得生硬(比如动画进行到一半用户又触发了反向操作)。而弹簧动画是基于当前速度和位置连续计算的,任何时刻被打断都能平滑地转向新目标,交互手感更好。iOS 的绝大多数动画都是弹簧驱动的。

代码示例:

javascriptCode
import { useState } from 'react';
import { useSpring, useTransition, useTrail, animated } from '@react-spring/web';

// React Spring 物理动画:翻转卡片
function SpringComponent() {
  const [flip, setFlip] = useState(false);

  const { transform, opacity } = useSpring({
    opacity: flip ? 1 : 0,
    transform: `perspective(600px) rotateX(${flip ? 180 : 0}deg)`,
    config: { mass: 5, tension: 500, friction: 80 }
  });

  return (
    <animated.div
      onClick={() => setFlip(!flip)}
      style={{ opacity: opacity.to(o => 1 - o), transform }}
    >
      Click to flip
    </animated.div>
  );
}

// React Spring 数字滚动动画
function AnimatedNumber({ value }) {
  const { number } = useSpring({
    from: { number: 0 },
    number: value,
    delay: 200,
    config: { mass: 1, tension: 20, friction: 10 }
  });

  return <animated.span>{number.to(n => n.toFixed(2))}</animated.span>;
}

// useTransition:列表进出场动画
function TransitionList({ items }) {
  const transitions = useTransition(items, {
    keys: item => item.id,
    from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
    enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
    leave: { opacity: 0, transform: 'translate3d(0,40px,0)' },
    config: { tension: 220, friction: 24 }
  });

  return transitions((style, item) => (
    <animated.div style={style}>{item.name}</animated.div>
  ));
}

// useTrail:多个元素跟随式的"拖尾"动画
function TrailText({ words }) {
  const trail = useTrail(words.length, {
    from: { opacity: 0, x: 20 },
    to: { opacity: 1, x: 0 },
    config: { mass: 1, tension: 280, friction: 30 }
  });

  return (
    <div style={{ display: 'flex', gap: 8 }}>
      {trail.map((style, i) => (
        <animated.span key={i} style={style}>{words[i]}</animated.span>
      ))}
    </div>
  );
}

真实案例:

React Spring 被 Notion、Coinbase 等产品用于关键交互动画。数字滚动(AnimatedNumber)常见于数据看板、金额展示、点赞计数等场景。

常见坑:

必须用 animated.xxx 包裹目标元素,直接用普通 div 无法应用动画值。
物理参数需要调试,tension 太高会震荡,friction 太低会长时间抖动。
数字动画要用 .to() 做插值格式化,直接渲染 number 值会是浮点数原始值。

最佳实践:

用 config.default 预设(如 config.gentle、config.wobbly、config.stiff)快速起步;被频繁打断的交互(拖拽、跟手)优先选 React Spring 而非基于时间的动画。

五、Lottie 矢量动画

概念定义:

Lottie 是 Airbnb 开源的方案,能把设计师用 After Effects 制作的复杂动画通过 Bodymovin 插件导出为 JSON,然后在 Web、iOS、Android 上原生渲染。

为什么重要:

对于品牌插画、引导页动画、复杂的加载动画(如支付成功的对勾动画),用代码手写成本极高。Lottie 让设计师直接产出可交付的动画资产,且矢量渲染在任意分辨率都清晰、体积远小于 GIF/视频。

代码示例:

javascriptCode
import { useState, useRef } from 'react';
import Lottie from 'lottie-react';
import animationData from './animation.json';

// 基础 Lottie 动画
function LottieAnimation() {
  const [isPlaying, setIsPlaying] = useState(true);

  return (
    <div>
      <Lottie
        animationData={animationData}
        loop={true}
        autoplay={isPlaying}
        style={{ width: 200, height: 200 }}
      />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
    </div>
  );
}

// 通过 ref 命令式控制播放段落(如按钮的 hover/click 分段动画)
function InteractiveLottie() {
  const lottieRef = useRef(null);

  return (
    <div
      onMouseEnter={() => lottieRef.current?.playSegments([0, 30], true)}
      onMouseLeave={() => lottieRef.current?.playSegments([30, 60], true)}
    >
      <Lottie
        lottieRef={lottieRef}
        animationData={animationData}
        autoplay={false}
        loop={false}
        style={{ width: 120, height: 120 }}
      />
    </div>
  );
}

真实案例:

Airbnb 的加载动画、微信读书的书籍翻页、各类 App 的空状态插画、支付宝的"支付成功"对勾动画,很多都是 Lottie 实现的。

常见坑:

JSON 文件可能很大(几百 KB),需要按需懒加载,避免拖慢首屏。
复杂的 Lottie 动画在低端机上仍可能掉帧,因为部分特效走的是 Canvas/SVG 渲染而非合成层。
默认 SVG 渲染器 DOM 节点多,动画元素复杂时改用 Canvas 渲染器性能更好。

最佳实践:

用 dynamic import 懒加载动画 JSON;对纯装饰性动画包裹在 prefers-reduced-motion 判断里;复杂动画选 Canvas 渲染器。

性能优化策略(核心)

1. 使用 GPU 加速属性:

transform 和 opacity 是仅有的只触发合成阶段的 CSS 属性,不会导致重排和重绘。优先使用 translate、scale、rotate 代替 top、left、width、height 等属性。下面这组对比是性能优化的核心:

| 需求 | 差(触发重排/重绘) | 好(仅合成,GPU 加速) |

| --- | --- | --- |

| 移动元素 | left / top / margin | transform: translate() |

| 缩放元素 | width / height | transform: scale() |

| 旋转元素 | 无对应低效写法 | transform: rotate() |

| 隐藏/淡出 | visibility / display | opacity |

| 展开收起 | height: auto | transform: scaleY() 或 max-height |

2. 合成层与 will-change:

当一个元素被提升为独立的合成层(compositing layer)后,它的 transform/opacity 动画完全由 GPU 处理,不占用主线程。触发独立合成层的方式包括:3D transform(translate3d、translateZ(0))、will-change: transform、video/canvas 元素等。will-change 属性提前告知浏览器元素将要变化,让浏览器提前创建合成层并优化,但不要过度使用——每个合成层都占用 GPU 内存,滥用 will-change 反而会因为内存暴涨拖慢整体性能。正确做法是在动画开始前动态加上 will-change,动画结束后移除。

3. 避免强制同步布局(Layout Thrashing):

避免在动画中"读一次写一次"地交替读取和修改布局属性。读取 offsetWidth、offsetTop、getBoundingClientRect、scrollHeight 等属性会强制浏览器立即执行一次布局计算(因为它必须返回最新值)。如果在循环里读写交替,会触发几十上百次同步布局,帧率暴跌。正确做法是批量读、再批量写。

javascriptCode
// 坏:读写交替,强制同步布局(layout thrashing)
function badResize(elements) {
  elements.forEach(el => {
    const width = el.offsetWidth;      // 读,强制布局
    el.style.width = width * 2 + 'px'; // 写,使布局失效
    // 下一次循环再读,又强制布局……
  });
}

// 好:先批量读,再批量写
function goodResize(elements) {
  const widths = elements.map(el => el.offsetWidth); // 批量读
  elements.forEach((el, i) => {
    el.style.width = widths[i] * 2 + 'px';           // 批量写
  });
}

4. requestAnimationFrame 驱动 JS 动画:

如果必须用 JS 计算动画(如 Canvas、跟手交互),务必用 requestAnimationFrame 而不是 setInterval/setTimeout。rAF 会与屏幕刷新率同步,在每帧绘制前回调一次,避免丢帧和过度渲染,页面切到后台时还会自动暂停节省电量。

javascriptCode
import { useEffect, useRef } from 'react';

// 用 requestAnimationFrame 实现平滑滚动到顶部
function useScrollToTop() {
  const rafId = useRef(null);

  const scrollToTop = () => {
    const start = window.scrollY;
    const startTime = performance.now();
    const duration = 400;

    const easeOutCubic = t => 1 - Math.pow(1 - t, 3);

    const step = (now) => {
      const elapsed = now - startTime;
      const progress = Math.min(elapsed / duration, 1);
      window.scrollTo(0, start * (1 - easeOutCubic(progress)));
      if (progress < 1) {
        rafId.current = requestAnimationFrame(step);
      }
    };

    rafId.current = requestAnimationFrame(step);
  };

  useEffect(() => () => {
    if (rafId.current) cancelAnimationFrame(rafId.current);
  }, []);

  return scrollToTop;
}

5. FLIP 技术:

FLIP 是 First-Last-Invert-Play 的缩写,是实现高性能布局动画的经典技术,也是 Framer Motion layout 动画的底层原理。它的思路是:不去直接动画代价高昂的布局属性,而是先记录元素动画前(First)和动画后(Last)的位置,计算出差值(Invert,用 transform 把元素"假装"放回原位),然后用 GPU 加速的 transform 动画到最终位置(Play)。这样,一个涉及重排的布局变化被转换成了纯 transform 动画。

javascriptCode
import { useLayoutEffect, useRef } from 'react';

// 手写 FLIP:让元素在位置变化时平滑过渡
function useFlip(dep) {
  const ref = useRef(null);
  const prevRect = useRef(null);

  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const last = el.getBoundingClientRect(); // Last

    if (prevRect.current) {
      const first = prevRect.current;
      const dx = first.left - last.left;      // Invert
      const dy = first.top - last.top;

      if (dx || dy) {
        // 先瞬间放回原位(无过渡)
        el.style.transform = `translate(${dx}px, ${dy}px)`;
        el.style.transition = 'none';
        // 下一帧再动画到最终位置(Play)
        requestAnimationFrame(() => {
          el.style.transform = '';
          el.style.transition = 'transform 0.3s ease';
        });
      }
    }
    prevRect.current = last;
  }, [dep]);

  return ref;
}

6. React 层面的优化:

使用 React.memo 避免动画组件的不必要重新渲染。将动画状态与业务状态分离,避免动画每一帧都触发业务逻辑更新(这是 React 动画卡顿最常见的原因——把动画进度存进 state 导致每帧重渲染整棵树)。跟手/连续动画应绕过 React 直接操作 DOM 或用库的命令式 API。使用 CSS 变量动态控制动画参数,减少 JavaScript 计算。对于长列表动画,使用虚拟列表(react-window、react-virtuoso)减少 DOM 节点数量。

javascriptCode
// 坏:把动画进度存进 state,每帧重渲染整个组件
function BadFollowMouse() {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const onMove = e => setPos({ x: e.clientX, y: e.clientY }); // 每次移动都 setState
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);
  return <div style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }} />;
}

// 好:用 ref 直接操作 DOM,绕开 React 重渲染
function GoodFollowMouse() {
  const ref = useRef(null);
  useEffect(() => {
    const onMove = e => {
      if (ref.current) {
        ref.current.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
      }
    };
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);
  return <div ref={ref} />;
}

// 展开收起动画:优先 transform 而非 top/left
function OptimizedAnimation() {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div
      className="optimized-container"
      style={{
        transform: isExpanded ? 'scale(1.1)' : 'scale(1)',
        opacity: isExpanded ? 0.8 : 1,
        transition: 'transform 0.3s ease, opacity 0.3s ease',
        willChange: 'transform, opacity'
      }}
      onClick={() => setIsExpanded(!isExpanded)}
    >
      Optimized Animation
    </div>
  );
}

性能基准对比

下表是各方案在典型场景下的量化对比(数值为经验估算,用于横向参考,实际因设备和动画复杂度而异):

| 方案 | 是否占用主线程 | 典型帧率 | 包体积(gzip) | 适用场景 |

| --- | --- | --- | --- | --- |

| CSS Transition | 否(合成线程驱动) | 稳定 60fps | 0 | 简单状态过渡、悬停 |

| CSS @keyframes | 否 | 稳定 60fps | 0 | loading、骨架屏、循环动画 |

| JS + setInterval | 是 | 易掉帧 30-50fps | 0 | 不推荐 |

| JS + requestAnimationFrame | 是 | 接近 60fps | 0 | Canvas、跟手交互 |

| Framer Motion | 部分(引擎绕开 React) | 稳定 60fps | 约 30-50KB | 复杂交互、退场、布局动画 |

| React Spring | 部分 | 稳定 60fps | 约 20KB | 物理弹簧、可打断交互 |

| Lottie | 是(SVG/Canvas 渲染) | 视复杂度 40-60fps | 约 60KB + JSON | 品牌插画、复杂矢量动画 |

再看具体属性的渲染成本对比(触发的管线阶段):

| CSS 属性 | 触发阶段 | 每帧相对成本 |

| --- | --- | --- |

| transform | 仅 Composite | 最低(可 GPU) |

| opacity | 仅 Composite | 最低(可 GPU) |

| color / background-color | Paint + Composite | 中 |

| box-shadow | Paint + Composite | 中高(绘制开销大) |

| width / height / top / left | Layout + Paint + Composite | 高(波及后代) |

可访问性考虑

动画设计需要考虑可访问性,尊重用户的 prefers-reduced-motion 设置。部分用户(前庭功能障碍、眩晕敏感人群)会在系统里开启"减少动态效果",我们必须尊重这个偏好。使用 CSS 媒体查询 @media (prefers-reduced-motion: reduce) 禁用或简化动画。提供动画开关选项,让用户可以自主选择。确保动画不会导致内容闪烁或引起光敏性癫痫——WCAG 规定内容闪烁频率不得超过每秒 3 次。

javascriptCode
// CSS 层面:全局尊重 reduced-motion
// @media (prefers-reduced-motion: reduce) {
//   *, *::before, *::after {
//     animation-duration: 0.01ms !important;
//     animation-iteration-count: 1 !important;
//     transition-duration: 0.01ms !important;
//     scroll-behavior: auto !important;
//   }
// }

// JS 层面:用 Hook 读取用户偏好,动态决定是否播放动画
import { useState, useEffect } from 'react';

function usePrefersReducedMotion() {
  const query = '(prefers-reduced-motion: reduce)';
  const [reduced, setReduced] = useState(
    () => typeof window !== 'undefined' && window.matchMedia(query).matches
  );

  useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = () => setReduced(mql.matches);
    mql.addEventListener('change', onChange);
    return () => mql.removeEventListener('change', onChange);
  }, []);

  return reduced;
}

// 在 Framer Motion 中应用:偏好减少动态时禁用动画
function AccessibleMotion({ children }) {
  const reduced = usePrefersReducedMotion();

  return (
    <motion.div
      initial={reduced ? false : { opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: reduced ? 0 : 0.3 }}
    >
      {children}
    </motion.div>
  );
}

如何选型:决策指南

| 你的场景 | 推荐方案 | 理由 |

| --- | --- | --- |

| 悬停、简单过渡、loading | 纯 CSS | 零依赖、性能最佳 |

| Modal/Drawer 进出场 | Framer Motion (AnimatePresence) | 退场动画开箱即用 |

| 列表重排、卡片展开成详情 | Framer Motion (layout / layoutId) | 内置 FLIP |

| 拖拽、跟手、可被打断的交互 | React Spring | 弹簧物理连续可打断 |

| 品牌插画、复杂矢量动画 | Lottie | 设计师直接交付资产 |

| Canvas、粒子、自定义渲染 | requestAnimationFrame | 精细控制每一帧 |

总结

React 动画的核心不是"会用某个库",而是理解浏览器渲染管线并始终围绕 60fps 预算做取舍。记住三条主线:优先用 transform/opacity 让动画走 GPU 合成、把动画状态与业务状态解耦避免无谓重渲染、尊重用户的 prefers-reduced-motion 偏好。库只是工具——简单场景 CSS 就够,复杂交互再引入 Framer Motion 或 React Spring,品牌动画交给 Lottie。

| 维度 | 关键要点 |

| --- | --- |

| 性能黄金法则 | 只动画 transform 和 opacity,避开 Layout/Paint |

| 帧预算 | 60Hz 约 16.7ms/帧,120Hz 约 8.3ms/帧 |

| 合成层 | 用 will-change/translateZ 提升,但按需、用完即撤 |

| 避免陷阱 | 不读写交替触发强制同步布局;不把动画进度存进 state |

| JS 动画 | 一律用 requestAnimationFrame,配 cancelAnimationFrame 清理 |

| 布局动画 | FLIP 技术把重排转成 transform 动画 |

| 退场动画 | React 需延迟卸载或用 AnimatePresence |

| 库选型 | CSS < Framer Motion < React Spring < Lottie 逐级加重 |

| 可访问性 | 必须响应 prefers-reduced-motion,闪烁不超每秒 3 次 |

六、Framer Motion 进阶:MotionValue 与响应式动画

概念定义:

前面用的 initial/animate 是"声明式状态动画",而 Framer Motion 还有一套更底层的响应式原语——MotionValue。MotionValue 是一个游离于 React 渲染之外的可订阅数值容器,它的更新不会触发组件重渲染,而是直接写入 DOM 的 style。这正是 Framer Motion 高性能的秘密:一个跟手拖拽的元素,即使每秒更新 120 次坐标,React 组件本身一次都不会重渲染。

原理:

`useMotionValue(0)` 创建一个初始值为 0 的 MotionValue;`useTransform(mv, inputRange, outputRange)` 从一个 MotionValue 派生出另一个(做映射插值);`useSpring(mv, config)` 给一个 MotionValue 套上弹簧物理,让它对目标值的追随带惯性。它们组合起来可以搭出复杂的响应式动画管线,全程零重渲染。

代码示例:

tsxCode
import { motion, useMotionValue, useTransform, useSpring, useMotionValueEvent } from 'framer-motion';

// 卡片 3D 倾斜:鼠标位置驱动 rotateX / rotateY,全程不重渲染
function TiltCard() {
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  // 把 -0.5~0.5 的归一化坐标映射为 -15deg~15deg 的旋转
  const rotateX = useTransform(y, [-0.5, 0.5], [15, -15]);
  const rotateY = useTransform(x, [-0.5, 0.5], [-15, 15]);

  // 套一层弹簧,让回中更自然(stiffness 越大越硬,damping 越大越不回弹)
  const springX = useSpring(rotateX, { stiffness: 300, damping: 30 });
  const springY = useSpring(rotateY, { stiffness: 300, damping: 30 });

  const handleMove = (e: React.MouseEvent<HTMLDivElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    x.set((e.clientX - rect.left) / rect.width - 0.5);
    y.set((e.clientY - rect.top) / rect.height - 0.5);
  };

  const handleLeave = () => {
    x.set(0);
    y.set(0);
  };

  return (
    <motion.div
      onMouseMove={handleMove}
      onMouseLeave={handleLeave}
      style={{
        rotateX: springX,
        rotateY: springY,
        transformPerspective: 800,
        width: 240,
        height: 320,
      }}
      className="tilt-card"
    />
  );
}

// useMotionValueEvent:订阅数值变化做副作用(如埋点、节流上报),不触发重渲染
function ProgressWatcher() {
  const { scrollYProgress } = useScroll();
  useMotionValueEvent(scrollYProgress, 'change', (latest) => {
    if (latest > 0.9) console.log('用户已滚到底部 90%');
  });
  return null;
}

滚动进场:useInView 与 scroll offset:

Framer Motion 的 `useInView` 基于 IntersectionObserver 封装,用来实现"滚动到视口内才播放进场动画"。而 `useScroll({ target, offset })` 可以拿到某个元素相对视口的滚动进度(0~1),做局部滚动驱动。

tsxCode
import { useRef } from 'react';
import { motion, useInView, useScroll, useTransform } from 'framer-motion';

// 滚动到视口才淡入上移;once: true 表示只播一次
function RevealOnScroll({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: '0px 0px -100px 0px' });

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 40 }}
      animate={inView ? { opacity: 1, y: 0 } : {}}
      transition={{ duration: 0.5, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}

// 局部滚动进度驱动图片缩放(scroll-linked,随手指/滚轮实时联动)
function ScrollZoomImage({ src }: { src: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start end', 'end start'], // 元素顶部进入视口底部 到 元素底部离开视口顶部
  });
  const scale = useTransform(scrollYProgress, [0, 1], [1.2, 1]);

  return (
    <div ref={ref} style={{ overflow: 'hidden' }}>
      <motion.img src={src} style={{ scale }} />
    </div>
  );
}

按需加载减小体积:LazyMotion

完整引入 `motion` 组件会带来约 30-50KB(gzip)的体积。若只用基础动画,可用 `LazyMotion` + `domAnimation` 特性包把核心体积压到约 4.6KB,把 `motion.div` 换成 `m.div`:

tsxCode
import { LazyMotion, domAnimation, m } from 'framer-motion';

// 只加载基础动画特性(约 4.6KB),拖拽/布局动画用 domMax(约 15KB)
function App() {
  return (
    <LazyMotion features={domAnimation} strict>
      <m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
    </LazyMotion>
  );
}

常见坑:

1.MotionValue 的值改变不会触发 React 重渲染——想在 JSX 里读它当前值请用 `useMotionValueEvent` 或 `mv.get()`,别指望它像 state 一样自动更新文本。
2.`useScroll` 的 offset 语法容易写错,`'start end'` 表示"目标的 start 对齐视口的 end",方向别搞反。
3.用了 `LazyMotion strict` 后就不能再用大写的 `motion.div`,否则会抛错——必须统一用 `m.div`。

最佳实践:

跟手交互(拖拽、3D 倾斜、视差)一律走 MotionValue,杜绝把坐标存进 state。
首屏之外的动画组件配合 `useInView` 懒触发,既省性能又提升体验。
对体积敏感的项目(尤其移动端)默认用 LazyMotion,按需升级到 domMax。

七、Web Animations API(WAAPI):浏览器原生动画

概念定义:

Web Animations API 是浏览器原生提供的命令式动画接口,核心是 `element.animate(keyframes, options)`。它相当于"用 JS 写 @keyframes",但返回一个可控的 Animation 对象,能 pause/play/reverse/finish、监听 finished(Promise)、动态改 playbackRate。它由浏览器合成线程驱动,性能与 CSS 动画同级,却拥有 JS 的可编程性,且零依赖、零 bundle 成本。

原理:

WAAPI 与 CSS 动画共享同一套底层实现,因此同样能把 transform/opacity 动画放到合成层由 GPU 处理,不占用主线程。相比手写 requestAnimationFrame(每帧在主线程算插值),WAAPI 把插值交给浏览器引擎,主线程更空闲。现代浏览器(Chrome/Firefox/Safari 全支持)覆盖率已超过 95%。

代码示例:

tsxCode
import { useRef, useEffect } from 'react';

// 用 WAAPI 封装一个可复用的进场 Hook,零依赖
function useEnterAnimation<T extends HTMLElement>() {
  const ref = useRef<T>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const anim = el.animate(
      [
        { opacity: 0, transform: 'translateY(20px)' },
        { opacity: 1, transform: 'translateY(0)' },
      ],
      {
        duration: 400,
        easing: 'cubic-bezier(0.22, 1, 0.36, 1)', // easeOutQuint,快进慢停
        fill: 'both',
      }
    );
    return () => anim.cancel();
  }, []);

  return ref;
}

// 可控播放:hover 加速、离开反向,用 finished Promise 串联序列
function ControllableBox() {
  const ref = useRef<HTMLDivElement>(null);
  const animRef = useRef<Animation | null>(null);

  const start = async () => {
    const el = ref.current;
    if (!el) return;
    animRef.current = el.animate(
      [{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }],
      { duration: 300, fill: 'forwards' }
    );
    await animRef.current.finished; // 等动画完成再执行下一步
    console.log('放大完成');
  };

  const reverse = () => animRef.current?.reverse();

  return <div ref={ref} onMouseEnter={start} onMouseLeave={reverse} />;
}

WAAPI vs CSS vs 库 对比:

| 维度 | CSS Animation | WAAPI | Framer Motion |

| --- | --- | --- | --- |

| bundle 成本 | 0 | 0 | 约 30-50KB |

| 是否占主线程 | 否 | 否 | 部分 |

| 可编程控制 | 弱(需切类名) | 强(play/pause/reverse) | 强 |

| 动态关键帧 | 不便 | 便捷(JS 对象) | 便捷 |

| 学习成本 | 低 | 中 | 中 |

常见坑:

`fill: 'forwards'` 会让元素停在终态,但过多"填充中"的动画会占内存,长期动画结束后建议 `commitStyles()` + `cancel()` 把终态写回内联样式再释放。
旧版 Safari 对 composite 操作和部分 easing 支持不全,关键动画要做降级测试。

最佳实践:

需要"CSS 的性能 + JS 的可控性"且不想加依赖时,WAAPI 是最优解;配合 finished Promise 能优雅地串联多段动画。

八、缓动函数(Easing)与手写 rAF 动画引擎

概念定义:

缓动函数决定动画进度随时间的变化曲线——线性(linear)显得机械,而 ease-in(慢进快出)、ease-out(快进慢停)、ease-in-out 更符合物理直觉。本质上它是一个把时间进度 t(0~1)映射为动画进度(0~1)的纯函数。

常用缓动函数实现:

tsxCode
// 一组常用缓动函数(t 均为 0~1)
const Easing = {
  linear: (t: number) => t,
  easeInQuad: (t: number) => t * t,
  easeOutQuad: (t: number) => t * (2 - t),
  easeInOutCubic: (t: number) =>
    t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
  easeOutQuint: (t: number) => 1 - Math.pow(1 - t, 5),
  // 回弹(overshoot):先冲过头再拉回,弹性手感
  easeOutBack: (t: number) => {
    const c1 = 1.70158;
    const c3 = c1 + 1;
    return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
  },
  // 弹跳
  easeOutBounce: (t: number) => {
    const n1 = 7.5625;
    const d1 = 2.75;
    if (t < 1 / d1) return n1 * t * t;
    if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
    if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
    return n1 * (t -= 2.625 / d1) * t + 0.984375;
  },
};

// 通用 rAF 补间动画引擎:从 from 到 to,带缓动与完成回调
function animateValue(
  from: number,
  to: number,
  duration: number,
  easing: (t: number) => number,
  onUpdate: (v: number) => void,
  onComplete?: () => void
) {
  const startTime = performance.now();
  let rafId = 0;

  const tick = (now: number) => {
    const elapsed = now - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const eased = easing(progress);
    onUpdate(from + (to - from) * eased);
    if (progress < 1) {
      rafId = requestAnimationFrame(tick);
    } else {
      onComplete?.();
    }
  };

  rafId = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(rafId); // 返回取消函数
}

帧率无关(delta time)的连续动画:

对于持续运行的动画(如无限滚动、粒子),不能假设每帧固定 16.7ms,否则在 120Hz 屏上速度会翻倍、在掉帧时会顿挫。正确做法是用两帧之间的时间差(delta)来推进:

tsxCode
import { useEffect, useRef } from 'react';

// 帧率无关的匀速旋转:无论 60Hz 还是 120Hz,视觉速度一致
function useSpin(speedDegPerSec = 90) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let angle = 0;
    let last = performance.now();
    let rafId = 0;

    const loop = (now: number) => {
      const delta = (now - last) / 1000; // 转成秒
      last = now;
      angle = (angle + speedDegPerSec * delta) % 360;
      if (ref.current) ref.current.style.transform = `rotate(${angle}deg)`;
      rafId = requestAnimationFrame(loop);
    };

    rafId = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(rafId);
  }, [speedDegPerSec]);

  return ref;
}

常见坑:

忘记用 delta time,动画速度会随刷新率漂移——高刷屏用户看到的动画会明显偏快。
缓动函数选错方向:进场(元素出现)该用 ease-out(快进慢停)显得利落,退场用 ease-in。
rAF 回调里做重活(复杂计算、读布局)会拉长帧时间,务必把纯计算和 DOM 写入分开。

最佳实践:

把补间引擎抽成可复用工具,通过传入不同 easing 复用;连续动画一律 delta time 驱动;每个 rAF 循环都要在卸载时 cancelAnimationFrame 清理。

九、滚动驱动动画:IntersectionObserver 与 CSS 原生方案

概念定义:

滚动驱动动画分两类:一是"滚动触发"(scroll-triggered,元素进入视口后播一次进场),二是"滚动联动"(scroll-linked,动画进度实时绑定滚动位置,如视差、进度条)。前者首选 IntersectionObserver(避免监听 scroll 事件带来的高频回调),后者可用 CSS 原生的 scroll-timeline / view-timeline。

IntersectionObserver 实现滚动进场:

tsxCode
import { useEffect, useRef, useState } from 'react';

// 通用"进入视口才显示"Hook,性能远优于监听 scroll 事件
function useInViewport<T extends HTMLElement>(options?: IntersectionObserverInit) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setVisible(true);
        observer.unobserve(entry.target); // 只触发一次,触发后停止观察省开销
      }
    }, { threshold: 0.15, ...options });

    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return { ref, visible };
}

function FadeInSection({ children }: { children: React.ReactNode }) {
  const { ref, visible } = useInViewport<HTMLDivElement>();
  return (
    <div
      ref={ref}
      style={{
        opacity: visible ? 1 : 0,
        transform: visible ? 'translateY(0)' : 'translateY(30px)',
        transition: 'opacity 0.6s ease, transform 0.6s ease',
      }}
    >
      {children}
    </div>
  );
}

CSS 原生滚动驱动动画(scroll-timeline / view-timeline):

Chrome 115+ 起支持纯 CSS 的滚动驱动动画,完全跑在合成线程,连 rAF 都不用写。`animation-timeline: scroll()` 把动画进度绑定到滚动容器,`view()` 绑定到元素在视口中的可见进度。

cssCode
/* 顶部阅读进度条:随页面滚动从 0 拉伸到 100%,纯 CSS,零 JS */
@keyframes grow-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 100%;
  transform-origin: left;
  background: #3498db;
  animation: grow-progress linear;
  animation-timeline: scroll(root block); /* 绑定根滚动容器的垂直滚动 */
}

/* 图片进入视口时淡入上移:view() 绑定元素自身可见进度 */
@keyframes reveal {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: translateY(0); }
}

.reveal-on-scroll {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 60%; /* 从刚进入视口到进入 60% 之间完成 */
}

@property 让渐变/自定义属性可动画:

默认自定义属性(CSS 变量)不可插值动画,用 `@property` 声明类型后即可平滑过渡,常用于渐变角度、颜色滚动等:

cssCode
@property --gradient-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@keyframes rotate-gradient {
  to { --gradient-angle: 360deg; }
}

.conic-border {
  background: conic-gradient(from var(--gradient-angle), #3498db, #9b59b6, #3498db);
  animation: rotate-gradient 3s linear infinite;
}

常见坑:

直接监听 window 的 scroll 事件做联动动画,回调可能每帧触发多次且在主线程,极易掉帧——能用 IntersectionObserver 或 CSS scroll-timeline 就别监听 scroll。
CSS scroll-driven animation 目前 Safari 仍在推进中,生产环境需配合 `@supports (animation-timeline: scroll())` 做特性检测降级。
IntersectionObserver 的 threshold 设太高(如 1.0)在元素比视口还高时永远不触发。

最佳实践:

滚动触发一次性进场用 IntersectionObserver 并在触发后 unobserve;滚动联动优先尝试 CSS 原生方案,不支持再降级到 Framer Motion 的 useScroll。

十、SVG 路径动画与线条绘制

概念定义:

SVG 动画能做出用 DOM/CSS 难以实现的效果,最经典的是"线条自我绘制"(如签名、对勾、图标描边逐渐画出)。核心技巧是利用 `stroke-dasharray`(虚线间隔)和 `stroke-dashoffset`(虚线偏移):把整条路径设成一段等于路径总长的虚线,再从"完全偏移出去"动画到"偏移为 0",视觉上就是线条被逐渐画出来。

代码示例:

tsxCode
import { useRef, useEffect } from 'react';

// SVG 对勾"绘制"动画:常用于"提交成功"反馈
function AnimatedCheck() {
  const pathRef = useRef<SVGPathElement>(null);

  useEffect(() => {
    const path = pathRef.current;
    if (!path) return;
    const length = path.getTotalLength(); // 拿到路径总长度
    path.style.strokeDasharray = String(length);
    path.style.strokeDashoffset = String(length); // 先"藏起来"
    // 强制重排后再触发过渡,确保动画生效
    path.getBoundingClientRect();
    path.style.transition = 'stroke-dashoffset 0.6s ease-out';
    path.style.strokeDashoffset = '0'; // 画出来
  }, []);

  return (
    <svg width="80" height="80" viewBox="0 0 80 80">
      <circle cx="40" cy="40" r="36" fill="none" stroke="#2ecc71" strokeWidth="4" />
      <path
        ref={pathRef}
        d="M24 42 L36 54 L58 28"
        fill="none"
        stroke="#2ecc71"
        strokeWidth="5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

也可用纯 CSS 配合 @keyframes 实现同样效果(性能更好,走合成线程):

cssCode
.draw-path {
  stroke-dasharray: 200;
  stroke-dashoffset: 200;
  animation: draw 0.8s ease-out forwards;
}

@keyframes draw {
  to { stroke-dashoffset: 0; }
}

常见坑:

`getTotalLength()` 必须在元素挂载到 DOM 后才能拿到正确值,写在渲染阶段会得 0。
stroke 动画走的是 Paint 阶段(不是纯合成),路径很复杂或数量多时仍可能掉帧,SVG 大动画优先考虑 Lottie。

最佳实践:

简单描边动画能用纯 CSS 就别用 JS;对勾/加载/图标微动效用 SVG + dashoffset 体积极小(几乎为 0)且清晰无损。

十一、动画性能剖析:用 DevTools 找出掉帧根因

概念定义:

优化的前提是测量。Chrome DevTools 提供了几套定位动画性能问题的利器:Performance 面板(火焰图 + 帧率轨道)、Rendering 面板的 FPS meter / Paint flashing / Layer borders,以及 Layers 面板。

排查流程(原理 + 操作):

| 工具 | 位置 | 看什么 | 定位的问题 |

| --- | --- | --- | --- |

| FPS meter | Rendering 面板 | 实时帧率曲线 | 是否掉帧、掉到多少 |

| Paint flashing | Rendering 面板 | 重绘区域高亮闪绿 | 有没有本不该重绘的区域在重绘 |

| Layer borders | Rendering 面板 | 合成层橙色边框 | 元素是否被提升为合成层 |

| Performance 火焰图 | Performance 面板 | Main 轨道长任务、紫色 Layout 块 | 强制同步布局、长任务阻塞 |

| Frames 轨道 | Performance 面板 | 每帧耗时、红色掉帧标记 | 哪一帧超了 16.7ms 预算 |

火焰图读法:

录制一段动画后,看 Main 线程轨道。紫色代表 Layout/重排,绿色代表 Paint,若在动画期间反复出现密集紫色块,说明触发了强制同步布局(Layout Thrashing)——右上角还会有红色警告三角 "Forced reflow"。理想的 transform/opacity 动画在录制里 Main 线程应该几乎是空的,工作都在 Compositor 和 GPU 线程。

tsxCode
// 用 PerformanceObserver 在生产环境采集长任务(>50ms)做监控上报
import { useEffect } from 'react';

function useLongTaskMonitor() {
  useEffect(() => {
    if (!('PerformanceObserver' in window)) return;
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // 超过 50ms 的任务会阻塞渲染,动画期间尤其致命
        if (entry.duration > 50) {
          console.warn(`长任务 ${entry.duration.toFixed(1)}ms,可能导致掉帧`);
        }
      }
    });
    observer.observe({ entryTypes: ['longtask'] });
    return () => observer.disconnect();
  }, []);
}

// 手动测量一段动画的实际帧率
function measureFps(durationMs = 1000): Promise<number> {
  return new Promise((resolve) => {
    let frames = 0;
    const start = performance.now();
    const tick = () => {
      frames++;
      if (performance.now() - start < durationMs) {
        requestAnimationFrame(tick);
      } else {
        resolve(Math.round((frames * 1000) / (performance.now() - start)));
      }
    };
    requestAnimationFrame(tick);
  });
}

性能预算参考:

| 指标 | 目标值 | 说明 |

| --- | --- | --- |

| 单帧总耗时 | < 16.7ms(60Hz) | 超出即掉帧 |

| 单帧 JS 执行 | < 10ms | 留出样式/布局/绘制时间 |

| 高刷屏单帧 | < 8.3ms(120Hz) | ProMotion / 安卓旗舰 |

| 长任务阈值 | 50ms | 超过即判定为 long task |

| 交互响应(INP) | < 200ms | Core Web Vitals 良好线 |

| 合成层数量 | 适度(几十个内) | 过多耗 GPU 内存反成负担 |

常见坑:

只在高配开发机上测,掉帧不明显——务必用 DevTools 的 CPU 4x/6x throttling 模拟中低端机。
看到卡顿就无脑加 will-change / translateZ(0),结果合成层爆炸、GPU 内存溢出反而更卡。
只看平均帧率,忽略了偶发的严重掉帧(长尾),应关注 p95/p99 帧时间。

最佳实践:

先测量再优化,用 Performance 火焰图定位是 Layout 还是 Paint 瓶颈;用 CPU throttling 复现低端机表现;生产环境用 PerformanceObserver 采集 longtask 和 INP 做长期监控。

十二、动画库全景选型对比

综合体积、能力、心智模型给出一张更完整的选型速查表(体积为 gzip 后经验值,随版本浮动):

| 库 / 方案 | bundle(gzip) | 动画模型 | 强项 | 短板 | 何时选它 |

| --- | --- | --- | --- | --- | --- |

| 纯 CSS / WAAPI | 0 | 时间 + 缓动 | 零成本、合成线程、最稳 | 复杂编排繁琐 | 简单过渡、loading、进度条 |

| Framer Motion | 约 30-50KB(可 LazyMotion 降到 4.6KB) | 声明式 + 弹簧 + MotionValue | 退场、布局 FLIP、手势、生态 | 全量体积偏大 | 交互丰富的产品级应用 |

| React Spring | 约 20KB | 纯物理弹簧 | 可打断、跟手手感 | API 偏底层、上手略陡 | 拖拽、跟手、连续可打断交互 |

| GSAP | 约 45KB | 时间线(timeline) | 复杂时间线编排、跨框架、兼容性强 | 商用部分插件收费、非 React 原生 | 复杂剧情动画、营销页 |

| React Transition Group | 约 5KB | 生命周期钩子 | 轻量、只管进出场 | 需自己写 CSS、能力有限 | 只需要进出场且想极致轻量 |

| Lottie | 约 60KB + JSON | AE 导出回放 | 设计师直接交付复杂矢量 | JSON 大、低端机可能掉帧 | 品牌插画、复杂矢量动画 |

| Auto-Animate | 约 3KB | 自动 FLIP | 一行代码给列表加动画 | 只能做增删移位 | 快速给列表/表单加过渡 |

选型决策口诀:

能 CSS 就 CSS;要退场/布局/手势上 Framer Motion;跟手可打断选 React Spring;复杂时间线找 GSAP;设计师产出的复杂矢量交给 Lottie;只想给列表一行代码加动画用 Auto-Animate。永远从"最轻的够用方案"起步,别一上来就引入重型库。

结语:动画工程师的心智模型

React 动画的进阶,本质是从"会调库的 API"升级到"理解每一帧发生了什么"。四条贯穿全文的主线值得反复咀嚼:第一,所有动画最终都要落到浏览器渲染管线,能只碰 transform/opacity 就不碰 Layout/Paint;第二,把动画数值从 React 渲染循环里剥离出去(MotionValue、ref 直接写 DOM、WAAPI、CSS),是高性能的共同秘诀;第三,测量先于优化,DevTools 火焰图和帧率轨道是判断的唯一依据,而非直觉;第四,动画是为人服务的,尊重 prefers-reduced-motion、控制闪烁频率、给出关闭开关,才是完整的工程实践。

| 进阶维度 | 一句话要点 |

| --- | --- |

| MotionValue | 数值游离于渲染之外,跟手动画零重渲染 |

| WAAPI | 原生、零 bundle、有 CSS 的性能与 JS 的可控 |

| 缓动与 rAF | 连续动画用 delta time,帧率无关才不漂移 |

| 滚动驱动 | 触发用 IntersectionObserver,联动优先 CSS scroll-timeline |

| SVG 描边 | stroke-dasharray + dashoffset,体积几乎为零 |

| 性能剖析 | 先测量:FPS meter、Paint flashing、火焰图找紫色 Layout |

| 库选型 | 从最轻够用起步,CSS → Framer/Spring → GSAP/Lottie |

| 帧预算 | 60Hz 约 16.7ms,120Hz 约 8.3ms,JS 部分留在 10ms 内 |