一、什么是雪碧图动画?

1.1 雪碧图(Sprite Sheet)概念

雪碧图是将多个小图片合并成一张大图的技术。在游戏和动画领域,雪碧图常用于存储动画的每一帧。

┌─────┬─────┬─────┬─────┐
│ 帧1 │ 帧2 │ 帧3 │ 帧4 │  ← 第一行
├─────┼─────┼─────┼─────┤
│ 帧5 │ 帧6 │ 帧7 │ 帧8 │  ← 第二行
├─────┼─────┼─────┼─────┤
│ 帧9 │帧10 │帧11 │帧12 │  ← 第三行
└─────┴─────┴─────┴─────┘

1.2 为什么使用雪碧图?

  1. 减少 HTTP 请求:一张图代替多张图,减少网络开销
  2. 更流畅的动画:所有帧预加载完成后播放,避免卡顿
  3. 更小的文件体积:合并后的图片通常比单独图片总和更小

1.3 动画原理

通过 CSS background-position 属性,快速切换显示雪碧图的不同区域,形成动画效果。就像翻书动画一样,快速翻页就能看到连贯的动作。


二、组件设计思路

2.1 核心功能

  1. 加载雪碧图并自动计算帧数
  2. 生成 CSS 关键帧动画
  3. 支持自定义播放时长
  4. 播放完成后触发回调

2.2 Props 接口设计

interface ISpriteSheetAnimator {
  url: string;                // 雪碧图 URL
  width: number;              // 单帧宽度(px)
  height: number;             // 单帧高度(px)
  imageMultiplier?: number;   // 图片倍率(如 @2x、@3x)
  duration?: number;          // 播放总时长(毫秒)
  frameCount?: number;        // 总帧数(可选,不传则自动计算)
  onPlayComplete?: () => void; // 播放完成回调
}

2.3 状态设计

// 图片是否加载完成
const [isLoaded, setIsLoaded] = useState(false);
// 实际帧数
const [actualFrameCount, setActualFrameCount] = useState(0);
// 每行帧数(列数)
const [columns, setColumns] = useState(0);
// 总行数
const [rows, setRows] = useState(0);

三、实现步骤详解

3.1 第一步:创建组件骨架

import React, { memo, useEffect, useRef, useState } from "react";
import Style from "./index.module.less";

const SpriteSheetAnimator: React.FC<ISpriteSheetAnimator> = ({
  url = "",
  width,
  height,
  frameCount,
  duration = 3000,
  imageMultiplier = 3,
  onPlayComplete,
}) => {
  // 状态和逻辑将在这里实现
  return null;
};

export default memo(SpriteSheetAnimator);

3.2 第二步:预加载图片并计算帧数

这是组件的核心逻辑之一。我们需要:

  1. 创建一个 Image 对象加载雪碧图
  2. 根据图片尺寸和单帧尺寸计算行列数
  3. 计算总帧数
useEffect(() => {
  const img = new Image();

  img.onload = () => {
    const imgWidth = img.width;
    const imgHeight = img.height;

    // 计算列数:图片宽度 / 倍率 / 单帧宽度
    const calculatedColumns = Math.floor(imgWidth / imageMultiplier / width);
    // 计算行数:图片高度 / 倍率 / 单帧高度
    const calculatedRows = Math.floor(imgHeight / imageMultiplier / height);
    // 总帧数 = 列数 × 行数
    const calculatedFrames = calculatedColumns * calculatedRows;

    setColumns(calculatedColumns);
    setRows(calculatedRows);
    setActualFrameCount(frameCount || calculatedFrames);
    setIsLoaded(true);
  };

  img.onerror = () => {
    console.error("Failed to load sprite sheet:", url);
  };

  img.src = url;
}, [url, width, height, frameCount, imageMultiplier]);

关键点解释:

  • imageMultiplier:UI 设计师通常提供 2x 或 3x 的高清图,实际显示时需要除以倍率
  • 自动计算帧数:如果用户没有传入 frameCount,组件会自动根据图片尺寸计算

计算示例:

假设雪碧图尺寸为 1200×900px(3 倍图),单帧尺寸为 100×100px:

实际尺寸 = 1200/3 × 900/3 = 400×300px
列数 = 400 / 100 = 4
行数 = 300 / 100 = 3
总帧数 = 4 × 3 = 12

3.3 第三步:生成关键帧动画

这是最核心的部分。我们需要为每一帧生成对应的 background-position

const generateKeyframes = () => {
  if (actualFrameCount === 0 || columns === 0) return "";

  const keyframes: string[] = [];

  for (let i = 0; i < actualFrameCount; i++) {
    // 计算当前帧在时间轴上的百分比位置
    const percentage = (i / actualFrameCount) * 100;

    // 计算当前帧所在的行和列
    const currentRow = Math.floor(i / columns); // 第几行
    const currentCol = i % columns;             // 第几列

    // 计算背景位置(负值,因为是向左/上移动)
    const x = -currentCol * width;
    const y = -currentRow * height;

    keyframes.push(
      `${percentage.toFixed(2)}% { background-position: ${x}px ${y}px; }`
    );
  }

  // 添加 100% 关键帧,确保动画结束在最后一帧
  const lastRow = Math.floor((actualFrameCount - 1) / columns);
  const lastCol = (actualFrameCount - 1) % columns;
  keyframes.push(
    `100% { background-position: ${-lastCol * width}px ${-lastRow * height}px; }`
  );

  return keyframes.join("\n");
};

帧位置计算图解:

假设有 12 帧,4 列 3 行,单帧 100×100px:

帧索引 i=0:  行=0, 列=0  →  position: 0px 0px
帧索引 i=1:  行=0, 列=1  →  position: -100px 0px
帧索引 i=2:  行=0, 列=2  →  position: -200px 0px
帧索引 i=3:  行=0, 列=3  →  position: -300px 0px
帧索引 i=4:  行=1, 列=0  →  position: 0px -100px
帧索引 i=5:  行=1, 列=1  →  position: -100px -100px
...

3.4 第四步:处理动画结束回调

使用 useRef 保存回调函数,确保在动画结束时能获取到最新的回调。

const animationEndRef = useRef<(() => void) | null>(null);

// 保持回调函数引用最新
useEffect(() => {
  animationEndRef.current = onPlayComplete || null;
}, [onPlayComplete]);

// 动画结束处理函数
const handleAnimationEnd = () => {
  if (animationEndRef.current) {
    animationEndRef.current();
  }
};

为什么用 useRef?

如果直接在 onAnimationEnd 中调用 onPlayComplete,可能会因为闭包问题获取到旧的回调函数。使用 useRef 可以确保始终调用最新的回调。

3.5 第五步:渲染组件

// 计算雪碧图总尺寸(用于 background-size)
const spriteSheetWidth = columns * width;
const spriteSheetHeight = rows * height;

// 动态生成样式
const animationStyle: React.CSSProperties = {
  width: `${width}px`,
  height: `${height}px`,
  backgroundImage: `url(${url})`,
  backgroundRepeat: "no-repeat",
  backgroundPosition: "0 0",
  backgroundSize: `${spriteSheetWidth}px ${spriteSheetHeight}px`,
  animation:
    isLoaded && actualFrameCount > 0
      ? `spriteAnimation ${duration}ms steps(1) forwards`
      : "none",
};

return (
  <>
    {/* 隐藏的图片元素,用于获取图片尺寸 */}
    <img ref={imgRef} src={url} className={Style.realImg} alt="sprite sheet" />

    {/* 动态注入关键帧样式 */}
    {isLoaded && actualFrameCount > 0 && (
      <style>
        {`@keyframes spriteAnimation {
          ${generateKeyframes()}
        }`}
      </style>
    )}

    {/* 动画容器 */}
    <div
      ref={containerRef}
      className={Style.spriteContainer}
      style={animationStyle}
      onAnimationEnd={handleAnimationEnd}
    />
  </>
);

关键 CSS 属性解释:

  • steps(1):逐帧动画,每个关键帧之间没有过渡效果
  • forwards:动画结束后保持最后一帧的状态
  • background-size:将雪碧图缩放到正确的尺寸(处理高清图)

3.6 第六步:样式文件

// index.module.less

.realImg {
  // 隐藏用于获取尺寸的图片
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
  pointer-events: none;
}

.spriteContainer {
  // 动画容器基础样式
  display: block;
}

四、完整代码

import React, { memo, useEffect, useRef, useState } from "react";
import Style from "./index.module.less";

interface ISpriteSheetAnimator {
  url: string;
  width: number;
  height: number;
  imageMultiplier?: number;
  duration?: number;
  frameCount?: number;
  onPlayComplete?: () => void;
}

const SpriteSheetAnimator: React.FC<ISpriteSheetAnimator> = ({
  url = "",
  width,
  height,
  frameCount,
  duration = 3000,
  imageMultiplier = 3,
  onPlayComplete,
}) => {
  const imgRef = useRef<HTMLImageElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const animationEndRef = useRef<(() => void) | null>(null);

  const [isLoaded, setIsLoaded] = useState(false);
  const [actualFrameCount, setActualFrameCount] = useState(frameCount || 0);
  const [columns, setColumns] = useState(0);
  const [rows, setRows] = useState(0);

  // 保持回调引用最新
  useEffect(() => {
    animationEndRef.current = onPlayComplete || null;
  }, [onPlayComplete]);

  // 预加载图片并计算帧数
  useEffect(() => {
    const img = new Image();
    img.onload = () => {
      const imgWidth = img.width || imgRef.current?.width || 0;
      const imgHeight = img.height || imgRef.current?.height || 0;

      const calculatedColumns = Math.floor(imgWidth / imageMultiplier / width);
      const calculatedRows = Math.floor(imgHeight / imageMultiplier / height);

      if (!frameCount) {
        setActualFrameCount(calculatedColumns * calculatedRows);
      } else {
        setActualFrameCount(frameCount);
      }

      setColumns(calculatedColumns);
      setRows(calculatedRows);
      setIsLoaded(true);
    };
    img.onerror = () => console.error("Failed to load sprite sheet:", url);
    img.src = url;
  }, [url, width, height, frameCount, imageMultiplier]);

  // 生成关键帧
  const generateKeyframes = () => {
    if (actualFrameCount === 0 || columns === 0) return "";

    const keyframes: string[] = [];

    for (let i = 0; i < actualFrameCount; i++) {
      const percentage = (i / actualFrameCount) * 100;
      const currentRow = Math.floor(i / columns);
      const currentCol = i % columns;
      const x = -currentCol * width;
      const y = -currentRow * height;
      keyframes.push(
        `${percentage.toFixed(2)}% { background-position: ${x}px ${y}px; }`
      );
    }

    const lastRow = Math.floor((actualFrameCount - 1) / columns);
    const lastCol = (actualFrameCount - 1) % columns;
    keyframes.push(
      `100% { background-position: ${-lastCol * width}px ${-lastRow * height}px; }`
    );

    return keyframes.join("\n");
  };

  const handleAnimationEnd = () => {
    animationEndRef.current?.();
  };

  if (!url) return null;

  const spriteSheetWidth = columns * width;
  const spriteSheetHeight = rows * height;

  const animationStyle: React.CSSProperties = {
    width: `${width}px`,
    height: `${height}px`,
    backgroundImage: `url(${url})`,
    backgroundRepeat: "no-repeat",
    backgroundPosition: "0 0",
    backgroundSize: `${spriteSheetWidth}px ${spriteSheetHeight}px`,
    animation:
      isLoaded && actualFrameCount > 0
        ? `spriteAnimation ${duration}ms steps(1) forwards`
        : "none",
  };

  return (
    <>
      <img
        ref={imgRef}
        src={url}
        className={Style.realImg}
        alt="sprite sheet"
      />
      {isLoaded && actualFrameCount > 0 && (
        <style>{`@keyframes spriteAnimation { ${generateKeyframes()} }`}</style>
      )}
      <div
        ref={containerRef}
        className={Style.spriteContainer}
        style={animationStyle}
        onAnimationEnd={handleAnimationEnd}
      />
    </>
  );
};

export default memo(SpriteSheetAnimator);

五、使用示例

5.1 基础用法

import SpriteSheetAnimator from "@/components/SpriteSheetAnimator";

const MyComponent = () => {
  return (
    <SpriteSheetAnimator
      url="https://cdn.example.com/animation-sprite.png"
      width={100}
      height={100}
      duration={2000}
    />
  );
};

5.2 带回调的用法

const MyComponent = () => {
  const handleComplete = () => {
    console.log("动画播放完成!");
    // 可以在这里触发下一步操作
  };

  return (
    <SpriteSheetAnimator
      url="https://cdn.example.com/animation-sprite.png"
      width={100}
      height={100}
      duration={3000}
      onPlayComplete={handleComplete}
    />
  );
};

5.3 指定帧数

当雪碧图最后一行没有填满时,需要手动指定帧数:

// 假设雪碧图是 4×3 布局,但实际只有 10 帧(最后一行只有 2 帧)
<SpriteSheetAnimator
  url="https://cdn.example.com/animation-sprite.png"
  width={100}
  height={100}
  frameCount={10} // 手动指定实际帧数
  duration={2000}
/>

六、常见问题与优化

6.1 动画卡顿

原因:图片未加载完成就开始播放

解决:组件已通过 isLoaded 状态控制,确保图片加载完成后才播放

6.2 高清屏适配

问题:在高清屏上图片模糊

解决:使用 imageMultiplier 参数,UI 提供 2x 或 3x 图片

6.3 内存优化

建议

  • 组件卸载时清理动画
  • 大型雪碧图考虑懒加载
  • 不在视口内时暂停动画

6.4 性能优化建议

// 使用 memo 避免不必要的重渲染
export default memo(SpriteSheetAnimator);

// 使用 useCallback 缓存回调
const handleComplete = useCallback(() => {
  // 处理逻辑
}, []);

七、扩展思考

7.1 可以添加的功能

  1. 暂停/继续:通过 animation-play-state 控制
  2. 循环播放:将 forwards 改为 infinite
  3. 播放速度控制:动态修改 duration
  4. 反向播放:使用 animation-direction: reverse

7.2 替代方案对比

方案优点缺点
CSS 雪碧图动画性能好,兼容性强不支持复杂动画
Lottie支持复杂动画,文件小需要 AE 导出
GIF简单易用文件大,不支持透明度渐变
Canvas灵活度高实现复杂,性能消耗大

八、总结

本文详细介绍了如何实现一个 React 雪碧图动画组件,核心要点:

  1. 图片预加载:确保动画流畅
  2. 自动计算帧数:减少使用成本
  3. 动态生成关键帧:支持任意行列的雪碧图
  4. 回调处理:使用 useRef 避免闭包陷阱

希望这篇文章能帮助你理解雪碧图动画的实现原理,并能在实际项目中灵活运用。