返回目录

从补间到叙事:GSAP 动画实战 · 公开阅读

第9章:GSAP Utility 方法

使用 wrap、clamp、random、mapRange 等工具处理动画数据。

本章小节

9.1 为什么需要 Utility 方法

实际项目中,动画参数很少是写死的——它们可能来自 DOM 属性、数组索引、用户输入。GSAP 的 Utility 方法封装了常见的数学转换操作,避免手写容易出错的表达式。

// 没有 Utility
gsap.to(".box", {
  x: Math.random() * 500,
  rotation: ((index % 360) + 360) % 360,  // 角度循环
});

// 使用 Utility
gsap.to(".box", {
  x: gsap.utils.random(0, 500),
  rotation: gsap.utils.wrap(0, 360, index),
});

9.2 gsap.utils.wrap()——跑道绕圈

wrap 就像一个环形跑道——不管你跑多远,位置永远在跑道上。跑完一圈,从头再算。最常见的场景是角度循环和颜色循环。

基本用法

gsap.utils.wrap(0, 100, 120);      // → 20  (120 超出了 100,折返回到 20)
gsap.utils.wrap(0, 100, -10);      // → 90  (-10 低于 0,循环到 90)
gsap.utils.wrap(0, 100, 50);       // → 50  (在范围内,不变)

数学上等价于:((value - min) % range + range) % range + min

wrap 函数的变体

// 创建可复用的 wrap 函数
const wrap360 = gsap.utils.wrap(0, 360);
wrap360(400);   // → 40
wrap360(-100);  // → 260
wrap360(720);   // → 0

const wrapArray = gsap.utils.wrap(["red", "green", "blue"]);
wrapArray(5);   // → "green" (5 % 3 = 2 → 索引 2 → "green")
wrapArray(0);   // → "red"
wrapArray(99);  // → "red" (99 % 3 = 0)

实战——角度循环旋转

// 每次都加 120 度,但 rotation 永远在 0~360 之间
const wrap360 = gsap.utils.wrap(0, 360);

document.querySelectorAll(".gear").forEach((gear, i) => {
  gsap.to(gear, {
    rotation: () => wrap360(Number(gsap.getProperty(gear, 'rotation')) + 120),
    // 而不是 rotation: "+=120"(会导致数值无限增长)
    duration: 2,
    ease: "power2.inOut",
  });
});

使用 gsap.getProperty() 获取属性;_gsap 是私有缓存,不应作为教程中的公开接口。

实战——颜色循环背景

const colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4"];
const wrapColor = gsap.utils.wrap(colors);

document.querySelectorAll(".card").forEach((card, i) => {
  gsap.set(card, { backgroundColor: wrapColor(i) });
});

// 点击按钮循环切换
let colorIndex = 0;
document.querySelector("#cycle-btn").addEventListener("click", () => {
  colorIndex++;
  gsap.to(document.body, {
    backgroundColor: wrapColor(colorIndex),
    duration: 0.5,
  });
});

9.3 gsap.utils.clamp()——区间截断

clamp 将数值限制在指定区间内,超出上限取上限,低于下限取下限:

gsap.utils.clamp(0, 100, 150);   // → 100  (超出上限,截断)
gsap.utils.clamp(0, 100, -20);   // → 0    (低于下限,截断)
gsap.utils.clamp(0, 100, 50);    // → 50   (在范围内,不变)

const clamp01 = gsap.utils.clamp(0, 1);
clamp01(0.5);   // → 0.5
clamp01(1.5);   // → 1
clamp01(-0.3);  // → 0

wrap vs clamp

方法超出上限低于下限适用场景
wrap循环回到起点循环回到终点角度、颜色循环、轮播
clamp截断为上限截断为下限opacity 限制、坐标边界、百分比安全值

实战——安全的 opacity 设置

// opacity 必须保持在 0~1 之间
const safeOpacity = gsap.utils.clamp(0, 1);

function setOpacity(value) {
  return safeOpacity(value);
}

setOpacity(2);    // → 1
setOpacity(-0.3); // → 0
setOpacity(0.5);  // → 0.5

实战——限制拖拽边界

onDrag 不是普通 gsap.to() 的回调。拖拽输入应由 Observer、Draggable 或指针事件处理,再把位置限制在合法区间:

// Observer 已导入并注册;area 是局部拖拽区,box 是其中的元素。
let start = 0;
const observer = Observer.create({
  target: area, type: 'pointer',
  onPress() { start = Number(gsap.getProperty(box, 'x')); },
  onDrag(self) {
    const next = start + self.x - self.startX;
    gsap.set(box, { x: gsap.utils.clamp(0, maxX, next) });
  }
});
// 卸载时 observer.kill()。

不要用私有 _gsap 字段读取位置;使用公开的 gsap.getProperty()。

把这一节跑一遍:数值工具边界

clamp 截断,wrap 循环,snap 吸附,mapRange 做线性范围换算。wrap 的上边界不包含在区间内,而 clamp 可以输出上边界;mapRange 不自动限制到 0…1。

实验:数值工具边界

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:输入 125 应得到 clamp=100、wrap=25、snap=120、mapRange=1.25;输入 -25 再预测四个结果。

9.4 gsap.utils.random()——掷骰子

gsap.utils.random(0, 100);          // 0~100 之间随机小数
gsap.utils.random(-50, 50);         // -50~50 之间
gsap.utils.random(0, 100, 1);       // 仅整数
gsap.utils.random([1, 2, 3, 4, 5]); // 从数组中随机选一个
gsap.utils.random(["red", "blue"]);  // 随机颜色

// 创建可复用的"骰子"——每次掷的结果不同
const randX = gsap.utils.random(0, 500, 1);
randX();  // → 342
randX();  // → 87
randX();  // → 423

实战——粒子散布

const particles = document.querySelectorAll(".particle");

particles.forEach((p, i) => {
  gsap.to(p, {
    x: gsap.utils.random(-200, 200, 1),
    y: gsap.utils.random(-200, 200, 1),
    scale: gsap.utils.random(0.5, 2),
    rotation: gsap.utils.random(0, 360),
    opacity: gsap.utils.random(0.2, 1),
    duration: gsap.utils.random(0.5, 2),
    delay: gsap.utils.random(0, 0.5),
    ease: "power2.out",
  });
});

实战——随机颜色闪烁

const randColor = gsap.utils.random(["#FF6B6B", "#4ECDC4", "#FFD93D", "#6C5CE7"]);

gsap.to(".blink-box", {
  backgroundColor: () => randColor(),
  duration: 0.3,
  repeat: 10,
  ease: "none",
});

9.5 gsap.utils.mapRange()——"温度计换算"

mapRange 把数值从一个范围映射到另一个范围,就像把华氏度换算成摄氏度——比例关系不变,只变量纲。

// 将 0~100 映射到 0~1
gsap.utils.mapRange(0, 100, 0, 1, 50);    // → 0.5
gsap.utils.mapRange(0, 100, 0, 1, 0);     // → 0
gsap.utils.mapRange(0, 100, 0, 1, 100);   // → 1

// 将 0~500 映射到 -1~1
gsap.utils.mapRange(0, 500, -1, 1, 250);  // → 0

// 将鼠标位置映射到旋转角度
gsap.utils.mapRange(0, window.innerWidth, -45, 45, mouseX);

创建可复用的映射函数

const mapScrollToProgress = gsap.utils.mapRange(0, 1000, 0, 1);

window.addEventListener("scroll", () => {
  const progress = mapScrollToProgress(window.scrollY);
  // progress 从 0(起始)到 1(滚动 1000px)
  document.querySelector(".progress").style.width = `${progress * 100}%`;
});

实战——鼠标位置驱动元素旋转

document.addEventListener("mousemove", (e) => {
  const mapXToRotation = gsap.utils.mapRange(
    0, window.innerWidth,   // 鼠标 X 范围
    -20, 20                  // 映射到旋转角度 -20° ~ 20°
  );

  const mapYToScale = gsap.utils.mapRange(
    0, window.innerHeight,  // 鼠标 Y 范围
    0.9, 1.1                // 映射到缩放 0.9 ~ 1.1
  );

  gsap.to(".card", {
    rotationY: mapXToRotation(e.clientX),
    scale: mapYToScale(e.clientY),
    duration: 0.3,
  });
});

9.6 gsap.utils.interpolate()——两点之间连线

interpolate 根据 0~1 的进度值,在两个值之间做线性插值,支持数字、颜色、字符串:

gsap.utils.interpolate(0, 100, 0.5);      // → 50
gsap.utils.interpolate(0, 100, 0.25);     // → 25
gsap.utils.interpolate("#ff0000", "#0000ff", 0.5);  // → "rgba(128, 50, 128, 1)" 紫色
gsap.utils.interpolate("0px", "100px", 0.5);        // → "50px"

// 创建可复用插值函数
const lerp = gsap.utils.interpolate(0, 100);
lerp(0);     // → 0
lerp(0.5);   // → 50
lerp(1);     // → 100

实战——颜色渐变补间

const colorLerp = gsap.utils.interpolate("#ff0000", "#0000ff");

for (let i = 0; i <= 10; i++) {
  const t = i / 10;
  console.log(colorLerp(t));  // 从红到蓝的 11 个渐变色
}

实战——动态渐变背景

const lerpC1 = gsap.utils.interpolate("#a8edea", "#fed6e3");
const lerpC2 = gsap.utils.interpolate("#ffd1ff", "#a1c4fd");

let progress = 0;
gsap.to({ val: 0 }, {
  val: 1,
  duration: 4,
  repeat: -1,
  yoyo: true,
  ease: "sine.inOut",
  onUpdate() {
    document.body.style.background =
      `linear-gradient(135deg, ${lerpC1(this.targets()[0].val)}, ${lerpC2(this.targets()[0].val)})`;
  }
});

把这一节跑一遍:颜色插值

interpolate 返回颜色插值函数;动画更新普通对象 p,再把 color(p) 写到元素背景。颜色字符串的插值和物理感知上的色差不是同一尺度。

实验:颜色插值

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:定位到 0%、50%、100%,核对 RGB 数值。随后思考如何把 clamp 接在输入前,避免交互产生超出范围的颜色参数。

9.7 gsap.utils.snap()——格子对齐

snap 将数值吸附到最近的格点或数组元素上:

gsap.utils.snap(10, 23);     // → 20  (最近的 10 的倍数)
gsap.utils.snap(10, 26);     // → 30
gsap.utils.snap(0.5, 1.3);   // → 1.5
gsap.utils.snap([0, 30, 60, 90, 180], 85);  // → 90 (最近的数组值)

// 创建可复用函数
const snap10 = gsap.utils.snap(10);
snap10(23);  // → 20
snap10(28);  // → 30

实战——拖拽吸附

const snap50 = gsap.utils.snap(50);

Draggable.create(".block", {
  onDragEnd() {
    gsap.to(this.target, {
      x: snap50(this.x),
      y: snap50(this.y),
      duration: 0.3,
      ease: "power2.out",
    });
  }
});
// 拖放后自动吸附到最近的 50px 网格点

实战——角度吸附(0° 45° 90°)

const snapAngle = gsap.utils.snap([0, 45, 90, 135, 180, 225, 270, 315, 360]);

gsap.to(".knob", {
  rotation: () => snapAngle(currentAngle),
  duration: 0.5,
  ease: "elastic.out(1, 0.3)",
});

9.8 gsap.utils.toArray()——选择器转数组

把 querySelectorAll 的结果转成真正的数组,以便使用 forEach、map、filter 等数组方法:

// 原生写法——繁琐
const items = Array.from(document.querySelectorAll(".item"));
// 或 [...document.querySelectorAll(".item")];

// gsap——简洁
const items = gsap.utils.toArray(".item");

// 同时也支持 NodeList、HTMLCollection、jQuery 对象等
const items = gsap.utils.toArray($(".item"));        // jQuery
const items = gsap.utils.toArray(myNodeList);        // NodeList
const items = gsap.utils.toArray(document.forms);    // HTMLCollection

实战——批量创建动画

const cards = gsap.utils.toArray(".card");

cards.forEach((card, i) => {
  gsap.from(card, {
    y: 50,
    opacity: 0,
    delay: i * 0.1,
  });
});

9.9 实战综合示例

示例一:角度循环旋转

const wrap360 = gsap.utils.wrap(0, 360);

gsap.to(".ring", {
  rotation: "+=1440",  // 4 圈
  duration: 6,
  ease: "power3.inOut",
  onUpdate() {
    const rawRotation = gsap.getProperty(this.targets()[0], "rotation");
    console.log(wrap360(rawRotation));  // 始终在 0~360 之间
  }
});

示例二:颜色渐变循环

const colors = ["#f6d365", "#fda085", "#f093fb", "#4facfe", "#43e97b"];
const wrapColor = gsap.utils.wrap(colors);

let index = 0;
setInterval(() => {
  index++;
  gsap.to("body", {
    backgroundColor: wrapColor(index),
    duration: 2,
  });
}, 3000);

示例三:数值区间映射——进度动画

const mapToProgress = gsap.utils.mapRange(0, 1000, 0, 1);
const clampProgress = gsap.utils.clamp(0, 1);

function updateProgress(scrollY) {
  const raw = mapToProgress(scrollY);
  const safe = clampProgress(raw);
  document.querySelector(".bar").style.transform = `scaleX(${safe})`;
}

window.addEventListener("scroll", () => updateProgress(window.scrollY));

示例四:Utility 组合——复杂动画参数生成

const randX = gsap.utils.random(-300, 300, 1);
const randDelay = gsap.utils.random(0, 0.5);
const wrapColor = gsap.utils.wrap(["#F44336", "#E91E63", "#9C27B0", "#673AB7", "#3F51B5"]);

gsap.fromTo(".bubble",
  { scale: 0, opacity: 0 },
  {
    scale: 1,
    opacity: 1,
    x: () => randX(),
    backgroundColor: (i) => wrapColor(i),
    duration: gsap.utils.random(0.8, 2),
    delay: (i) => i * randDelay(),
    stagger: { amount: 1.5 },
    ease: "back.out(2)",
  }
);

9.10 常见陷阱

陷阱一:wrap 和 clamp 别弄混

现象:用了 wrap 但期望的是 clamp 的行为,反之亦然。

// wrap:循环——超出上限折回起点
gsap.utils.wrap(0, 100, 120);   // → 20
gsap.utils.wrap(0, 100, -10);   // → 90

// clamp:截断——超出上限等于上限
gsap.utils.clamp(0, 100, 120);  // → 100
gsap.utils.clamp(0, 100, -10);  // → 0
使用场景应该用
角度(-180° 和 180° 应该是同一个位置)wrap
透明度(不能低于 0,不能高于 1)clamp
轮播图索引wrap
进度条百分比clamp
颜色循环(红→蓝→红)wrap
地图缩放级别clamp

陷阱二:mapRange 方向反转

现象:映射结果和预期方向相反。

原因:mapRange(inMin, inMax, outMin, outMax, value)——当 outMin > outMax 时,映射会反转。

// 常规:0 → 0, 100 → 1
gsap.utils.mapRange(0, 100, 0, 1, 50);   // → 0.5

// 反转:0 → 1, 100 → 0
gsap.utils.mapRange(0, 100, 1, 0, 50);   // → 0.5
gsap.utils.mapRange(0, 100, 1, 0, 0);    // → 1
gsap.utils.mapRange(0, 100, 1, 0, 100);  // → 0

这个特性可用于实现反向映射——例如滚动越往下,元素越透明:

// 滚动越往下,元素越透明(反转映射)
const mapScrollToOpacity = gsap.utils.mapRange(0, 1000, 1, 0);
// 滚动 0 → opacity 1
// 滚动 1000 → opacity 0

陷阱三:toArray 匹配不到元素不报错

现象:用 gsap.utils.toArray(".non-existent") 得到空数组 [],后续 .forEach 静默跳过——什么都不发生,也不报错。

// 不报错,但也不做任何事——可能导致调试困难
const items = gsap.utils.toArray(".card");
gsap.from(items, { opacity: 0, stagger: 0.1 });
// 如果没有 .card 元素,什么都不会发生

建议:在关键场景下验证一下:

const items = gsap.utils.toArray(".card");
if (items.length === 0) {
  console.warn("No .card elements found for animation");
  return;
}

陷阱四:wrap 数组——负数索引不是 clamp

现象:用 wrap 处理数组时,-1 不会变成 0,而是循环到了最后一个元素。

const colors = ["red", "green", "blue"];
const wrapColor = gsap.utils.wrap(colors);

wrapColor(-1);   // → "blue"  (从末尾向前)
wrapColor(-2);   // → "green"
wrapColor(3);    // → "red"   (回到开头)

如果需要负数索引返回 0,可以用 clamp 或手动处理:

const getColor = (idx) => colors[Math.max(0, Math.min(idx, colors.length - 1))];

陷阱五:random 函数每次调用都出新值

现象:期望所有元素得到同一个随机值,结果每个都不一样。

// 错误期望:所有 .box 得到同一个随机值
const sharedX = gsap.utils.random(0, 500)();

// 实际上每个回调执行时都生成新值
gsap.to(".box", {
  x: () => gsap.utils.random(0, 500)(),  // 每个 .box 各不相同
  stagger: 0.1,
});

如果希望所有元素共享同一个随机值,提前生成一次再传入:

const sharedX = gsap.utils.random(0, 500)();
gsap.to(".box", { x: sharedX });  // 全部移到同一个位置