从补间到叙事: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); // → 0wrap 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 }); // 全部移到同一个位置