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

Splash Screen组件动画加载过慢问题排查求助

问题描述

我想实现一个带自定义加载动画的启动屏(Splash Screen),但目前遇到两个问题:一是加载耗时过长,二是有时动画还没播放甚至没显示出来,启动屏就已经结束了。我的目标是显示启动屏的同时预加载资源,但二者不同步。我试过把精灵表加入index.html的head标签、设置img.loading='eager'等方法,都没法加快加载速度,怀疑是AssetProvider和启动屏的运行逻辑有冲突,不知道该怎么解决。

以下是相关代码文件:

1. main.tsx

import { createRoot } from 'react-dom/client';
import './index.css';
import AssetProvider, { useLoading } from './modules/AssetProvider';
import SplashScreen from './components/SplashScreen';
import App from './App.tsx';
import { useState, useEffect } from 'react';

const Root = () => {
  return (
    <AssetProvider>
      <RootContent />
    </AssetProvider>
  );
};

const RootContent = () => {
  const loading = useLoading();
  const [isMounted, setIsMounted] = useState(false);
  const [spriteSheetLoaded, setSpriteSheetLoaded] = useState(false);

  useEffect(() => {
    if (spriteSheetLoaded) {
      setIsMounted(true);
    }
  }, [spriteSheetLoaded]);

  const handleSpriteSheetLoaded = () => {
    setSpriteSheetLoaded(true);
  };

  console.log('Rendering RootContent Component, loading:', loading, 'isMounted:', isMounted);

  return (loading || !isMounted || !spriteSheetLoaded) ? (
    <SplashScreen onLoaded={handleSpriteSheetLoaded} />
  ) : (
    <App />
  );
};

createRoot(document.getElementById('root')!).render(
  <Root />
);

2. AssetProvider.tsx

import { createContext, useContext, useState, ReactNode, useEffect } from 'react';

const AssetContext = createContext<{ [key: string]: string } | null>(null);
const LoadingContext = createContext<boolean>(true);

export const useAssets = () => useContext(AssetContext);
export const useLoading = () => useContext(LoadingContext);

const assetCache = new Map<string, string>();

const preloadAssets = async () => {
  try {
    const assetModules = import.meta.glob('/src/assets/**/*.{png,wav}');
    const assets: { [key: string]: string } = {};

    await Promise.all(
      Object.keys(assetModules).map(async (path) => {
        const normalizedPath = path.replace(/^\/src\/assets\//, 'assets/');
        if (assetCache.has(normalizedPath)) {
          assets[normalizedPath] = assetCache.get(normalizedPath)!;
          return;
        }
        const module = (await assetModules[path]()) as { default: string };
        const resolvedPath = module.default;
        assetCache.set(normalizedPath, resolvedPath);
        assets[normalizedPath] = resolvedPath;
      })
    );

    return assets;
  } catch (error) {
    console.error('Error resolving assets:', error);
    throw error;
  }
};

interface AssetProviderProps {
  children: ReactNode;
}

const AssetProvider: React.FC<AssetProviderProps> = ({ children }) => {
  const [assets, setAssets] = useState<{ [key: string]: string }>({});
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const loadAssets = async () => {
      try {
        const resolvedAssets = await preloadAssets();
        setAssets(resolvedAssets);
      } catch (error) {
        console.error('Error resolving assets:', error);
      } finally {
        setLoading(false);
      }
    };
    loadAssets();
  }, []);

  return (
    <AssetContext.Provider value={assets}>
      <LoadingContext.Provider value={loading}>
        {children}
      </LoadingContext.Provider>
    </AssetContext.Provider>
  );
};

export default AssetProvider;

3. SplashScreen.tsx

import LogoAnimation from './LogoAnimation';

const SplashScreen = ({ onLoaded }: { onLoaded: () => void }) => {
  return (
    <div className="splash-screen">
      <h1>Loading...</h1>
      <div className="loading-indicator">
        <LogoAnimation onLoaded={onLoaded} />
      </div>
    </div>
  );
};

export default SplashScreen;

4. LogoAnimation.tsx

import { useEffect, useRef, useState } from 'react';
import logo from '/src/assets/Sprites/logo.png';

const LogoAnimation = ({ onLoaded }: { onLoaded: () => void }) => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const spriteSheet = useRef<HTMLImageElement | null>(null);

  useEffect(() => {
    const image = new Image();
    image.src = logo;

    image.onload = () => {
      spriteSheet.current = image;
      setIsLoaded(true);
      onLoaded();
    };

    image.onerror = () => {
      console.error('Failed to load image');
    };

    return () => {
      spriteSheet.current = null;
    };
  }, []);

  useEffect(() => {
    if (!spriteSheet.current || !canvasRef.current || !isLoaded) return;

    const ctx = canvasRef.current.getContext('2d');
    if (!ctx) return;

    const frameWidth = 251;
    const frameHeight = 247;
    const framesX = 16;
    const framesY = 2;
    const totalFrames = framesX * framesY;

    let currentFrame = 0;
    let lastTime = 0;
    const frameRate = 1000 / 60;

    const animateSpriteSheet = (timestamp: number) => {
      if (!spriteSheet.current || !canvasRef.current) return;

      const deltaTime = timestamp - lastTime;
      if (deltaTime >= frameRate) {
        lastTime = timestamp;
        const col = currentFrame % framesX;
        const row = Math.floor(currentFrame / framesX);

        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
        ctx.drawImage(
          spriteSheet.current,
          col * frameWidth,
          row * frameHeight,
          frameWidth,
          frameHeight,
          0,
          0,
          frameWidth,
          frameHeight
        );

        currentFrame = (currentFrame + 1) % totalFrames;
      }

      requestAnimationFrame(animateSpriteSheet);
    };

    const animationFrame = requestAnimationFrame(animateSpriteSheet);

    return () => cancelAnimationFrame(animationFrame);
  }, [isLoaded]);

  return (
    <canvas
      ref={canvasRef}
      width={251}
      height={247}
      style={{ display: isLoaded ? 'block' : 'none' }}
    />
  );
};

export default LogoAnimation;

解决方案

核心问题分析

当前代码的矛盾点:

  1. AssetProvider启动后立刻批量预加载所有资源,可能在启动屏动画资源加载/播放完成前就结束加载,导致启动屏提前关闭。
  2. LogoAnimation在图片加载完成后立刻调用onLoaded,此时动画才刚开始,并未完成播放。
  3. RootContent的判断逻辑只要loading为false且spriteSheetLoaded为true,就立刻切换到App,完全未考虑动画播放状态。

具体修复步骤

1. 等待动画播放完成再关闭启动屏

修改LogoAnimation.tsx,确保动画播放完一轮后才触发启动屏关闭:

import { useEffect, useRef, useState } from 'react';
import logo from '/src/assets/Sprites/logo.png';

const LogoAnimation = ({ onLoaded }: { onLoaded: () => void }) => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const spriteSheet = useRef<HTMLImageElement | null>(null);
  const animationComplete = useRef(false);

  useEffect(() => {
    const image = new Image();
    image.src = logo;

    image.onload = () => {
      spriteSheet.current = image;
      setIsLoaded(true);
    };

    image.onerror = () => {
      console.error('Failed to load image');
    };

    return () => {
      spriteSheet.current = null;
    };
  }, []);

  useEffect(() => {
    if (!spriteSheet.current || !canvasRef.current || !isLoaded || animationComplete.current) return;

    const ctx = canvasRef.current.getContext('2d');
    if (!ctx) return;

    const frameWidth = 251;
    const frameHeight = 247;
    const framesX = 16;
    const framesY = 2;
    const totalFrames = framesX * framesY;

    let currentFrame = 0;
    let lastTime = 0;
    const frameRate = 1000 / 60;

    const animateSpriteSheet = (timestamp: number) => {
      if (!spriteSheet.current || !canvasRef.current) return;

      const deltaTime = timestamp - lastTime;
      if (deltaTime >= frameRate) {
        lastTime = timestamp;
        const col = currentFrame % framesX;
        const row = Math.floor(currentFrame / framesX);

        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
        ctx.drawImage(
          spriteSheet.current,
          col * frameWidth,
          row * frameHeight,
          frameWidth,
          frameHeight,
          0,
          0,
          frameWidth,
          frameHeight
        );

        currentFrame++;
        // 播放完所有帧后标记完成并关闭启动屏
        if (currentFrame >= totalFrames) {
          animationComplete.current = true;
          onLoaded();
          return;
        }
      }

      requestAnimationFrame(animateSpriteSheet);
    };

    const animationFrame = requestAnimationFrame(animateSpriteSheet);

    return () => cancelAnimationFrame(animationFrame);
  }, [isLoaded, onLoaded]);

  return (
    <canvas
      ref={canvasRef}
      width={251}
      height={247}
      style={{ display: isLoaded ? 'block' : 'none' }}
    />
  );
};

export default LogoAnimation;

2. 简化启动屏判断逻辑

修改RootContent,确保资源加载完成且动画播放完成后才切换到App:

const RootContent = () => {
  const loading = useLoading();
  const [animationFinished, setAnimationFinished] = useState(false);

  const shouldShowSplash = loading || !animationFinished;

  console.log('Rendering RootContent, loading:', loading, 'animationFinished:', animationFinished);

  return shouldShowSplash ? (
    <SplashScreen onLoaded={() => setAnimationFinished(true)} />
  ) : (
    <App />
  );
};

3. 优先加载启动屏资源

修改AssetProvider.tsx,先加载启动屏的精灵表,再加载其他资源,避免大资源阻塞动画加载:

const preloadAssets = async () => {
  try {
    const assetModules = import.meta.glob('/src/assets/**/*.{png,wav}');
    const assets: { [key: string]: string } = {};

    // 优先加载启动屏精灵表
    const splashAssetPath = '/src/assets/Sprites/logo.png';
    if (assetModules[splashAssetPath]) {
      const normalizedPath = splashAssetPath.replace(/^\/src\/assets\//, 'assets/');
      const module = (await assetModules[splashAssetPath]()) as { default: string };
      const resolvedPath = module.default;
      assetCache.set(normalizedPath, resolvedPath);
      assets[normalizedPath] = resolvedPath;
    }

    // 加载剩余资源
    await Promise.all(
      Object.keys(assetModules)
        .filter(path => path !== splashAssetPath)
        .map(async (path) => {
          const normalizedPath = path.replace(/^\/src\/assets\//, 'assets/');
          if (assetCache.has(normalizedPath)) {
            assets[normalizedPath] = assetCache.get(normalizedPath)!;
            return;
          }
          const module = (await assetModules[path]()) as { default: string };
          const resolvedPath = module.default;
          assetCache.set(normalizedPath, resolvedPath);
          assets[normalizedPath] = resolvedPath;
        })
    );

    return assets;
  } catch (error) {
    console.error('Error resolving assets:', error);
    throw error;
  }
};

4. 可选:添加启动屏最小显示时长

如果资源加载过快,动画来不及播放,可给启动屏设置最小显示时长(比如1.5秒):

const RootContent = () => {
  const loading = useLoading();
  const [animationFinished, setAnimationFinished] = useState(false);
  const [minTimePassed, setMinTimePassed] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => setMinTimePassed(true), 1500);
    return () => clearTimeout(timer);
  }, []);

  const shouldShowSplash = loading || !animationFinished || !minTimePassed;

  return shouldShowSplash ? (
    <SplashScreen onLoaded={() => setAnimationFinished(true)} />
  ) : (
    <App />
  );
};

内容的提问来源于stack exchange,提问作者TheRemaker

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 23:32:32