前言
在網頁中,常見的數字滾動動畫能有效提升視覺吸引力,尤其用於統計數據或關鍵指標的呈現。這段程式碼示範如何結合 IntersectionObserver 與計時器,實現當數字元素滾動進入視窗時,從初始值平滑遞增到目標數字的動畫效果。適合有基礎 JavaScript 知識,並希望理解如何優化滾動觸發動畫的工程師或自學者。
使用 IntersectionObserver 監控元素可見性
為避免動畫在頁面初始就執行,程式利用 IntersectionObserver 監控所有帶有 .number 類別的元素。當元素至少有 30% 進入視窗時,觸發動畫函式。
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateCounter(entry.target);
}
});
},
{ threshold: 0.3 }
);
counters.forEach(counter => {
observer.observe(counter);
});
這裡的 threshold 設為 0.3,代表元素可見度達 30% 即觸發動畫,這個數值可以根據需求調整,達到更精準的觸發時機。
數字動畫的實作細節
動畫函式 animateCounter 主要負責數字從初始值(此處為 1)平滑遞增到目標數字。實作重點如下:
避免重複執行
利用元素的 dataset.animated 屬性標記,確保同一元素不會重複觸發動畫,避免動畫重疊或閃爍。
if (counter.dataset.animated) return;
counter.dataset.animated = "true";
處理數字與附加的 <sup> 標籤
有些數字後面可能會帶有 <sup> 標籤(例如加號「+」),程式先將其分離並保留,動畫過程只針對純數字進行計算,最後再將 <sup> 加回去。
const sup = counter.querySelector("sup");
const supHTML = sup ? sup.outerHTML : "";
const rawText = counter.childNodes[0].nodeValue.trim();
const target = parseFloat(rawText);
動畫邏輯
- 設定動畫總時長為 1500 毫秒,幀率為 30 fps。
- 使用
setInterval逐幀更新數字。 - 根據進度比例計算當前數字,並判斷是否為小數,決定顯示格式。
- 動畫結束時,強制將數字顯示為原始目標值,避免浮點數誤差。
const duration = 1500;
const frameRate = 30;
const totalFrames = Math.round(duration / (1000 / frameRate));
let frame = 0;
const timer = setInterval(() => {
frame++;
const progress = frame / totalFrames;
const current = start + (target - start) * progress;
counter.innerHTML = (isDecimal ? current.toFixed(1) : Math.floor(current)) + supHTML;
if (frame >= totalFrames) {
clearInterval(timer);
counter.innerHTML = rawText + supHTML;
}
}, 1000 / frameRate);
實際應用與優化方向
- 此動畫適合用於展示統計數字、銷售數據、會員人數等 KPI。
- 可依需求調整動畫時長與幀率,達到不同的視覺效果。
- 若數字範圍極大,建議調整起始值或使用指數型動畫增長,提升動畫流暢度。
- 可結合 CSS 動畫或其他視覺效果,增強整體互動體驗。
常見問題與注意事項
- 重複觸發動畫:未使用
dataset.animated標記,可能導致動畫重複執行。 - 浮點數誤差:動畫過程中使用浮點數計算,最後強制顯示原始文字可避免誤差。
- IntersectionObserver 支援度:大部分現代瀏覽器支援,舊版瀏覽器需考慮 polyfill。
完整程式碼
document.addEventListener("DOMContentLoaded", function () {
const counters = document.querySelectorAll(".number");
const animateCounter = (counter) => {
// 避免重複執行
if (counter.dataset.animated) return;
counter.dataset.animated = "true";
// 取出 <sup>+</sup>
const sup = counter.querySelector("sup");
const supHTML = sup ? sup.outerHTML : "";
// 取純數字(忽略 sup)
const rawText = counter.childNodes[0].nodeValue.trim();
const target = parseFloat(rawText);
const isDecimal = rawText.includes(".");
let start = 1;
const duration = 1500;
const frameRate = 30;
const totalFrames = Math.round(duration / (1000 / frameRate));
let frame = 0;
const timer = setInterval(() => {
frame++;
const progress = frame / totalFrames;
const current = start + (target - start) * progress;
counter.innerHTML = (isDecimal ? current.toFixed(1) : Math.floor(current)) + supHTML;
if (frame >= totalFrames) {
clearInterval(timer);
counter.innerHTML = rawText + supHTML;
}
}, 1000 / frameRate);
};
// IntersectionObserver:滾動到可見才啟動
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateCounter(entry.target);
}
});
},
{ threshold: 0.3 } // 可見 30% 即觸發,可調整
);
counters.forEach(counter => {
observer.observe(counter);
});
});