React 动画与 Framer Motion

深入理解 React 动画方案:从 CSS transition 到 Framer Motion,学习 layout 动画、手势驱动动画、以及动画性能优化。

React 动画与 Framer Motion

动画方案概述

React 中实现动画有多种方案,从简单到复杂:

  1. CSS Transition/Animation:最简单的动画方案,适合简单的状态切换
  2. CSS-in-JS 动画:如 styled-componentskeyframes
  3. React Motion:早期的 React 动画库
  4. Framer Motion:目前最流行的 React 动画库,API 设计优雅
  5. React Spring:基于物理的动画库

选择依据:

  • 简单状态切换 → CSS Transition
  • 复杂交互动画 → Framer Motion
  • 物理仿真动画 → React Spring

CSS 动画基础

CSS Transition

transition 适合简单的 A → B 状态变化:

function Button() {
  const [isHovered, setIsHovered] = useState(false);

  return (
    <button
      style={{
        transition: 'all 0.3s ease',
        transform: isHovered ? 'scale(1.05)' : 'scale(1)',
        backgroundColor: isHovered ? '#0056b3' : '#007bff',
      }}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      Click me
    </button>
  );
}

CSS Keyframes

@keyframes 适合复杂的、时间线式的动画:

const keyframes = `
  @keyframes slideIn {
    from {
      opacity: 0;
      transform: translateX(-100%);
    }
    to {
      opacity: 1;
      transform: translateX(0);
    }
  }

  @keyframes fadeOut {
    from { opacity: 1; }
    to { opacity: 0; }
  }
`;

function AnimatedComponent({ isVisible }) {
  return (
    <>
      <style>{keyframes}</style>
      <div style={{
        animation: isVisible ? 'slideIn 0.5s ease' : 'fadeOut 0.3s ease'
      }}>
        Content
      </div>
    </>
  );
}

Framer Motion 简介

Framer Motion 是由 Framer 公司开发的 React 动画库,它的核心概念是:

  • Animate:任何可动画属性的值
  • Gesture:点击、拖拽、悬停等交互
  • Layout:自动布局动画
  • MotionValue:可追踪的动画值

安装

npm install framer-motion

基础动画:motion 组件

import { motion } from 'framer-motion';

function AnimatedBox() {
  return (
    <motion.div
      initial={{ opacity: 0, x: -100 }}
      animate={{ opacity: 1, x: 0 }}
      transition={{ duration: 0.5, ease: 'easeOut' }}
    >
      Content
    </motion.div>
  );
}

motion 组件的行为:

  1. initial:组件挂载前的状态
  2. animate:组件挂载后或变量变化后的目标状态
  3. transition:如何从初始状态过渡到目标状态

动画变体(Variants)

Variants 让你把多个动画状态组合成一个「动画状态机」:

const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1  // 子元素延迟
    }
  }
};

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

function StaggeredList() {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
    >
      {[1, 2, 3].map(i => (
        <motion.li key={i} variants={itemVariants}>
          Item {i}
        </motion.li>
      ))}
    </motion.ul>
  );
}

Variants 的优势:

  1. 代码更清晰
  2. 支持 staggerChildren 实现交错动画
  3. 可以动态切换整个状态机

手势动画

Hover

function ScaleButton() {
  return (
    <motion.button
      whileHover={{ scale: 1.05 }}
      whileTap={{ scale: 0.95 }}
    >
      Click me
    </motion.button>
  );
}

拖拽(Drag)

function DraggableBox() {
  return (
    <motion.div
      drag="x"  // 只允许水平拖拽
      dragConstraints={{ left: -100, right: 100 }}
      dragElastic={0.1}  // 弹性系数
      whileDrag={{ scale: 1.1 }}
    >
      Drag me
    </motion.div>
  );
}

高级拖拽:拖拽释放到目标

function DropZone() {
  return (
    <motion.div
      id="drop-zone"
      style={{
        width: 200,
        height: 200,
        backgroundColor: '#eee',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center'
      }}
    >
      Drop here
    </motion.div>
  );
}

function DraggableItem() {
  const { drag, setDrag } = useState();

  return (
    <motion.div
      drag
      dragSnapToOrigin
      whileDrag={{ scale: 1.1 }}
      onDragEnd={(e, info) => {
        // 检查是否释放到目标区域
        const dropTarget = document.getElementById('drop-zone');
        const dropRect = dropTarget.getBoundingClientRect();
        if (
          info.point.x >= dropRect.left &&
          info.point.x <= dropRect.right &&
          info.point.y >= dropRect.top &&
          info.point.y <= dropRect.bottom
        ) {
          console.log('Dropped in zone!');
        }
      }}
    >
      Drag me
    </motion.div>
  );
}

Layout 动画

Layout 动画是 Framer Motion 最强大的特性之一——当元素位置或大小变化时,自动添加平滑过渡:

function LayoutDemo() {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <motion.div
      layout
      style={{
        width: isExpanded ? 400 : 200,
        height: isExpanded ? 400 : 200,
        backgroundColor: '#007bff',
        borderRadius: 10
      }}
      onClick={() => setIsExpanded(!isExpanded)}
    >
      Click to resize
    </motion.div>
  );
}

layout prop 添加后,Framer Motion 会自动计算位置和大小的变化,并添加平滑过渡动画。

列表动画

function AnimatedList({ items, onRemove }) {
  return (
    <motion.ul layout>
      {items.map(item => (
        <motion.li
          key={item.id}
          layout
          initial={{ opacity: 0, y: -20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, x: -100 }}
        >
          <span>{item.text}</span>
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </motion.li>
      ))}
    </motion.ul>
  );
}

exit prop 定义元素被移除前的动画,配合 AnimatePresence 组件使用。


AnimatePresence

AnimatePresence 允许在组件卸载时执行退出动画:

import { AnimatePresence, motion } from 'framer-motion';

function Modal({ isOpen, onClose }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <div onClick={onClose}>
            <motion.div
              initial={{ scale: 0.8, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.8, opacity: 0 }}
            >
              Modal Content
            </motion.div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

关键点:组件必须作为 AnimatePresence 的子组件才能在卸载时执行 exit 动画。


Motion Values

useMotionValue 让你创建可追踪的动画值:

function MotionValueDemo() {
  const x = useMotionValue(0);

  return (
    <motion.div
      style={{ x }}
      drag="x"
      dragConstraints={{ left: 0, right: 300 }}
    >
      <MotionValueDisplay value={x} />
    </motion.div>
  );
}

function MotionValueDisplay({ value }) {
  // useMotionValue 不触发 React 重新渲染
  // 需要用 useMotionValueEvent 来监听变化
  useMotionValueEvent(value, 'change', (latest) => {
    console.log('x is now:', latest);
  });

  return null;
}

useTransform

useTransform 可以在一个 motion value 的变化范围内计算另一个值:

function ParallaxCard() {
  const y = useMotionValue(0);

  const rotate = useTransform(y, [0, 300], [0, 10]);
  const opacity = useTransform(y, [0, 300], [1, 0.5]);

  return (
    <motion.div style={{ y, rotate, opacity }}>
      Card
    </motion.div>
  );
}

动画性能优化

GPU 加速

Framer Motion 默认使用 transformopacity 进行动画,这两个属性不会触发 layout 或 paint:

// 好:使用 transform
<motion.div animate={{ x: 100 }} />

// 差:使用宽高变化(触发 layout)
<motion.div animate={{ width: 200 }} />

避免动画时重新渲染

// 好:只动画不渲染
<motion.div animate={{ scale: 1.1 }} />

// 差:依赖变化触发重新渲染
const [scale, setScale] = useState(1);
<motion.div animate={{ scale }} onClick={() => setScale(1.1)} />

使用 layoutId 实现共享动画

layoutId 允许在不同组件之间实现「形变」动画:

function SharedLayoutDemo() {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div>
      <motion.div
        layoutId="card"
        style={{ backgroundColor: '#007bff' }}
        onClick={() => setIsExpanded(!isExpanded)}
      >
        {!isExpanded && <div>Preview</div>}
        {isExpanded && <div>Full Content</div>}
      </motion.div>
    </div>
  );
}

layoutId 相同时,Framer Motion 会自动计算两个状态之间的形变并添加过渡动画。


面试中的表达

面试中聊到 React 动画,通常是在考察你对动画原理和用户体验的理解:

关于动画方案的选择,我的经验是:简单交互用 CSS Transition,复杂交互动画用 Framer Motion。Framer Motion 的核心优势是声明式 API 和 layout 动画——你只需要描述动画的初始状态和目标状态,库会自动计算过渡。

layout 动画是 Framer Motion 最强大的特性。当列表项位置变化时,只需要加一个 layout prop,就会自动有过渡动画,而不是突兀地跳到新位置。

关于性能,动画应该尽量只改变 transformopacity,这两个属性是 GPU 加速的,不会触发重排(reflow)和重绘(repaint)。


延展阅读