先来实现一个简单的数字翻滚功能。
简单版
- 没有各种配置项和校验
- 每次滚动数字+1

初始值其实是0,在录视频的时候没录到。
代码很简单,原理就是利用requestAnimationFrame改变dom的内容:
const end = 636;
useEffect(() => {
let number = 0;
const el = document.querySelector('#count');
let raf: number;
const count = () => {
if(number < end) {
number++;
}
if(number < end) raf = requestAnimationFrame(count);
else cancelAnimationFrame(raf);
number = number >= end ? end : number;
if(el) el.innerHTML = number + '';
}
count();
},[]);
return <div id="count"></div>
进阶版
控制时间,每秒数字才加1

原理是利用requestAnimationFrame会给回调函数传递一个与performance.now()相同的值,它表示 开始requestAnimationFrame() 去执行回调函数的时刻,通过对比前后两次的差值来实现:
const end = 60;
useEffect(() => {
let number = 0;
const el = document.querySelector('#count');
let raf: number;
let lastTimestamp: number;
const count = (timestamp: number) => {
if (!lastTimestamp) lastTimestamp = timestamp;
else if (timestamp - lastTimestamp > 1000 + Number.EPSILON) {
lastTimestamp = timestamp;
if (number < end) {
number++;
}
}
if (number < end) raf = requestAnimationFrame(count);
else cancelAnimationFrame(raf);
number = number >= end ? end : number;
if (el) el.innerHTML = number + '';
};
count(performance.now());
}, []);
return <div id="count"></div>;
其实countUp的核心原理也是用requestAnimation来实现的,在此基础上封装了各种参数配置、回调函数、校验、开始、暂停等。
countUp代码阅读
工具函数介绍
resetDuration
重置开始时间、持续时间和剩余时间
resetDuration() {
this.startTime = null;
// 从这里可以看出options的duration单位是秒
this.duration = Number(this.options.duration) * 1000;
// 剩余时间
this.remaining = this.duration;
}
printValue
在目标节点展示数字,这里主要是根据目标节点的类型来更改内容:
printValue(val: number) {
const result = this.formattingFn(val);
if(this.el.tagName === 'INPUT') {
const input = this.el as HTMLInputElement;
input.value = result;
} else if (this.el.tagName === 'text' || this.el.tagName === 'tspan') {
this.el.textContent = result;
} else {
this.el.innerHTML = result;
}
}
formatNumber
默认的格式化数字方法,这里有7个属性需要了解:
● decimalPlaces:保留几位小数,默认是0
● decimal:小数点,默认是字符.
● useGrouping:是否对整数部分按千分位处理,默认是true
● separator:千分位分隔符号,默认是字符,
● numerals:把整数和小数替换掉,例如x1 = "1234", numerals = ['0','4','3','2','1'], x1替换结果是 "4321"
● prefix:前缀,位于负数符号后,默认无
● suffix:后缀,位于整个数字后,默认无
formatNumber = (num: number): string => {
const neg = (num < 0) ? '-' : '';
let result: string,
x: string[],
x1: string,
x2: string,
x3: string;
result = Math.abs(num).toFixed(this.options.decimalPlaces);
result += '';
x = result.split('.');
// x1是整数部分
x1 = x[0];
// decimal默认是小数点. 可以由使用者设置
// x2 是小数部分(带小数点)
x2 = x.length > 1 ? this.options.decimal + x[1] : '';
// useGrouping:是否对整数部分按千分位处理
if (this.options.useGrouping) {
x3 = '';
for (let i = 0, len = x1.length; i < len; ++i) {
if (i !== 0 && (i % 3) === 0) {
x3 = this.options.separator + x3;
}
x3 = x1[len - i - 1] + x3;
}
x1 = x3;
}
// 把整数和小数替换掉,例如x1 = "1234", numerals = ['0','4','3','2','1'], x1替换结果是 "4321"。
if (this.options.numerals && this.options.numerals.length) {
x1 = x1.replace(/[0-9]/g, (w) => this.options.numerals[+w]);
x2 = x2.replace(/[0-9]/g, (w) => this.options.numerals[+w]);
}
return neg + this.options.prefix + x1 + x2 + this.options.suffix;
}
开始
首先,新建一个CountUp实例:
const countUp = new CountUp('count', 5234);
if(!countUp.error) {
countUp.start();
}
在判断error不存在后,执行start开始滚动数字。
接下来看看start方法内部。
start
如果新建实例参数没有duration,则会直接打印endVal。如果有duration(默认是2000),则执行数字滚动动画:
start(callback?: (args?: any) => any) {
if (this.error) {
return;
}
this.callback = callback;
if (this.duration > 0) {
this.determineDirectionAndSmartEasing();
this.paused = false;
this.rAF = requestAnimationFrame(this.count);
} else {
this.printValue(this.endVal);
}
}
注意到start有个回调函数,这个是执行完数字翻滚后会执行的函数。
所以接下来的重点是determineDirectionAndSmartEasing和count这两个方法。
determineDirectionAndSmartEasing
这个词的中文意思是"确认方向和智能缓动"。"确认方向"大致可以推动出这个方法是在确认数字是向上滚(小于end)还是向下滚(大于end),"智能缓动"则是给滚动添加一些条件、判断等。
private determineDirectionAndSmartEasing() {
const end = (this.finalEndVal) ? this.finalEndVal : this.endVal;
// 是否向下滚动
this.countDown = (this.startVal > end);
const animateAmount = end - this.startVal;
// smartEasingThreshold是智能缓动阈值,默认999。只有剩余值大于smartEasingThreshold才会继续执行
if (Math.abs(animateAmount) > this.options.smartEasingThreshold) {
this.finalEndVal = end;
const up = (this.countDown) ? 1 : -1;
this.endVal = end + (up * this.options.smartEasingAmount);
this.duration = this.duration / 2;
} else {
this.endVal = end;
this.finalEndVal = null;
}
// ...
}
接下来,看看count方法。
count
从命名上推测count是"计算"的意思。所以推断这个方法是计算当前值,然后打印出当前值。
count = (timestamp: number) => {
if(!this.startTime) {
this.startTime = timestamp;
}
const progress = timestamp - this.startTime;
this.remaining = this.duration - progress;
// ...
// 滚动方向
if (this.countDown) {
this.frameVal =
this.startVal -
(this.startVal - this.endVal) * (progress / this.duration);
}
else {
this.frameVal =
this.startVal +
(this.endVal - this.startVal) * (progress / this.duration);
}
// 边界条件
if (this.countDown) {
this.frameVal = this.frameVal < this.endVal ? this.endVal : this.frameVal;
} else {
this.frameVal = this.frameVal > this.endVal ? this.endVal : this.frameVal;
}
// decimal
this.frameVal = Number(this.frameVal.toFixed(this.options.decimalPlaces));
// 在节点上打印数字
this.printValue(this.frameVal);
// 判断是否继续
if (progress < this.duration) {
this.rAF = requestAnimationFrame(this.count);
} else if (this.finalEndVal !== null) {
// smart easing
this.update(this.finalEndVal);
} else {
if (this.callback) {
this.callback();
}
}
}
第一次执行count,给this.startTime赋值。progress等于0,更新this.remaining。
之后的每次执行,progress会越来越接近this.duration,直到大于this.duration。
接着判断是否有this.finalEndVal,有才执行 this.update(this.finalEndVal),没有则结束。
总结
刚开始看countUp源码的时候,发现代码300行左右,想着应该能很快看完。后面发现代码内涉及到数学运算比较复杂,更坑的是参数命名太简写了,就像下面这个,完全不懂这个方法在做什么:
easeOutExpo = (t: number, b: number, c: number, d: number): number =>
(c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b;
所以在看源码的时候可以借助实例和断点调试来分析代码。