You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React组件切屏/最小化后批量生成移动圆圈问题求助

问题:窗口最小化/切标签页后返回,BannerSkills组件瞬间生成大量技能圆圈

我在个人作品集网站里实现了一个BannerSkills组件,功能是每隔数秒生成一个斜向移动的技能圆圈。现在遇到的问题是:当窗口最小化或切换标签页一段时间后返回,组件会一次性生成大量圆圈——目前设置了屏幕最多显示6个,返回时会直接填满这个上限;如果没设限制,甚至会生成无数个。我排查过重渲染相关问题,但没找到解决办法,组件代码如下:

import { useEffect, useRef } from 'react';
import './banner-skills.component.css';
import SKILLS_DATA, { Skill } from '../../data/skills';

const BannerSkills = () => {
  const CREATE_CIRCLE_MS = 4500;
  const CREATE_CIRCLE_MS_MOBILE = 7000;
  const ARRAY_OF_SKILLS: Skill[] = [];
  const isFirstRender = useRef(true);

  useEffect(() => {
    const skillsContainer = document.getElementsByClassName('banner-skills-container')[0];
    let minScreenSize = window.screen.width * 0.08;
    let maxScreenSize = window.screen.width * 0.85;
    let maxCircles = 6;
    let prevInitPos = Math.floor(Math.random() * maxScreenSize) - minScreenSize;

    const createDiagonalCircle = () => {
      if(window.screen.width <= 500) {
        minScreenSize = window.screen.width * 0.8;
        maxCircles = 4;
      }
      else {
        minScreenSize = window.screen.width * 0.08;
        maxCircles = 6;
      }

      if(ARRAY_OF_SKILLS.length >= maxCircles) {
        return;
      }
      
      const circle = document.createElement('div');
      circle.classList.add('skill-circle');
      skillsContainer.appendChild(circle);
      const logo = document.createElement('img');
      let randomSkill = SKILLS_DATA[Math.floor(Math.random() * SKILLS_DATA.length)];
      
      while(ARRAY_OF_SKILLS.indexOf(randomSkill) >= 0) {
        randomSkill = SKILLS_DATA[Math.floor(Math.random() * SKILLS_DATA.length)];
      }

      logo.src = randomSkill.img;
      ARRAY_OF_SKILLS.push(randomSkill);
      circle.appendChild(logo);

      let size = Math.floor(Math.random() * 70) + 70;
      circle.style.width = size + 'px';
      circle.style.height = size + 'px';

      let posTop = -150;
      let posLeft = Math.floor(Math.random() * maxScreenSize) - minScreenSize;

      while(Math.abs(posLeft - prevInitPos) < (window.screen.width * 0.13)) {
        posLeft = Math.floor(Math.random() * maxScreenSize) - minScreenSize;
      }
      
      prevInitPos = posLeft;
      let moveCirclesInterval = setInterval(frame, size - (size * 0.65));
      
      function frame() {
        if (posTop === 500) {
          skillsContainer.removeChild(circle);
          ARRAY_OF_SKILLS.splice(ARRAY_OF_SKILLS.indexOf(randomSkill), 1);
          clearInterval(moveCirclesInterval);
        } else {
          posTop++;
          posLeft++;
          circle.style.top = posTop + 'px';
          circle.style.left = posLeft + 'px';
          circle.style.visibility = 'visible';
        }
      }
      isFirstRender.current = false;
    }

    if(isFirstRender.current === true) {
      createDiagonalCircle();
      if(window.screen.width <= 500) {
        let spawnCirclesInterval = setInterval(createDiagonalCircle, CREATE_CIRCLE_MS_MOBILE);
        return () => clearInterval(spawnCirclesInterval); 
      }
      else {
        let spawnCirclesInterval = setInterval(createDiagonalCircle, CREATE_CIRCLE_MS);
        return () => clearInterval(spawnCirclesInterval); 
      } 
    }
  }, [])

  return (
    <div className="banner-skills-container"></div>
  )
}

export default BannerSkills

问题根源

浏览器在标签页隐藏或窗口最小化时,会降低setInterval的执行频率甚至暂停它,但不会清除定时器。当页面重新激活时,浏览器会把暂停期间本该执行的setInterval回调一次性全部执行,导致短时间内生成大量圆圈。

修复方案

1. 核心改动:用requestAnimationFrame替代setInterval控制生成频率

requestAnimationFrame会自动适配页面活跃状态:页面隐藏时暂停,激活后恢复,不会堆积回调。结合时间戳精准控制生成间隔,避免依赖固定定时器。

2. 优化状态管理

原代码里的ARRAY_OF_SKILLS是组件内全局变量,改用useRef存储,确保状态稳定且不随组件重渲染重置。

修复后的完整代码

import { useEffect, useRef } from 'react';
import './banner-skills.component.css';
import SKILLS_DATA, { Skill } from '../../data/skills';

const BannerSkills = () => {
  const CREATE_CIRCLE_MS = 4500;
  const CREATE_CIRCLE_MS_MOBILE = 7000;
  const skillsArrayRef = useRef<Skill[]>([]);
  const animationFrameIdRef = useRef<number | null>(null);
  const lastCreateTimeRef = useRef<number>(Date.now());

  useEffect(() => {
    const skillsContainer = document.querySelector('.banner-skills-container');
    if (!skillsContainer) return;

    // 获取当前设备的生成间隔
    const getCreateInterval = () => {
      return window.innerWidth <= 500 ? CREATE_CIRCLE_MS_MOBILE : CREATE_CIRCLE_MS;
    };

    // 获取当前设备的最大圆圈数量
    const getMaxCircles = () => {
      return window.innerWidth <= 500 ? 4 : 6;
    };

    const createDiagonalCircle = () => {
      const maxCircles = getMaxCircles();
      if (skillsArrayRef.current.length >= maxCircles) return;

      const circle = document.createElement('div');
      circle.classList.add('skill-circle');
      skillsContainer.appendChild(circle);
      const logo = document.createElement('img');
      
      // 随机选择不重复的技能
      let randomSkill = SKILLS_DATA[Math.floor(Math.random() * SKILLS_DATA.length)];
      while (skillsArrayRef.current.includes(randomSkill)) {
        randomSkill = SKILLS_DATA[Math.floor(Math.random() * SKILLS_DATA.length)];
      }

      logo.src = randomSkill.img;
      skillsArrayRef.current.push(randomSkill);
      circle.appendChild(logo);

      // 设置圆圈尺寸
      const size = Math.floor(Math.random() * 70) + 70;
      circle.style.width = `${size}px`;
      circle.style.height = `${size}px`;

      // 计算初始位置
      const minScreenSize = window.innerWidth * (window.innerWidth <= 500 ? 0.8 : 0.08);
      const maxScreenSize = window.innerWidth * 0.85;
      let prevInitPos = Math.floor(Math.random() * maxScreenSize) - minScreenSize;
      let posLeft = Math.floor(Math.random() * maxScreenSize) - minScreenSize;

      // 避免初始位置过于接近
      while (Math.abs(posLeft - prevInitPos) < (window.innerWidth * 0.13)) {
        posLeft = Math.floor(Math.random() * maxScreenSize) - minScreenSize;
      }
      
      prevInitPos = posLeft;
      let posTop = -150;
      // 控制圆圈移动
      const moveInterval = setInterval(() => {
        if (posTop === 500) {
          skillsContainer.removeChild(circle);
          const skillIndex = skillsArrayRef.current.indexOf(randomSkill);
          if (skillIndex !== -1) {
            skillsArrayRef.current.splice(skillIndex, 1);
          }
          clearInterval(moveInterval);
        } else {
          posTop++;
          posLeft++;
          circle.style.top = `${posTop}px`;
          circle.style.left = `${posLeft}px`;
          circle.style.visibility = 'visible';
        }
      }, size - (size * 0.65));
    };

    // 循环生成圆圈的逻辑
    const spawnLoop = () => {
      const now = Date.now();
      const interval = getCreateInterval();
      if (now - lastCreateTimeRef.current >= interval) {
        createDiagonalCircle();
        lastCreateTimeRef.current = now;
      }
      animationFrameIdRef.current = requestAnimationFrame(spawnLoop);
    };

    // 初始化第一个圆圈
    createDiagonalCircle();
    // 启动生成循环
    animationFrameIdRef.current = requestAnimationFrame(spawnLoop);

    // 组件卸载时清理资源
    return () => {
      if (animationFrameIdRef.current) {
        cancelAnimationFrame(animationFrameIdRef.current);
      }
      // 清理所有圆圈元素
      document.querySelectorAll('.skill-circle').forEach(circle => {
        skillsContainer?.removeChild(circle);
      });
      skillsArrayRef.current = [];
    };
  }, []);

  return (
    <div className="banner-skills-container"></div>
  );
};

export default BannerSkills;

关键改动说明

  • 用requestAnimationFrame的循环替代setInterval,利用浏览器帧同步机制,页面隐藏时自动暂停,不会堆积生成任务
  • 用useRef存储技能数组、动画帧ID和上次生成时间,确保状态在组件生命周期内稳定
  • 替换window.screen.width为window.innerWidth,更准确反映当前视窗实际宽度
  • 优化组件卸载时的清理逻辑,确保所有动画和残留元素都被清除

内容的提问来源于stack exchange,提问作者Khaled Al Hariri

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.25 00:32:44