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;
解决方案
核心问题分析
当前代码的矛盾点:
AssetProvider启动后立刻批量预加载所有资源,可能在启动屏动画资源加载/播放完成前就结束加载,导致启动屏提前关闭。LogoAnimation在图片加载完成后立刻调用onLoaded,此时动画才刚开始,并未完成播放。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
相关产品推荐
相关产品推荐

