
本文介绍如何不依赖任何第三方库,仅用 react + 原生 css/js 实现具备“滚动数字”视觉效果的 odometer 动画组件,支持平滑递增/递减、逐位滚动、可配置步长与延迟。
本文介绍如何不依赖任何第三方库,仅用 react + 原生 css/js 实现具备“滚动数字”视觉效果的 odometer 动画组件,支持平滑递增/递减、逐位滚动、可配置步长与延迟。
在数据可视化或仪表盘场景中,一个带“滚动数字”效果的计数器(类似汽车里程表)能显著提升用户体验。虽然 react-odometer、odometer-react 等库开箱即用,但若项目追求轻量、可控性高或需深度定制动画逻辑,纯手写实现是更优选择。
以下是一个完整、可复用的 SpeedoMeter 组件实现,它通过数字位分离 + 位移动画 + 递进式状态更新三步达成 odometer 效果:
✅ 核心思路解析
- 位分离渲染:将目标数字(如 100)转为字符串,逐字符映射为 <div class="speedo-digit">,每个容器内预置 0–9 十个数字子元素;
- 垂直定位控制:利用 margin-top: -${val}em 将对应数字“推入视口”,例如 margin-top: -3em 使数字 3 对齐顶部;
- 状态驱动过渡:currentValue 通过 useEffect 循环逼近 targetValue,每次更新触发 DOM 重排,配合 CSS transition 实现平滑滚动。
? 完整组件代码(React + TypeScript 友好)
import { useEffect, useState } from "react";
import "./SpeedoMeter.css";
interface SpeedoMeterProps {
value: number;
}
export default function SpeedoMeter({ value }: SpeedoMeterProps) {
const [currentValue, setCurrentValue] = useState(0);
const [targetValue, setTargetValue] = useState(value);
// 同步外部 value 变化为 target
useEffect(() => {
setTargetValue(value);
}, [value]);
// 递进式逼近 target —— 关键动画驱动逻辑
useEffect(() => {
if (currentValue === targetValue) return;
const distance = Math.abs(targetValue - currentValue);
const stepSize = Math.max(1, Math.ceil(distance / 10)); // 分 10 步,最小步长为 1
const nextValue = currentValue < targetValue
? currentValue + stepSize
: currentValue - stepSize;
const timer = setTimeout(() => {
setCurrentValue(nextValue);
}, 16); // ≈ 60fps,比 1ms 更自然;可调为 props
return () => clearTimeout(timer);
}, [currentValue, targetValue]);
const digits = String(Math.abs(currentValue)).split("");
return (
<div className="speedo-wrap">
{digits.map((digit, idx) => (
<div
key={idx}
className="speedo-digit"
style={{ marginTop: `-${digit}em` }}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<div key={num} data-val={num}>{num}</div>
))}
</div>
))}
</div>
);
}? 必备 CSS 样式(SpeedoMeter.css)
.speedo-wrap {
display: flex;
align-items: center;
height: 1.2em; /* 高度需容纳 10 行数字 */
font-size: 1.2em;
line-height: 1.2em;
overflow: hidden;
}
.speedo-digit {
position: relative;
width: 1em;
height: 1.2em;
overflow: hidden;
}
.speedo-digit > div {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: center;
transition: margin-top 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); /* 缓动更真实 */
}
/* 可选:添加数字间间距 */
.speedo-digit:not(:last-child) {
margin-right: 0.2em;
}⚠️ 注意事项与优化建议
- 性能安全:使用 setTimeout 而非 setInterval,避免内存泄漏;清理定时器(useEffect cleanup)已包含;
- 负数处理:当前示例对负数取绝对值显示(Math.abs(currentValue)),如需显示负号,可在 digits 前插入 <div className="speedo-digit">−</div> 并特殊处理;
- 精度控制:stepSize 使用 Math.ceil(distance / steps) 避免因整数截断导致最终值偏差;若需严格精确,最后一步可强制设为 targetValue;
- 响应式适配:em 单位确保缩放一致性,配合 font-size 可全局缩放整个组件;
- 无障碍增强:可添加 aria-live="polite" 和 aria-valuenow 提升读屏体验。
✅ 使用方式
<SpeedoMeter value={12345} />
// 或动态绑定
<SpeedoMeter value={userStats.totalPoints} />该实现零依赖、逻辑清晰、动画可控性强,既满足基础 odometer 效果,又为后续扩展(如自定义数字字体、千分位分隔、颜色渐变等)预留了良好结构。如需更高性能(如万级数字高频更新),可进一步结合 requestAnimationFrame 替代 setTimeout,但对常规仪表场景,当前方案已足够稳健高效。









