但行好事,莫问前程
前言
最近需要开发看板功能,涉及到给用户展示一些 number 数据的场景。
作为看板页面,我们要 避免突兀的数值变化,让数字的变化更加自然、更有视觉吸引力,提升用户体验。
最后我采用了 数字滚动 动效,并封装为 hooks 方便复用。
效果如下,其中有不少有趣的设计思路值得复盘。

预览:我的后台 -> Editor
tsx
<NumberScroll value={numberValue} options={{ decimals: 0 }} className="justify-center self-center" />

文中已附上源代码和思路,如果对你有所帮助,还望点赞、收藏、关注三连😽。
方案
整理一下思路,如果要实现 0 -> 100 或者 100 -> 0,会想到什么方法?
- 最简单的是直接更新对应 state,但这样一闪而过十分突兀
- 其次是步进,即
value / time = step,例如step = 10,0 -> 10 -> 20 ... -> 100- 这样不错,但这种线性的变化还是略显生硬
- 最后我们可以利用缓动函数 来控制过程
0 -> ... -> 50 -> 70 -> 80 -> 85 -> ... -> 98 -> 99 -> 100
实现:
- 迅速完成大部分数值变化,视觉上快速地接近最终值 +
- 剩余小部分差值平滑变化,平稳、自然减速并抵达终点
变量分为:更新频率 和 更新步幅(数值)
原理是结合变量与场景,使用缓动函数(贝塞尔曲线数学公式)。
程序逻辑:
ini
例:
初始值: 0 → 目标值: 1000,
duration = 3500ms,1 / 3 = 1167ms 时间用于快速的线性变化,剩下时间用于平滑滚动
线性阈值 = 1000 * 2 / 3 = 666.67,滑动值 = 1000 / 3 = 333.33
│
├─ determine(): 在不同节点,判断需要变化的数值A和线性阈值B,设置新的数值变化目标C
│ ├─ 第一阶段:剩余变化量A 1000 > 阈值B 666.67(大数值线性变化),0 → 目标C 666.67(线性,3500 / 3 = 1167ms)
│ └─ 第二阶段:剩余变化量A 333.33 < 阈值B 666.67(小数值平滑滚动),666.67 → 目标C 1000(缓动,3500 - 1167 = 2333ms)
│
└─ count() 循环执行:循环改变计数值
├─ 第一阶段:
│ ├─ 线性变化
│ ├─ 目标数值 = 666.67
│ ├─ 当前数值 = 0 -> 666.67
│ ├─ 耗时 = 3500 / 3 = 1167ms
│ └─ 动画结束,调用 determine() 重新计算
│
└─ 第二阶段:
│ ├─ 平滑滚动
│ ├─ ...类上
└─ 动画结束,currentValue = 1000
还有很多场景要考虑,数值为负数,减操作,数字被减为0...
缓动函数
简单来说,是 "通过数学公式来 控制 变化的进度 或者 运动的轨迹"。
它使数据和动画摆脱生硬、呆板的线性变化,它让事物的变化 更加符合视觉常识。
详情可见 贝塞尔曲线:实现更好的动画效果和图形

tsx
// utils/index.ts
// 生成对应三次贝塞尔曲线的js代码
export function createBezierFunction(p1x: number, p1y: number, p2x: number, p2y: number) {
return function (t: number) {
return 3 * Math.pow(1 - t, 2) * t * p1y + 3 * (1 - t) * Math.pow(t, 2) * p2y + Math.pow(t, 3);
};
}
实践
我们封装成hooks来重复使用
/src/hooks/useNumberDuration.ts
tsx
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
// 缓动函数
const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
const linear = (t: number) => t;
export interface UseNumberDurationProps {
value: number;
duration?: number;
decimals?: number;
split?: string;
}
const useNumberScroll = ({
value,
duration = 5500,
decimals = 2,
split = ",",
}: UseNumberDurationProps) => {
const rafRef = useRef<number>();
const currentRef = useRef<number>(0);
const durationRef = useRef<number>(duration);
const startTime = useRef<number>();
const startValue = useRef<number>(0);
/** 线性变化的值 */
const EasingThreshold = useMemo(() => {
const diff = value - startValue.current;
const threshold = (Math.abs(diff) * 2) / 3;
return startValue.current + (diff > 0 ? threshold : -threshold);
}, [value]);
/** 滑动变化的值 */
const EasingAmount = useMemo(() => {
const amount = value - EasingThreshold;
return amount;
}, [value, EasingThreshold]);
// 当前的实际值
const [currentValue, setCurrentValue] = useState(0);
// 格式化后用于展示的值
const _current = useMemo(
() =>
currentValue.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
[currentValue, decimals, split]
);
// 当前目标的结束值
const endValue = useRef<number>(value);
// 最终值
const finalValue = useRef<number | null>(null);
const result = useMemo(
() => value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
[value, decimals, split]
);
// determine - 判断变化采用线性还是缓动,设置最终值final和当前目标结束值end
const determine = useCallback(() => {
const end =
finalValue.current !== null ? finalValue.current : endValue.current;
const animateAmount = Math.abs(end - startValue.current);
if (animateAmount > Math.abs(EasingThreshold)) {
finalValue.current = end;
endValue.current = end - EasingAmount;
// 拿出小部分时间用于线性变化
durationRef.current = duration / 3;
} else {
finalValue.current = null;
endValue.current = end;
durationRef.current = duration; // 这样动画滑动更明显一点
// durationRef.current = (duration * 2) / 3;
}
}, [duration, EasingThreshold, EasingAmount]);
const count = useCallback(
(timestamp: number) => {
if (!startTime.current) startTime.current = timestamp;
// 根据时间差计算当前进度
const elapsed = timestamp - startTime.current;
const progress = Math.min(elapsed / durationRef.current, 1);
const eased =
finalValue.current !== null ? linear(progress) : easeOutExpo(progress);
const currentValue =
startValue.current + (endValue.current - startValue.current) * eased;
currentRef.current = currentValue;
setCurrentValue(currentValue);
if (progress < 1) {
rafRef.current = requestAnimationFrame(count);
} else {
startValue.current = currentValue;
// 最终值不为空 = 还未到最终值 = 剩下的值要平滑增加
if (finalValue.current !== null) {
cancelAnimationFrame(rafRef.current!);
startTime.current = undefined;
endValue.current = finalValue.current;
finalValue.current = null;
determine();
rafRef.current = requestAnimationFrame(count);
}
}
},
[determine]
);
useEffect(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
// 变化时重置状态
endValue.current = value;
finalValue.current = null;
durationRef.current = duration;
startValue.current = currentRef.current;
startTime.current = undefined;
// 判断当前的变化方式,并开始动画循环
determine();
rafRef.current = requestAnimationFrame(count);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
startTime.current = undefined;
};
}, [value, duration]);
return { current: _current, result };
};
export default useNumberScroll;
数字滚动
上述的hooks已经实现数字增长的效果了,但还有样式上的问题需要解决,
- 数据变化的过程中,元素占宽也在不断变化
- 非等宽字体的数字占宽不同
以上因素会导致 数据抖动
我采用的解决方案:
- if 可以使用等宽字体
- 预先使用
result渲染真实元素获取所占宽度estimateWidth
- 预先使用
- else
result内的数字替换为占宽最大数字来渲染预估值
最后我们封装一个ui组件进行复用
/src/ui/NumberScroll/index.tsx
tsx
import React, { useRef, useLayoutEffect, useState } from "react";
import useNumberScroll, { UseNumberDurationProps } from "../hooks/useNumberScroll";
interface NumberScrollProps {
value: number;
options?: Omit<UseNumberDurationProps, 'value'>;
className?: string;
suffix?: string;
style?: React.CSSProperties;
}
const NumberScroll: React.FC<NumberScrollProps> = ({
value,
options,
suffix = "",
className,
style,
}) => {
const { current, result } = useNumberScroll({
value,
...options
});
const measureRef = useRef<HTMLSpanElement>(null);
const [fixedWidth, setFixedWidth] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
if (measureRef.current) {
const { width } = measureRef.current.getBoundingClientRect();
setFixedWidth(width);
}
}, [result]);
return (
<>
{/* estimate width */}
<span
className={className}
ref={measureRef}
style={{
position: "absolute",
visibility: "hidden",
height: "auto",
width: "auto",
whiteSpace: "nowrap",
...style,
}}
>
{result}
{suffix}
</span>
<span
className={className}
style={{
display: "inline-block",
width: fixedWidth,
...style,
}}
>
{current}
{suffix}
</span>
</>
);
};
export default NumberScroll;
总结
我们可以看出数字滚动动画的核心:
1. 分阶段策略
- 大数值快速线性变化:使用 1/3 的时间快速完成 2/3 的数值变化
- 小数值平滑缓动:使用 2/3 的时间平滑完成剩余的 1/3 变化
- 关键参数:线性阈值(2/3)、滑动值(1/3)、时间分配(1/3 vs 2/3)
2. 缓动函数选择
- 第一阶段 :使用
linear线性函数,快速接近目标 - 第二阶段 :使用
easeOutExpo指数缓出函数,自然减速到达
3. 样式优化
- 固定宽度:避免动画过程中的布局抖动
- 等宽字体优先:如果可以使用等宽字体,直接测量最终宽度
- 预估宽度:非等宽字体时,用最宽数字预估宽度
4. 封装复用
- 逻辑层 :使用
hooks处理动画逻辑,返回当前值和最终值 - UI 层:使用组件处理样式和布局,确保视觉稳定
结语
不要光看不实践哦,希望本文能对你有所帮助。
持续更新前端知识,脚踏实地不水文,真的不关注一下吗~
写作不易,如果有收获还望 点赞+收藏 🌹
才疏学浅,如有问题或建议还望指教~