[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;
}
相关推荐
Zaralike29 分钟前
关于浏览器缓存的思考
前端
m0_5287238134 分钟前
axios如何取消请求、配置
前端
匹马夕阳4 小时前
Vite+vue3项目中unplugin-auto-import的作用和使用方法详解
前端·javascript·vue.js
刺客-Andy4 小时前
React 第二十四节 useDeferredValue Hook 的用途以及注意事项详解
前端·react.js·前端框架
独孤求败Ace6 小时前
第40天:Web开发-JS应用&VueJS框架&Vite构建&启动打包&渲染XSS&源码泄露&代码审计
前端·javascript·vue.js
天若子6 小时前
flutter ListView Item复用源码解析
前端·flutter
文城5219 小时前
HTML-day1(学习自用)
前端·学习·html
阿珊和她的猫9 小时前
Vue 和 React 的生态系统有哪些主要区别
前端·vue.js·react.js
偷光9 小时前
深度剖析 React 的 useReducer Hook:从基础到高级用法
前端·javascript·react.js
The_era_achievs_hero10 小时前
动态表格html
前端·javascript·html