从补间到叙事:GSAP 动画实战 · 公开阅读
第12章 交互式数据可视化动画
制作柱状图、环形进度与数字变化等数据可视化动画。
本章小节
难度:★★★☆☆ | 用到的GSAP能力:stagger、SVG动画、对象属性动画、Timeline编排 | 项目类型:数据仪表盘
12.1 需求分析
数据仪表盘的动画需求与传统页面不同——动画不是"装饰",而是"信息传递"本身:
- 柱状图增长动画:数据柱从0增长到目标高度,配合stagger制造节奏感
- 环形进度条:SVG圆环从空到满的弧线动画,直观展示完成度
- 数字跳动:数据从0快速跳动到目标值,强化数据冲击力
- 交互切换:点击按钮切换不同数据集,动画平滑过渡
柱子、圆圈和数字,动起来比不动的更有说服力。本章实现一个完整的交互式数据仪表盘组件。
12.2 柱状图动画
原理
柱状图本质是一组 div,高度由数据决定。动画的核心是让高度从0增长到目标值:
// 数据
const data = [65, 42, 88, 55, 73, 91, 38];
// 为每个数据创建柱子
data.forEach((value, i) => {
const bar = document.createElement('div');
bar.className = 'bar';
bar.style.setProperty('--target-height', value + '%');
chartContainer.appendChild(bar);
});
// 动画:所有柱子从高度0增长到目标值
gsap.from('.bar', {
height: '0%',
stagger: 0.08, // 每个柱子间隔0.08秒
duration: 0.8,
ease: 'back.out(1.2)', // 弹性效果,末端略超过后回弹
transformOrigin: 'bottom center'
});CSS变量驱动 vs 直接动画height
/* 方案A:直接动画height —— 简单但有性能问题 */
.bar {
height: 0%;
background: var(--accent);
transition: height 0.8s ease;
}
/* 方案B:用scaleY代替height —— GPU加速,性能更好 */
.bar {
height: 100%; /* 实际高度由容器决定 */
background: var(--accent);
transform: scaleY(0); /* 初始缩放到0 */
transform-origin: bottom center; /* 从底部开始缩放 */
}推荐方案B。transform 操作由GPU合成器处理,不触发布局重排。方案A每帧修改 height 都会触发重排。改height浏览器得重新画整页,改transform只需要GPU挪一下——同样效果,后者快得多。
// 方案B的动画
gsap.from('.bar', {
scaleY: 0,
stagger: 0.08,
duration: 0.8,
ease: 'back.out(1.2)'
});但问题来了:每个柱子的目标高度不同(65%、42%等),而 scaleY 是相对于元素自身的。需要将 scaleY: 0 → 1 与柱子的实际高度绑定。解决方法:用CSS变量设置高度,transform: scaleY(1) 展开到目标高度。
完整柱状图
const barData = [65, 42, 88, 55, 73, 91, 38];
function createBarChart(container, data) {
container.innerHTML = '';
data.forEach(val => {
const barWrap = document.createElement('div');
barWrap.className = 'bar-wrap';
const bar = document.createElement('div');
bar.className = 'bar';
bar.style.height = val + '%'; // 实际高度
const label = document.createElement('span');
label.className = 'bar-value';
label.textContent = '0'; // 初始显示0
bar.appendChild(label);
barWrap.appendChild(bar);
container.appendChild(barWrap);
});
// 柱子弹入
gsap.from(container.querySelectorAll('.bar'), {
scaleY: 0,
duration: 0.8,
stagger: 0.06,
ease: 'back.out(1.4)',
transformOrigin: 'bottom center'
});
// 数字跳动:每个柱子的数值标签从0到目标值
container.querySelectorAll('.bar').forEach((bar, i) => {
const label = bar.querySelector('.bar-value');
gsap.to({ val: 0 }, {
val: data[i],
duration: 1,
delay: i * 0.06 + 0.3,
ease: 'power2.out',
onUpdate: function() {
label.textContent = Math.round(this.targets()[0].val);
}
});
});
}柱子先"长出来",数字再跟着"跳到目标值"——两个动画一前一后,节奏分明。
把这一节跑一遍:柱状图与数据切换
四根柱有固定基准高度;scaleY=value/100。标签位于柱体之外,避免一起缩放。点击切换会直接从当前原始值到新值过渡,没有“先归零”步骤。
实验:柱状图与数据切换
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
点击播放即可执行第一组数据过渡;进度条控制柱体与数值的同一时间线。中途再次切换会从当前值开始,暂停会同时停住柱体和数字。
观察与验证:连续快速切换数据,观察中途是否跳回 0。含 0 与 100 的数据用于检查最小最大边界;对非百分比数据先明确统一最大值。
12.3 环形进度条
SVG stroke-dasharray 原理
环形进度条的核心原理就两个CSS属性:stroke-dasharray 和 stroke-dashoffset。
<svg viewBox="0 0 200 200">
<circle cx="100" cy="100" r="85"
fill="none" stroke="#eee" stroke-width="12" />
<circle cx="100" cy="100" r="85"
fill="none" stroke="var(--accent)" stroke-width="12"
stroke-linecap="round"
class="progress-ring" />
</svg>圆周长 circumference = 2 × π × r = 2 × 3.14159 × 85 ≈ 534.07
const radius = 85;
const circumference = 2 * Math.PI * radius; // ≈ 534.07
const ring = document.querySelector('.progress-ring');
ring.style.strokeDasharray = circumference; // 虚线总长
ring.style.strokeDashoffset = circumference; // 偏移量 = 总长 → 完全隐藏stroke-dasharray: 534 → 将圆周长设为虚线的一个周期
stroke-dashoffset: 534 → 将虚线向前偏移一个周期长度 → 视觉上完全不可见
用虚线画一个圈,但这个虚线太长,一整圈只有一个"实线段"(刚好是整圈的长度)。然后把这个实线段往前推了一整个周期的距离——推到看不见的地方去了,圈就消失了。动画就是逐步把这个实线段"拉回来"。
动画到目标值(如73%):
const targetPercent = 73;
const targetOffset = circumference - (targetPercent / 100) * circumference;
gsap.to(ring, {
strokeDashoffset: targetOffset,
duration: 1.5,
ease: 'power3.out'
});关键公式:
targetOffset = circumference × (1 - percent/100)
- 0%:offset = circumference(完全隐藏)
- 50%:offset = circumference × 0.5(显示一半)
- 100%:offset = 0(完全显示)
对比:手动计算 vs GSAP直接动画
// ❌ 手动插值(繁琐)
let start = null;
function animate(timestamp) {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / 1500, 1);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
ring.style.strokeDashoffset = circumference * (1 - eased * 0.73);
if (progress < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// ✅ GSAP(一行)
gsap.to(ring, {
strokeDashoffset: circumference * (1 - 0.73),
duration: 1.5,
ease: 'power3.out'
});自己写要十几行,GSAP一行搞定——这差距足够明显了。
把这一节跑一遍:环形图描边
半径为 70,周长 2πr≈439.82。offset=周长×(1-p),strokeDasharray 固定为周长。显示数字和环形读取同一 p,避免两套 Tween 不同步。
实验:环形图描边
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:目标设 50%,播放到终点,应看到半环和 offset≈219.91;拖动总进度时区分动画进度与目标百分比。
12.4 数字跳动
方案对比
方案A:setInterval 粗暴递增
// ❌ 固定帧率,不可控、不平滑
let current = 0;
const interval = setInterval(() => {
current += 1;
el.textContent = current;
if (current >= target) clearInterval(interval);
}, 20);方案B:GSAP 对象属性动画
// ✅ 精确可控,支持各种缓动
gsap.to({ val: 0 }, {
val: 73,
duration: 1.5,
ease: 'power2.out',
onUpdate: function() {
el.textContent = Math.round(this.targets()[0].val);
}
});GSAP方案的优势:
- 缓动函数自动处理非匀速变化(先快后慢 vs 匀速)——数字开头跑得快,接近目标时优雅减速
- duration精确可控
- 可以嵌套在Timeline中
- JavaScript 使用浮点数,仍存在精度误差;计算与显示格式化应分离
setInterval是"每隔固定时间+1",僵硬;GSAP是"从0滑到73,该怎么滑听缓动函数的"。
精度处理
// 场景1:整数跳变(市场份额 0→73%)
onUpdate: function() {
el.textContent = Math.round(this.targets()[0].val) + '%';
}
// 场景2:一位小数(得分 0→95.8)
onUpdate: function() {
el.textContent = this.targets()[0].val.toFixed(1);
}
// 场景3:两位小数(金融场景 利率 0→4.25%)
onUpdate: function() {
el.textContent = this.targets()[0].val.toFixed(2) + '%';
}把这一节跑一遍:普通对象补间
浮点数是连续变化的,显示取整只是格式化。要显示两位小数,应把格式化放在输出端;不要认为 Tween 可以消除 JavaScript 的浮点精度问题。
实验:普通对象补间
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:以低速拖动进度,检查原始浮点值如何跨越整数阈值。业务金额可用整数分作为计算单位,再格式化为元。
12.5 交互切换:数据集过渡
FLIP 原理(简化版)
数值变化与布局变化应分别处理。柱子保持同一 DOM、只改变高度比例时,直接从当前值补间到新值即可;不用归零,也不需要 Flip。
const values = oldData.map(value => ({ value }));
function changeData(nextData) {
values.forEach((item,i) => gsap.to(item, {
value: nextData[i], duration:0.6, overwrite:'auto',
onUpdate() {
bars[i].style.transform = `scaleY(${item.value / 100})`;
labels[i].textContent = Math.round(item.value);
}
}));
}柱体有固定基准高度,transformOrigin 为 bottom。标签放在柱体外,避免跟着 scaleY 压缩。反复快速切换时 overwrite:auto 中断同一数值上的旧 Tween,新 Tween 从当前值开始。
当数据变化同时导致卡片重排、顺序改变或元素换到另一个容器时,才需要 Flip:先 getState,改变布局,再 Flip.from。第15章提供实际布局切换演示。
12.6 完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>从补间到叙事:GSAP 动画实战 - 第12章:交互式数据可视化动画</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--accent: #6C63FF;
--accent2: #4ECDC4;
--bg: #0D1117;
--card-bg: #161B22;
--border: #30363D;
--text: #E6EDF3;
--text-dim: #8B949E;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
/* === 仪表盘容器 === */
.dashboard {
width: 100%;
max-width: 960px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 1.5rem;
}
.dashboard-title {
grid-column: 1 / -1;
font-size: 1.5rem;
font-weight: 700;
text-align: center;
margin-bottom: 0.5rem;
}
/* === 卡片 === */
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
}
.card h3 {
font-size: 1rem;
color: var(--text-dim);
font-weight: 500;
margin-bottom: 1.5rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* === 柱状图 === */
.bar-chart {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 260px;
gap: 0.5rem;
padding: 1rem 0;
}
.bar-wrap {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.bar {
width: 100%;
border-radius: 8px 8px 0 0;
background: linear-gradient(180deg, var(--accent), rgba(108,99,255,0.3));
position: relative;
transform-origin: bottom center;
min-height: 4px; /* 即使数据为0也有最小高度 */
}
.bar-value {
position: absolute;
top: -28px;
left: 50%;
transform: translateX(-50%);
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.bar-label {
text-align: center;
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.5rem;
}
/* 第二数据集用的颜色 */
.bar.alt-color {
background: linear-gradient(180deg, var(--accent2), rgba(78,205,196,0.3));
}
/* === 环形进度条 === */
.ring-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.ring-svg {
width: 200px;
height: 200px;
transform: rotate(-90deg); /* 从12点方向开始 */
}
.progress-bg {
fill: none;
stroke: var(--border);
stroke-width: 12;
}
.progress-ring {
fill: none;
stroke: var(--accent);
stroke-width: 12;
stroke-linecap: round;
transition: stroke 0.3s;
}
.ring-center-text {
position: absolute;
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ring-label {
font-size: 0.9rem;
color: var(--text-dim);
}
/* === 数据切换按钮 === */
.controls {
grid-column: 1 / -1;
display: flex;
gap: 1rem;
justify-content: center;
}
.data-btn {
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.data-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.data-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* === 统计卡片 === */
.stats-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
text-align: center;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-variant-numeric: tabular-nums;
}
.stat-card .stat-label {
font-size: 0.85rem;
color: var(--text-dim);
margin-top: 0.4rem;
}
</style>
</head>
<body>
<div class="dashboard">
<h2 class="dashboard-title">季度销售数据仪表盘</h2>
<!-- 柱状图卡片 -->
<div class="card" style="grid-column: 1 / -1;">
<h3>月度营收(万元)</h3>
<div class="bar-chart" id="barChart"></div>
<div class="bar-labels" id="barLabels" style="display: flex; justify-content: space-around; padding: 0 0.5rem;"></div>
</div>
<!-- 环形进度卡片 -->
<div class="card">
<h3>目标完成率</h3>
<div class="ring-container">
<div style="position: relative; display: flex; align-items: center; justify-content: center;">
<svg class="ring-svg" viewBox="0 0 200 200">
<circle class="progress-bg" cx="100" cy="100" r="85"/>
<circle class="progress-ring" id="progressRing" cx="100" cy="100" r="85"/>
</svg>
<span class="ring-center-text" id="ringPercent">0%</span>
</div>
<span class="ring-label">年度目标进度</span>
</div>
</div>
<!-- 季度对比卡片 -->
<div class="card">
<h3>季度对比</h3>
<div class="bar-chart" id="quarterChart"></div>
</div>
<!-- 统计数字卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value" id="statRevenue">0</div>
<div class="stat-label">总营收(万元)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statOrders">0</div>
<div class="stat-label">总订单数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statGrowth">0%</div>
<div class="stat-label">同比增长</div>
</div>
</div>
<!-- 数据切换按钮 -->
<div class="controls">
<button class="data-btn active" data-set="0">2025 Q1</button>
<button class="data-btn" data-set="1">2025 Q2</button>
<button class="data-btn" data-set="2">2025 Q3</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ============================================
// 数据集
// ============================================
const datasets = [
{
name: '2025 Q1',
monthly: [42, 55, 68, 51, 73, 37],
quarterly: [38, 45, 52, 41],
totalRevenue: 326,
totalOrders: 2840,
growth: 12.5,
targetPercent: 62
},
{
name: '2025 Q2',
monthly: [65, 42, 88, 55, 73, 91],
quarterly: [52, 60, 45, 68],
totalRevenue: 414,
totalOrders: 3520,
growth: 18.3,
targetPercent: 78
},
{
name: '2025 Q3',
monthly: [38, 55, 42, 68, 52, 85],
quarterly: [42, 55, 38, 72],
totalRevenue: 340,
totalOrders: 2960,
growth: 8.7,
targetPercent: 73
}
];
// ============================================
// 柱状图渲染函数
// ============================================
const barChartEl = document.getElementById('barChart');
const barLabelsEl = document.getElementById('barLabels');
const months = ['1月', '2月', '3月', '4月', '5月', '6月'];
function renderBarChart(data, animate = true) {
barChartEl.innerHTML = '';
barLabelsEl.innerHTML = '';
data.monthly.forEach((val, i) => {
// 标签
const label = document.createElement('span');
label.className = 'bar-label';
label.textContent = months[i];
label.style.flex = '1';
label.style.textAlign = 'center';
label.style.fontSize = '0.75rem';
label.style.color = 'var(--text-dim)';
barLabelsEl.appendChild(label);
// 柱子
const barWrap = document.createElement('div');
barWrap.className = 'bar-wrap';
const bar = document.createElement('div');
bar.className = 'bar';
// 用百分比表示高度(最高值=100%)
const maxVal = Math.max(...data.monthly);
bar.style.height = (val / maxVal * 100) + '%';
const valueTag = document.createElement('span');
valueTag.className = 'bar-value';
valueTag.textContent = animate ? '0' : val;
bar.appendChild(valueTag);
barWrap.appendChild(bar);
barChartEl.appendChild(barWrap);
});
if (animate) {
// 柱子弹入
const bars = barChartEl.querySelectorAll('.bar');
gsap.from(bars, {
scaleY: 0,
duration: 0.7,
stagger: 0.06,
ease: 'back.out(1.3)',
transformOrigin: 'bottom center'
});
// 数字跳动
bars.forEach((bar, i) => {
const label = bar.querySelector('.bar-value');
gsap.to({ val: 0 }, {
val: data.monthly[i],
duration: 0.8,
delay: i * 0.06 + 0.3,
ease: 'power2.out',
onUpdate: function() {
label.textContent = Math.round(this.targets()[0].val);
}
});
});
}
}
// ============================================
// 季度柱状图渲染
// ============================================
const quarterChartEl = document.getElementById('quarterChart');
function renderQuarterChart(data, animate = true) {
quarterChartEl.innerHTML = '';
const labels = ['Q1', 'Q2', 'Q3', 'Q4'];
data.quarterly.forEach((val, i) => {
const barWrap = document.createElement('div');
barWrap.className = 'bar-wrap';
const bar = document.createElement('div');
bar.className = 'bar';
const maxVal = Math.max(...data.quarterly);
bar.style.height = (val / maxVal * 100) + '%';
const label = document.createElement('span');
label.className = 'bar-value';
label.textContent = animate ? '0' : val;
bar.appendChild(label);
barWrap.appendChild(bar);
quarterChartEl.appendChild(barWrap);
});
if (animate) {
const bars = quarterChartEl.querySelectorAll('.bar');
gsap.from(bars, {
scaleY: 0,
duration: 0.7,
stagger: 0.08,
ease: 'back.out(1.3)',
transformOrigin: 'bottom center'
});
bars.forEach((bar, i) => {
const label = bar.querySelector('.bar-value');
gsap.to({ val: 0 }, {
val: data.quarterly[i],
duration: 0.8,
delay: i * 0.08 + 0.3,
ease: 'power2.out',
onUpdate: function() {
label.textContent = Math.round(this.targets()[0].val);
}
});
});
}
}
// ============================================
// 环形进度条渲染
// ============================================
const ring = document.getElementById('progressRing');
const ringPercent = document.getElementById('ringPercent');
const radius = 85;
const circumference = 2 * Math.PI * radius;
ring.style.strokeDasharray = circumference;
ring.style.strokeDashoffset = circumference;
function animateRing(targetPercent, animate = true) {
const targetOffset = circumference - (targetPercent / 100) * circumference;
gsap.to(ring, {
strokeDashoffset: targetOffset,
duration: animate ? 1.5 : 0,
ease: 'power3.out',
onUpdate: function() {
const currentOffset = parseFloat(ring.style.strokeDashoffset || circumference);
const percent = Math.round((1 - currentOffset / circumference) * 100);
ringPercent.textContent = percent + '%';
}
});
}
// ============================================
// 统计数字动画
// ============================================
function animateStats(data, animate = true) {
const duration = animate ? 1.5 : 0;
// 总营收
gsap.to({ val: 0 }, {
val: data.totalRevenue,
duration,
ease: 'power2.out',
onUpdate: function() {
document.getElementById('statRevenue').textContent =
Math.round(this.targets()[0].val).toLocaleString();
}
});
// 总订单
gsap.to({ val: 0 }, {
val: data.totalOrders,
duration,
ease: 'power2.out',
onUpdate: function() {
document.getElementById('statOrders').textContent =
Math.round(this.targets()[0].val).toLocaleString();
}
});
// 同比增长
gsap.to({ val: 0 }, {
val: data.growth,
duration,
ease: 'power2.out',
onUpdate: function() {
document.getElementById('statGrowth').textContent =
this.targets()[0].val.toFixed(1) + '%';
}
});
}
// ============================================
// 初始化:加载默认数据集
// ============================================
let currentSet = 0;
renderBarChart(datasets[0]);
renderQuarterChart(datasets[0]);
animateRing(datasets[0].targetPercent);
animateStats(datasets[0]);
// ============================================
// 数据切换交互
// ============================================
const buttons = document.querySelectorAll('.data-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const targetSet = parseInt(btn.dataset.set);
if (targetSet === currentSet) return;
// 更新按钮状态
buttons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// 切换到新数据集
const newData = datasets[targetSet];
// 柱状图:归零 → 更新 → 弹回
const bars = barChartEl.querySelectorAll('.bar');
if (bars.length > 0) {
gsap.to(bars, {
scaleY: 0,
duration: 0.25,
ease: 'power2.in',
onComplete: () => {
renderBarChart(newData, true);
}
});
}
const qBars = quarterChartEl.querySelectorAll('.bar');
if (qBars.length > 0) {
gsap.to(qBars, {
scaleY: 0,
duration: 0.25,
ease: 'power2.in',
onComplete: () => {
renderQuarterChart(newData, true);
}
});
}
// 环形进度条平滑过渡
animateRing(newData.targetPercent, true);
// 统计数字跳动
animateStats(newData, true);
currentSet = targetSet;
});
});
});
</script>
</body>
</html>12.7 技术解析
scaleY vs height:为什么选scaleY
height 变化可能引起布局和绘制;transform 的 scaleY 通常可以减少布局工作,但浏览器是否合成仍需测量。
scaleY 会缩放整个元素和它的子元素,不会自动保持文字尺寸。把标签移到柱体外,固定柱体高度并以底部为 transformOrigin,才能只让数据柱改变。
| 实现 | 视觉与布局影响 |
|---|---|
| 改 height | 真实盒子高度改变,可能影响相邻布局 |
| 固定高度 + scaleY | 布局尺寸不变,子内容也被缩放 |
| 外置标签 + scaleY | 柱体缩放,标签保持可读 |
选择标准是布局需求与测量结果,不是“有 GSAP 就会自动使用 GPU”。
环形进度条的圆周长计算
// 计算公式:circumference = 2 × π × r
// radius = 85 → circumference ≈ 534.07
// 显示百分比 = targetPercent% → 需要隐藏的弧长 = circumference × (1 - targetPercent/100)
// strokeDashoffset = 需要隐藏的弧长验证:
- 0%:
dashoffset = 534.07 × (1 - 0) = 534.07→ 完全隐藏 - 50%:
dashoffset = 534.07 × 0.5 = 267.04→ 显示一半 - 100%:
dashoffset = 534.07 × 0 = 0→ 完全显示
数据切换策略
数值变化使用 to() 从当前值过渡;布局变化使用 Flip;确实需要强调数据整体更换时,可使用“退场→更新→入场”,但这是交互设计选择,并非不使用 Flip 的性能次优方案。快速切换还应明确取消旧动画和旧回调,避免过期数据覆盖新数据。
12.8 常见陷阱
陷阱1:stroke-dasharray 计算错误
症状:环形进度条看起来"对不上"——50%的目标显示不到一半。
原因:忘记设置 stroke-dasharray 为完整圆周长。
检查清单:
stroke-dasharray是否等于circumferencestroke-dashoffset初始值是否等于circumference(完全隐藏)circumference计算是否正确:2 * Math.PI * r
// 调试代码——在控制台验证
const ring = document.getElementById('progressRing');
console.log('dasharray:', ring.style.strokeDasharray); // 应为 534.07...
console.log('dashoffset:', ring.style.strokeDashoffset); // 应与dasharray相等(初始)陷阱2:数字精度丢失
症状:数字跳动到目标值时,显示 99.989999999% 而不是 99.99%。
原因:JS浮点数精度问题。this.targets()[0].val 在 99.99 附近可能因浮点误差显示为 99.98999999。
解决:
// ❌ 直接显示
el.textContent = this.targets()[0].val.toFixed(2) + '%';
// ✅ 配合 Math.round 做精度保护
el.textContent = (Math.round(this.targets()[0].val * 100) / 100).toFixed(2) + '%';对于整数场景(如用户数),用 Math.round 或 Math.floor:
// 整数:Math.round 达到目标值附近时自动四舍五入
el.textContent = Math.round(this.targets()[0].val);
// 大数值:配合 floor,避免在动画中出现小数位
el.textContent = Math.floor(this.targets()[0].val).toLocaleString();陷阱3:数据切换时的动画冲突
症状:快速连续切换数据集时,柱子动画错乱——多个动画同时作用。
原因:上一次切换动画还在进行中,下一次已经开始。
解决:用 gsap.killTweensOf 清除正在进行的动画:
function switchData(newData) {
// 先杀死所有正在进行的柱子动画
gsap.killTweensOf('.bar');
gsap.killTweensOf('.bar-value');
// 重置状态
gsap.set('.bar', { clearProps: 'all' });
// 然后执行新的切换动画
// ...
}也可以显式使用 overwrite: 'auto',只终止活动动画中的冲突属性。GSAP 的默认值是 false:
gsap.to('.bar', {
scaleY: 0,
duration: 0.25,
overwrite: 'auto' // 显式覆盖活动动画中的冲突属性
});