[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;
}
相关推荐
夏幻灵40 分钟前
HTML5里最常用的十大标签
前端·html·html5
Mr Xu_1 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js
未来龙皇小蓝1 小时前
RBAC前端架构-01:项目初始化
前端·架构
程序员agions1 小时前
2026年,微前端终于“死“了
前端·状态模式
万岳科技系统开发1 小时前
食堂采购系统源码库存扣减算法与并发控制实现详解
java·前端·数据库·算法
程序员猫哥_1 小时前
HTML 生成网页工具推荐:从手写代码到 AI 自动生成网页的进化路径
前端·人工智能·html
龙飞051 小时前
Systemd -systemctl - journalctl 速查表:服务管理 + 日志排障
linux·运维·前端·chrome·systemctl·journalctl
我爱加班、、1 小时前
Websocket能携带token过去后端吗
前端·后端·websocket
AAA阿giao1 小时前
从零拆解一个 React + TypeScript 的 TodoList:模块化、数据流与工程实践
前端·react.js·ui·typescript·前端框架
杨超越luckly1 小时前
HTML应用指南:利用GET请求获取中国500强企业名单,揭秘企业增长、分化与转型的新常态
前端·数据库·html·可视化·中国500强