React 动画集成:利用 Framer Motion 实现基于组件声明周期的声明式动画编排

欢迎来到 React 动画的圣殿。我是你们的向导,一个在这个充满 CSS 变量和 DOM 操作地狱中摸爬滚打多年的资深“代码修理工”。

今天我们要聊的话题,是关于如何在 React 这个声明式框架中,用一种优雅、甚至可以说是“艺术”的方式,给我们的 UI 添加灵魂。我们不聊那些陈词滥调的 CSS transition,也不聊那些让你头皮发麻的 setTimeout 魔法。今天,我们要聊的是 Framer Motion

如果你觉得 React 是写代码,而 CSS 是写艺术,那么 Framer Motion 就是那个把你俩撮合在一起的媒人。它能让你的组件在挂载、更新、卸载甚至被鼠标戳的时候,都表现得像个有血有肉的生命体。

准备好了吗?系好安全带,我们要开始这趟关于“生命周期与动画编排”的深度巡礼了。


第一部分:为什么我们要在这里?(DOM 操作的噩梦)

在 Framer Motion 出现之前,给 React 组件添加动画简直就是一场灾难。你手里拿着的是 React —— 一个告诉你“不要直接碰 DOM,状态才是王道”的框架;但你手头只有 CSS —— 一个必须直接操作 DOM 属性(style.top, style.left)才能看到效果的语言。

于是,你开始写这样的代码:

// 这种代码在 2015 年很流行,但现在读起来就像在嚼蜡
useEffect(() => {
  const element = document.getElementById('my-card');
  element.style.transform = 'translateY(0px)';
  element.style.opacity = '1';

  // 等等,如果用户快速点击怎么办?状态同步吗?
  // 如果组件卸载了怎么办?内存泄漏!
  return () => {
    element.style.transform = 'translateY(-100px)';
  };
}, [isOpen]);

看着这行代码,我都觉得眼晕。这根本不是 React 的写法。这是把 React 当成了单纯的模板引擎,然后在背后偷偷用 jQuery 的那一套逻辑在操作 DOM。这就像是你花大价钱买了一辆法拉利(React),却非要把它当拖拉机来开,还在引擎盖上挂满了链条和铁皮。

Framer Motion 的出现,就是为了让你用声明式的方式(告诉它“我想做什么”),而不是命令式的方式(告诉它“怎么去把那个 DOM 节点从 A 移到 B”),来控制动画。它把复杂的数学计算、GPU 加速和状态管理封装在了一个名为 motion 的组件里。


第二部分:组件生命周期的第一阶段——挂载

想象一下,你的应用是一个舞台,而你的组件是演员。当演员第一次登台(组件挂载)时,他们不应该直接僵硬地站在那里,他们应该有一种“登场”的感觉。

这就是 Framer Motion 的 AnimatePresenceinitial/animate 属性大展身手的时候。

2.1 基础入场:从无到有

最简单的动画,就是让一个元素从透明度 0 变到 1,或者从位移 100px 变到 0。

import { motion } from 'framer-motion';

const Welcome = () => {
  return (
    <motion.div
      initial={{ opacity: 0, y: 50 }} // 初始状态:看不见,在下面
      animate={{ opacity: 1, y: 0 }}   // 动画目标:看见,回到原位
      transition={{ duration: 0.5 }}  // 持续时间:0.5秒
    >
      欢迎来到动画的世界!
    </motion.div>
  );
};

看到了吗?没有 setTimeout,没有 requestAnimationFrame 的手动调用。你只是告诉了组件“初始状态是什么”和“目标状态是什么”,剩下的脏活累活,Framer Motion 全包了。

2.2 队列入场:AnimatePresence

但是,如果我们要渲染一个列表呢?如果列表里有 10 个项目,你想让它们一个接一个地滑入,而不是同时出现,那该怎么办?

这时候,我们需要 AnimatePresence。它是 Framer Motion 里的“队列管理器”。

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

const List = () => {
  const items = ['苹果', '香蕉', '葡萄', '西瓜'];

  return (
    <div>
      <AnimatePresence mode="popLayout"> {/* popLayout 模式很重要,下面会讲 */}
        {items.map((item, index) => (
          <motion.div
            key={item}
            initial={{ opacity: 0, x: -50 }} // 初始在左边看不见
            animate={{ opacity: 1, x: 0 }}   // 移动到原位
            exit={{ opacity: 0, x: 50 }}     // 退出时移到右边消失
            transition={{ delay: index * 0.1 }} // 延迟!每个项目延迟 0.1 秒
            style={{ marginBottom: 10 }}
          >
            {item}
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
};

这里有一个关键点:AnimatePresence 会管理组件的 exit 状态。它知道什么时候该让元素消失,什么时候该让新的元素出现。没有它,当你删除列表中的最后一个项目时,屏幕可能会瞬间变空,没有任何过渡效果,就像突然被切断电源一样生硬。


第三部分:组件生命周期的第二阶段——更新与布局

这是 Framer Motion 的杀手锏。在 React 中,更新组件通常意味着重新渲染,内容可能会跳动、闪烁。但在 Framer Motion 中,我们可以让内容在移动时保持平滑,就像它们被磁力吸附在了一起。

这就是 Layout Animations(布局动画)

3.1 原子弹:layout 属性

假设我们要做一个待办事项列表。当用户勾选一个任务时,任务文本通常会划掉,而列表会收缩。如果使用传统的 CSS,这会导致整个列表剧烈抖动。

但在 Framer Motion 中,我们只需要加一个 layout 属性。

import { motion } from 'framer-motion';

const TodoItem = ({ text, completed }) => {
  return (
    <motion.li
      layout // 开启布局动画
      initial={false}
      animate={{ textDecoration: completed ? 'line-through' : 'none' }}
      style={{ padding: 10, border: '1px solid #ccc', marginBottom: 5 }}
    >
      {text}
    </motion.li>
  );
};

const TodoList = () => {
  const [todos, setTodos] = React.useState([
    { id: 1, text: '学习 Framer Motion', completed: false },
    { id: 2, text: '吃顿好的', completed: false },
  ]);

  const toggle = (id) => {
    setTodos(todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
  };

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem 
          key={todo.id} 
          text={todo.text} 
          completed={todo.completed} 
          onClick={() => toggle(todo.id)} 
        />
      ))}
    </ul>
  );
};

看,我什么都没做!我只是在 TodoItem 上加了一个 layout 属性。当 completed 状态改变时,Framer Motion 会自动计算 DOM 节点的新位置和大小,然后执行一个平滑的补间动画。

这就像魔术一样。你不需要去计算“这个项目现在的高度是 50px,下一个是 40px,我需要移动多少像素”。Framer Motion 会自动搞定。

3.2 跨组件的布局动画:layoutId

这是最令人兴奋的功能。如果你有多个组件,它们共享同一个 ID,Framer Motion 会认为它们是同一个物体,即使它们在不同的 DOM 树中。

想象一下,你有一个卡片列表。当你点击一个卡片时,你想把它“拖”到页面的另一个位置,或者把它放大。layoutId 就能实现这种跨组件的共享动画。

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

const Card = ({ id, title, content }) => {
  return (
    <motion.div
      layoutId={id} // 关键!共享 ID
      style={{ 
        width: 200, 
        height: 200, 
        background: 'white', 
        padding: 20,
        borderRadius: 10,
        boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
      }}
      whileHover={{ scale: 1.05 }}
    >
      <h3>{title}</h3>
      <p>{content}</p>
    </motion.div>
  );
};

const Gallery = () => {
  const [selectedId, setSelectedId] = React.useState(null);

  return (
    <div style={{ display: 'flex', gap: 20, padding: 20 }}>
      <motion.div
        onClick={() => setSelectedId('card-1')}
        style={{ cursor: 'pointer' }}
      >
        <Card id="card-1" title="Card 1" content="点击我看看会发生什么" />
      </motion.div>

      <AnimatePresence>
        {selectedId && (
          <motion.div
            layoutId={selectedId} // 这里复用了 ID
            style={{ 
              position: 'fixed', 
              top: 0, left: 0, width: '100%', height: '100%', 
              background: 'rgba(0,0,0,0.5)', 
              display: 'flex', 
              justifyContent: 'center', 
              alignItems: 'center',
              zIndex: 100
            }}
            onClick={() => setSelectedId(null)}
          >
            <motion.div
              style={{ background: 'white', padding: 40, borderRadius: 20 }}
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
            >
              <h2>详情页面</h2>
              <p>这就是 layoutId 的魔力。卡片从列表中“飞”到了这里。</p>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
};

当你点击第一个卡片时,它不会消失,而是会缩放并移动到全屏遮罩层的位置。当你关闭遮罩层时,它会缩放回去,回到原来的位置。这一切都是自动的,因为它们共享了 layoutId


第四部分:组件生命周期的第三阶段——交互

组件不仅要有“出生”和“死亡”,它们还应该有“性格”。用户喜欢交互。当你把鼠标悬停在一个按钮上时,它应该有反应。当你点击它时,它应该有反馈。

Framer Motion 提供了一组 while 系列的属性,让你可以直接在 JSX 中绑定这些交互。

4.1 鼠标悬停与点击

import { motion } from 'framer-motion';

const Button = ({ children }) => {
  return (
    <motion.button
      whileHover={{ scale: 1.1, boxShadow: "0px 0px 20px rgba(0,0,0,0.3)" }}
      whileTap={{ scale: 0.95 }}
      whileFocus={{ scale: 1.05, borderColor: "blue" }}
      style={{
        padding: '10px 20px',
        fontSize: '16px',
        background: 'blue',
        color: 'white',
        border: 'none',
        borderRadius: '5px',
        cursor: 'pointer'
      }}
    >
      {children}
    </motion.button>
  );
};

whileHover:当鼠标移入时触发。
whileTap:当鼠标按下时触发。
whileFocus:当元素获得焦点时触发(比如按 Tab 键选中)。

这些属性本质上就是 onHoverStartonTaponFocus 的语法糖。它们让代码变得非常简洁。你不需要写一堆 useEffect 来监听事件。

4.2 拖拽

如果你想让用户直接在页面上拖拽元素,Framer Motion 也能轻松搞定。

const Draggable = () => {
  return (
    <motion.div
      drag
      dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
      style={{
        width: 100, height: 100, background: 'orange', borderRadius: 50
      }}
    >
      拖我!
    </motion.div>
  );
};

这行 drag 属性就开启了拖拽功能。dragConstraints 限制了拖拽的范围,防止你把元素拖出屏幕。这比你自己写触摸事件监听器要简单太多了。


第五部分:高级编排——Variants(变体)

随着动画变得复杂,直接在组件上写 initialanimateexit 会把代码弄得乱七八糟,就像在一堆乱麻里找针。这时候,我们需要 Variants(变体)

变体本质上就是状态机。你可以定义一组状态(比如 open, closed, hovered),然后在组件之间共享这些状态。

5.1 定义变体

const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.2 // 子元素依次出现
    }
  }
};

const itemVariants = {
  hidden: { y: 20, opacity: 0 },
  visible: {
    y: 0,
    opacity: 1,
    transition: { type: "spring", stiffness: 100 }
  }
};

5.2 应用变体

const Menu = () => {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
      style={{ listStyle: 'none', padding: 0 }}
    >
      <motion.li variants={itemVariants}>首页</motion.li>
      <motion.li variants={itemVariants}>关于我们</motion.li>
      <motion.li variants={itemVariants}>产品</motion.li>
      <motion.li variants={itemVariants}>联系我们</motion.li>
    </motion.ul>
  );
};

5.3 动态切换变体

有时候,你需要根据父组件的状态来切换变体。

const AccordionItem = ({ isOpen, title, children }) => {
  const contentVariants = {
    closed: { height: 0, opacity: 0 },
    open: { height: 'auto', opacity: 1 }
  };

  return (
    <motion.div
      initial={false}
      animate={isOpen ? "open" : "closed"}
      variants={contentVariants}
    >
      <h3>{title}</h3>
      <div>{children}</div>
    </motion.div>
  );
};

在这里,animate={isOpen ? "open" : "closed"} 告诉 Framer Motion:如果 isOpen 是 true,就用 open 变体;否则用 closed 变体。

这种声明式的方式,让动画逻辑和业务逻辑分离开来。你可以在父组件里管理状态,然后通过 props 把变体名传下去。代码变得非常干净。


第六部分:性能优化——别让动画拖垮你的浏览器

动画虽然好看,但它们是性能杀手。如果你在动画中使用了 top, left, width, height 等属性,浏览器会触发布局重排,导致动画卡顿。

Framer Motion 默认使用 transformopacity,这是 GPU 加速的最佳选择。但是,你仍然需要小心。

6.1 减少布局抖动

布局抖动是指动画开始时,元素突然跳到最终位置,然后才开始平滑过渡。这是因为浏览器在计算动画之前,先重新计算了布局。

Framer Motion 的 layout prop 会自动处理这个问题,但如果你手动操作了布局属性,就要小心了。

6.2 will-change 属性

你可以手动告诉浏览器某个元素即将发生变化,从而让浏览器提前做好优化。

<motion.div
  animate={{ x: 100 }}
  style={{ willChange: 'transform' }} // 提示浏览器优化 transform
>
  我会飞得很快
</motion.div>

但是,不要滥用这个属性。如果所有元素都标记为 will-change: transform,浏览器会分配过多的内存,导致页面变卡。只在那些真正需要高性能动画的元素上使用它。

6.3 useReducedMotion

有些用户可能对动画敏感,或者使用的是屏幕阅读器。我们应该尊重他们的选择。

import { useReducedMotion } from 'framer-motion';

const MyComponent = () => {
  const prefersReducedMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: prefersReducedMotion ? 0 : 1 }}
    >
      内容
    </motion.div>
  );
};

useReducedMotion 会检测系统设置。如果用户开启了“减少动画”,我们就直接跳过动画,或者设置一个非常短的持续时间。


第七部分:实战演练——构建一个动态购物车

好了,理论讲得够多了,让我们来点实战。我们要构建一个购物车,它具备以下功能:

  1. 添加商品:商品从购物车区域飞入列表。
  2. 删除商品:商品从列表飞出购物车区域。
  3. 数量变化:商品数量减少时,总价平滑更新。
  4. 布局动画:移除商品后,其他商品自动填补空缺。

这是一个非常经典的场景,涵盖了 Framer Motion 的所有核心概念。

import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

// 商品组件
const Product = ({ product, onRemove, layoutId }) => {
  return (
    <motion.div
      layout // 开启布局动画
      layoutId={layoutId} // 开启共享布局动画
      initial={{ opacity: 0, scale: 0.8 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.2 } }}
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        padding: 10,
        background: 'white',
        borderRadius: 8,
        marginBottom: 10,
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        width: '100%',
        maxWidth: 300
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <div style={{ width: 50, height: 50, background: '#eee', borderRadius: 4 }} />
        <div>
          <div style={{ fontWeight: 'bold' }}>{product.name}</div>
          <div style={{ color: 'gray' }}>${product.price}</div>
        </div>
      </div>
      <button
        onClick={() => onRemove(product.id)}
        style={{
          background: 'transparent',
          border: '1px solid red',
          color: 'red',
          padding: '5px 10px',
          borderRadius: 4,
          cursor: 'pointer'
        }}
      >
        移除
      </button>
    </motion.div>
  );
};

// 购物车组件
const ShoppingCart = () => {
  const [products, setProducts] = useState([
    { id: 1, name: '机械键盘', price: 500 },
    { id: 2, name: '无线鼠标', price: 100 },
    { id: 3, name: '显示器', price: 1200 },
  ]);

  const removeProduct = (id) => {
    setProducts(prev => prev.filter(p => p.id !== id));
  };

  const total = products.reduce((sum, p) => sum + p.price, 0);

  return (
    <div style={{ padding: 20, maxWidth: 400, margin: '0 auto' }}>
      <h2>我的购物车</h2>

      {/* 购物车列表区域 */}
      <div style={{ marginBottom: 20 }}>
        <AnimatePresence mode="popLayout"> {/* mode="popLayout" 防止布局抖动 */}
          {products.map(product => (
            <Product 
              key={product.id} 
              product={product} 
              onRemove={removeProduct} 
              layoutId={`product-${product.id}`} // 关键:共享 ID
            />
          ))}
        </AnimatePresence>
      </div>

      {/* 总价区域 */}
      <motion.div
        layout // 总价也会随着商品变化而动画
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          padding: 20,
          background: '#f8f8f8',
          borderRadius: 8,
          fontSize: 20,
          fontWeight: 'bold'
        }}
      >
        <span>总计:</span>
        <span>${total}</span>
      </motion.div>
    </div>
  );
};

export default ShoppingCart;

第八部分:深入探究——useMotionValue 与 useTransform

虽然 animate prop 很方便,但有时候你需要更精细的控制。比如,你想根据滚动位置来改变元素的大小,或者你想做一个复杂的物理效果。

这时候,你需要使用 Motion Values (运动值)

motion.divanimate prop 本质上就是一个简化的 useMotionValue。你可以创建自己的运动值,然后通过 useTransform 将它们映射到 CSS 属性上。

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

const ParallaxText = () => {
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  // 将 x 和 y 映射到 scale
  const scale = useTransform(x, [-200, 200], [0.8, 1.2]);
  const rotate = useTransform(y, [-200, 200], [0, 20]);

  return (
    <motion.div
      style={{
        x, y, scale, rotate,
        padding: 20,
        background: 'white',
        borderRadius: 10
      }}
      drag
      dragElastic={0.2}
    >
      拖动我!
      <p>我的缩放和旋转完全由你的拖动位置决定。</p>
    </motion.div>
  );
};

这里,我们创建了一个 xy 运动值,它们会随着拖动而改变。然后,我们通过 useTransform 创建了 scalerotate,它们是基于 xy 的函数。这种模式非常适合制作复杂的交互效果。

第九部分:总结与展望

我们今天聊了什么?

我们从 DOM 操作的痛苦中走来,发现了 Framer Motion 这位救星。我们学习了如何用 AnimatePresence 管理组件的进入和退出,如何用 layout prop 实现平滑的布局动画,如何用 variants 管理复杂的动画状态,以及如何用 motion values 进行底层的控制。

Framer Motion 的核心哲学是 “声明式动画编排”。它让动画成为 React 生态系统的一部分,而不是一个外挂的补丁。它让代码变得可预测、可维护,甚至可读。

当你下次想给按钮加个 hover 效果,或者给列表加个过渡动画时,不要再写 CSS 了。试试 Framer Motion。你会发现,写代码也可以是一种享受。

记住,动画不是为了炫技,而是为了沟通。好的动画能引导用户的注意力,提供反馈,让应用感觉更流畅、更自然。而 Framer Motion,就是实现这一切的最佳工具。

好了,今天的讲座就到这里。拿起你的键盘,去创造那些令人惊叹的动画吧!如果遇到问题,记得查阅文档,或者……再问我一次。

(完)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注