
本文介绍一种不依赖任何第三方库、仅使用 react hooks 与原生 css 的轻量级 odometer 效果实现方案,通过逐位数字滑动 + 状态驱动动画,达成平滑的数字递增/递减视觉效果。
本文介绍一种不依赖任何第三方库、仅使用 react hooks 与原生 css 的轻量级 odometer 效果实现方案,通过逐位数字滑动 + 状态驱动动画,达成平滑的数字递增/递减视觉效果。
在构建数据看板、实时统计面板或仪表盘组件时,我们常希望数字变化具备「机械式滚动」的视觉反馈——即每位数字像老式里程表一样独立上下翻转,而非简单地瞬时替换。虽然 odometer.js、react-odometer 等库能快速实现该效果,但若项目追求极简依赖、可控性或定制化深度,纯 React + CSS 的实现反而是更优选择。
以下是一个完整、可复用的 SpeedoMeter 组件,它完全基于 React 函数组件、useState 和 useEffect,无外部依赖,且支持任意整数(正/负/零)、响应式更新与灵活的动画节奏控制。
✅ 核心思路
- 数字拆分:将目标数值(如 10000)转为字符串,逐字符映射为 <div> 容器;
- 位图式数字轮盘:每位容器内预置 0–9 共 10 个数字节点,通过 margin-top 偏移实现“翻页”错觉;
- 状态驱动过渡:利用 currentValue 与 targetValue 的差值计算步进增量,配合 setTimeout 构建简易动画循环;
- CSS 过渡强化:对每位数字容器设置 transition: 1s all,使 margin-top 变化产生平滑位移。
? 组件代码(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(0);
const steps = 10; // 每次更新最多分几步完成
const lag = 1; // 每步延迟毫秒数(建议 1–20,太小易卡顿,太大显慢)
// 监听外部 value 变化,更新目标值
useEffect(() => {
setTargetValue(value);
}, [value]);
// 执行渐进式计数(核心动画逻辑)
useEffect(() => {
if (currentValue === targetValue) return;
const timer = setTimeout(() => {
setCurrentValue((prev) => {
const distance = Math.abs(targetValue - prev);
const stepSize = Math.max(1, Math.ceil(distance / steps));
return prev < targetValue ? prev + stepSize : prev - stepSize;
});
}, lag);
return () => clearTimeout(timer);
}, [currentValue, targetValue, steps, lag]);
// 渲染每位数字的滚动轮盘
const digits = Math.abs(currentValue).toString().split("");
const isNegative = currentValue < 0;
return (
<div className="speedo-wrap">
{isNegative && (
<div className="speedo-digit" style={{ marginTop: "-1em" }}>
<div data-val="-">−</div>
<div data-val="+">+</div>
</div>
)}
{digits.map((digit, idx) => (
<div
key={idx}
className="speedo-digit"
style={{ marginTop: `-${digit}em` }}
>
{[...Array(10).keys()].map((n) => (
<div key={n} data-val={n.toString()}>
{n}
</div>
))}
</div>
))}
</div>
);
}? 必备 CSS(SpeedoMeter.css)
.speedo-wrap {
display: flex;
align-items: center;
height: 1.2em;
font-size: 1.2em;
line-height: 1.2em;
overflow: hidden;
font-family: monospace;
font-weight: bold;
}
.speedo-digit {
position: relative;
width: 1em;
height: 1.2em;
overflow: hidden;
transition: margin-top 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.speedo-digit > div {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: center;
line-height: 1.2em;
}? 关键细节说明:
- cubic-bezier(0.25, 0.46, 0.45, 0.94) 替代默认 ease,模拟更自然的「启动→加速→减速」过程;
- height / line-height / font-size 保持严格一致,确保数字高度精准对齐;
- 负数支持通过额外渲染一个符号位实现(可按需扩展为千分位、小数点等);
- useEffect 清理函数防止异步 setTimeout 在组件卸载后触发状态更新(避免警告)。
? 使用方式
// 父组件中
import SpeedoMeter from "./SpeedoMeter";
function Dashboard() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(prev => prev + 1), 800);
return () => clearInterval(id);
}, []);
return (
<div>
<h3>实时计数器:</h3>
<SpeedoMeter value={count} />
<button onClick={() => setCount(0)}>重置</button>
</div>
);
}⚠️ 注意事项与优化建议
- 性能提示:该实现适用于中低频更新(如每秒 ≤5 次)。高频场景(如 FPS 计数)建议改用 requestAnimationFrame 或 Web Animations API;
- 精度权衡:因采用 Math.ceil(distance / steps) 步进,最终值可能略超/略低于目标(如 99 → 100 可能经 99→100→101),可通过在 useEffect 中添加 if (Math.abs(currentValue - targetValue) <= 1) setCurrentValue(targetValue) 收尾校准;
- 样式扩展:可为 .speedo-digit 添加 box-shadow 或 border 强化数字边界感;支持 @keyframes 替代 transition 实现更复杂的翻转动画;
- 无障碍友好:建议为组件添加 aria-live="polite" 及 role="status",并在数字变更时同步更新 aria-valuenow。
此方案以不到 50 行核心逻辑、零依赖、高可读性,完美平衡了功能完整性与工程简洁性——你不仅获得了一个 odometer 组件,更掌握了一种通用的状态驱动 UI 动画建模方法。









