[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;
}
相关推荐
bug-1008617 小时前
hooks,mixin,pinia,vuex
前端·vue.js
Shirley~~17 小时前
npm包发布与 dist-tag 管理指南
前端·npm·node.js
Csvn17 小时前
前端可视化入门:Canvas、SVG 与 D3.js 基础
前端·d3.js
bug-1008617 小时前
vue2和vue3的路由变化
前端·vue.js
百数平台17 小时前
功能更新——百数详情页“数据简报”与“关联标签页”配置指南
java·服务器·前端
Csvn17 小时前
前端技术 - 3D 图形基础
前端·d3.js
狼丶宇先森17 小时前
vue-sign-canvas v2 重构复盘:从 Vue 2 签名板到 Vue 3 + TypeScript 组件库
前端·vue.js·重构·typescript·开源软件·canvas
迁旭17 小时前
Claude Code 项目 /init 命令详解
前端·javascript·chrome·机器学习·语言模型·gpt-3
ZC跨境爬虫17 小时前
跟着 MDN 学CSS day_9:(深入掌握CSS选择器核心技能测试)
前端·css·ui·html
Daybreak18 小时前
Convex + Next.js + Clerk 上线求生指南:六个坑,一个比一个离谱
前端