[React] 手动实现CountTo 数字滚动效果

这个CountTo组件npmjs里当然有大把的依赖存在,不过今天我们不需要借助任何三方依赖,造个轮子来手动实现这个组件。

通过研究其他count to插件我们可以发现,数字滚动效果主要依赖于requestAnimationFrame 通过js帧来让数字动起来,数字变化则是依赖于内部的easingFn函数来每次计算。

首先声明组件props类型

typescript 复制代码
interface Props {
  /**
   * 动画开始的值
   */
  start?: number;
  /**
   * 目标值
   */
  end: number;
  /**
   * 持续时间
   */
  duration?: number;
  /**
   * 是否自动播放
   */
  autoPlay?: boolean;
  /**
   * 精度
   */
  decimals?: number;
  /**
   * 小数点
   */
  decimal?: string;
  /**
   * 千分位分隔符
   */
  separator?: string;
  /**
   * 数字前 额外信息
   */
  prefix?: string;
  /**
   * 数字后 额外信息
   */
  suffix?: string;
  /**
   * 是否使用变速函数
   */
  useEasing?: boolean;
  /**
   * 计算函数
   */
  easingFn?: (t: number, b: number, c: number, d: number) => number;
  /**
   * 动画开始后传给父组件的回调
   */
  started?: () => void;
  /**
   * 动画结束传递给父组件的回调
   */
  ended?: () => void;
}

除了end 是必要的,其他都是可选参数。

所以我们需要给组件默认值,防止没有参数时会报错。

同时写几个工具函数便于后面使用

typescript 复制代码
export default function Index({
  end,
  start = 0,
  duration = 3000,
  autoPlay = true,
  decimals = 0,
  decimal = '.',
  separator = ',',
  prefix = '',
  suffix = '',
  useEasing = true,
  easingFn = (t, b, c, d) => (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b,
  started = () => {},
  ended = () => {},
}: Props) {
	  const isNumber = (val: string) => {
	    return !isNaN(parseFloat(val));
	  };
	  // 格式化数据,返回想要展示的数据格式
	  const formatNumber = (n: number) => {
	    let val = '';
	    if (n % 1 !== 0) val = n.toFixed(decimals);
	    const x = val.split('.');
	    let x1 = x[0];
	    const x2 = x.length > 1 ? decimal + x[1] : '';
	    const rgx = /(\d+)(\d{3})/;
	    if (separator && !isNumber(separator)) {
	      while (rgx.test(x1)) {
	        x1 = x1.replace(rgx, '$1' + separator + '$2');
	      }
	    }
	    return prefix + x1 + x2 + suffix;
	  };
	  ...
}

初始化数据

typescript 复制代码
  const [state, setState] = useState<State>({
    start: 0,
    paused: false,
    duration,
  });
  const startTime = useRef(0);
  const _timestamp = useRef(0);
  const remaining = useRef(0);
  const printVal = useRef(0);
  const rAf = useRef(0);
  const endRef = useRef(end);
  const endedCallback = useRef(ended);
  const [displayValue, setValue] = useState(formatNumber(start));
  // 定义一个计算属性,当开始数字大于结束数字时返回true
  const stopCount = useMemo(() => start > end, [start, end]);

动画的关键函数

typescript 复制代码
  const count = (timestamp: number) => {
    if (!startTime.current) startTime.current = timestamp;
    _timestamp.current = timestamp;
    const progress = timestamp - startTime.current;
    remaining.current = state.duration - progress;

    // 是否使用速度变化曲线
    if (useEasing) {
      if (stopCount) {
        printVal.current = state.start - easingFn(progress, 0, state.start - end, state.duration);
      } else {
        printVal.current = easingFn(progress, state.start, end - state.start, state.duration);
      }
    } else {
      if (stopCount) {
        printVal.current = state.start - (state.start - endRef.current) * (progress / state.duration);
      } else {
        printVal.current = state.start + (endRef.current - state.start) * (progress / state.duration);
      }
    }
    if (stopCount) {
      printVal.current = printVal.current < endRef.current ? endRef.current : printVal.current;
    } else {
      printVal.current = printVal.current > endRef.current ? endRef.current : printVal.current;
    }
    setValue(formatNumber(printVal.current));
    if (progress < state.duration) {
      rAf.current = requestAnimationFrame(count);
    } else {
      endedCallback.current?.();
    }
  };

执行动画的函数

typescript 复制代码
  const startCount = () => {
    setState({ ...state, start, duration, paused: false });
    rAf.current = requestAnimationFrame(count);
    startTime.current = 0;
  };

挂载时监听是否有autoPlay 来选择是否开始动画,同时组件销毁后清除requestAnimationFrame动画;

typescript 复制代码
  useEffect(() => {
    if (autoPlay) {
      startCount();
      started?.();
    }
    return () => {
      cancelAnimationFrame(rAf.current);
    };
  }, []);

一些相关依赖的监听及处理

typescript 复制代码
useEffect(() => {
    if (!autoPlay) {
      cancelAnimationFrame(rAf.current);
      setState({ ...state, paused: true });
    }
  }, [autoPlay]);
  useEffect(() => {
    if (!state.paused) {
      cancelAnimationFrame(rAf.current);
      startCount();
    }
  }, [start]);

最后返回displayValue就可以了;

好了 我要开启五一假期了!

最后附上完整代码 --

typescript 复制代码
'use client';

import { useEffect, useMemo, useRef, useState } from 'react';

interface Props {
  /**
   * 动画开始的值
   */
  start?: number;
  /**
   * 目标值
   */
  end: number;
  /**
   * 持续时间
   */
  duration?: number;
  /**
   * 是否自动播放
   */
  autoPlay?: boolean;
  /**
   * 精度
   */
  decimals?: number;
  /**
   * 小数点
   */
  decimal?: string;
  /**
   * 千分位分隔符
   */
  separator?: string;
  /**
   * 数字前 额外信息
   */
  prefix?: string;
  /**
   * 数字后 额外信息
   */
  suffix?: string;
  /**
   * 是否使用变速函数
   */
  useEasing?: boolean;
  /**
   * 计算函数
   */
  easingFn?: (t: number, b: number, c: number, d: number) => number;
  /**
   * 动画开始后传给父组件的回调
   */
  started?: () => void;
  /**
   * 动画结束传递给父组件的回调
   */
  ended?: () => void;
}
interface State {
  start: number;
  paused: boolean;
  duration: number;
}
export default function Index({
  end,
  start = 0,
  duration = 3000,
  autoPlay = true,
  decimals = 0,
  decimal = '.',
  separator = ',',
  prefix = '',
  suffix = '',
  useEasing = true,
  easingFn = (t, b, c, d) => (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b,
  started = () => {},
  ended = () => {},
}: Props) {
  const isNumber = (val: string) => {
    return !isNaN(parseFloat(val));
  };
  // 格式化数据,返回想要展示的数据格式
  const formatNumber = (n: number) => {
    let val = '';
    if (n % 1 !== 0) val = n.toFixed(decimals);
    const x = val.split('.');
    let x1 = x[0];
    const x2 = x.length > 1 ? decimal + x[1] : '';
    const rgx = /(\d+)(\d{3})/;
    if (separator && !isNumber(separator)) {
      while (rgx.test(x1)) {
        x1 = x1.replace(rgx, '$1' + separator + '$2');
      }
    }
    return prefix + x1 + x2 + suffix;
  };

  const [state, setState] = useState<State>({
    start: 0,
    paused: false,
    duration,
  });
  const startTime = useRef(0);
  const _timestamp = useRef(0);
  const remaining = useRef(0);
  const printVal = useRef(0);
  const rAf = useRef(0);
  const endRef = useRef(end);
  const endedCallback = useRef(ended);
  const [displayValue, setValue] = useState(formatNumber(start));
  // 定义一个计算属性,当开始数字大于结束数字时返回true
  const stopCount = useMemo(() => start > end, [start, end]);
  const count = (timestamp: number) => {
    if (!startTime.current) startTime.current = timestamp;
    _timestamp.current = timestamp;
    const progress = timestamp - startTime.current;
    remaining.current = state.duration - progress;

    // 是否使用速度变化曲线
    if (useEasing) {
      if (stopCount) {
        printVal.current = state.start - easingFn(progress, 0, state.start - end, state.duration);
      } else {
        printVal.current = easingFn(progress, state.start, end - state.start, state.duration);
      }
    } else {
      if (stopCount) {
        printVal.current = state.start - (state.start - endRef.current) * (progress / state.duration);
      } else {
        printVal.current = state.start + (endRef.current - state.start) * (progress / state.duration);
      }
    }
    if (stopCount) {
      printVal.current = printVal.current < endRef.current ? endRef.current : printVal.current;
    } else {
      printVal.current = printVal.current > endRef.current ? endRef.current : printVal.current;
    }
    setValue(formatNumber(printVal.current));
    if (progress < state.duration) {
      rAf.current = requestAnimationFrame(count);
    } else {
      endedCallback.current?.();
    }
  };

  const startCount = () => {
    setState({ ...state, start, duration, paused: false });
    rAf.current = requestAnimationFrame(count);
    startTime.current = 0;
  };

  useEffect(() => {
    if (!autoPlay) {
      cancelAnimationFrame(rAf.current);
      setState({ ...state, paused: true });
    }
  }, [autoPlay]);
  useEffect(() => {
    if (!state.paused) {
      cancelAnimationFrame(rAf.current);
      startCount();
    }
  }, [start]);
  useEffect(() => {
    if (autoPlay) {
      startCount();
      started?.();
    }
    return () => {
      cancelAnimationFrame(rAf.current);
    };
  }, []);
  return displayValue;
}
相关推荐
SleepyZone6 分钟前
Cline 源码浅析 - 从输入到输出
前端·ai编程·cline
Struggler2819 分钟前
pinia-基于monorepo的项目结构管理
前端
Struggler28114 分钟前
SSE的使用
前端
用户58061393930020 分钟前
前端文件下载实现深度解析:Blob与ObjectURL的完美协作
前端
Lin866623 分钟前
Vue 3 + TypeScript 组件类型推断失败问题完整解决方案
前端
coding随想24 分钟前
从零开始:前端开发者的SEO优化入门与实战
前端
前端工作日常26 分钟前
我理解的JSBridge
前端
Au_ust26 分钟前
前端模块化
前端
顺丰同城前端技术团队27 分钟前
还不会用 Charles?最后一遍了啊!
前端
BUG收容所所长28 分钟前
二分查找的「左右为难」:如何优雅地找到数组中元素的首尾位置
前端·javascript·算法