返回目录

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

第12章 交互式数据可视化动画

制作柱状图、环形进度与数字变化等数据可视化动画。

本章小节

难度:★★★☆☆ | 用到的GSAP能力:stagger、SVG动画、对象属性动画、Timeline编排 | 项目类型:数据仪表盘


12.1 需求分析

数据仪表盘的动画需求与传统页面不同——动画不是"装饰",而是"信息传递"本身:

  1. 柱状图增长动画:数据柱从0增长到目标高度,配合stagger制造节奏感
  2. 环形进度条:SVG圆环从空到满的弧线动画,直观展示完成度
  3. 数字跳动:数据从0快速跳动到目标值,强化数据冲击力
  4. 交互切换:点击按钮切换不同数据集,动画平滑过渡

柱子、圆圈和数字,动起来比不动的更有说服力。本章实现一个完整的交互式数据仪表盘组件。


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 为完整圆周长。

检查清单:

  1. stroke-dasharray 是否等于 circumference
  2. stroke-dashoffset 初始值是否等于 circumference(完全隐藏)
  3. 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'    // 显式覆盖活动动画中的冲突属性
});