如何在React/Next.js中实现高性能的滚动触发动画
卡顿根因
你当前的实现存在两个核心性能问题:
- 每帧更新都触发React state变更,导致整个页面组件频繁重渲染
- 动态切换img标签src,浏览器需要实时请求/解码图片,且每次图片变更都会触发DOM重绘,滚动时帧率会大幅下降
优化方案
我们采用「提前预加载所有序列帧 + Canvas渲染帧」的方案优化,完全规避DOM重绘和重复图片请求的问题。
1. 预加载逻辑说明
我们会在页面初始化阶段就把所有38张序列帧图片全部加载到内存中,存为Image对象数组,后续渲染时直接从内存取对应帧的图片,无需实时请求。
2. 完整优化后代码
import Head from "next/head"; import { motion, useTransform, useViewportScroll, } from "framer-motion"; import { useEffect, useRef, useState } from "react"; export default function Home() { const { scrollY } = useViewportScroll(); // 序列帧总数 const TOTAL_FRAMES = 38; // 预加载的图片数组 const framesRef = useRef<HTMLImageElement[]>([]); // canvas元素引用 const canvasRef = useRef<HTMLCanvasElement>(null); // 预加载完成标记 const [isFramesLoaded, setIsFramesLoaded] = useState(false); // 原Framer Motion动画参数保留 const opacityHero = useTransform(scrollY, [400, 700], [1, 0]); const opacitySecondary = useTransform(scrollY, [700, 1000], [0, 1]); const xSecondary = useTransform(scrollY, [700, 1000], [-150, -50]); // 预加载所有序列帧 useEffect(() => { const loadFrames = async () => { const framePromises: Promise<HTMLImageElement>[] = []; for (let i = 0; i < TOTAL_FRAMES; i++) { // 补0处理帧号 const frameNum = i.toString().padStart(2, '0'); framePromises.push(new Promise((resolve, reject) => { const img = new Image(); img.src = `/images/testAssets-website/Hero_asset_test${frameNum}.webp`; img.onload = () => resolve(img); img.onerror = reject; })); } // 等待所有帧加载完成 framesRef.current = await Promise.all(framePromises); setIsFramesLoaded(true); // 加载完成后先绘制第一帧 drawFrame(0); }; loadFrames(); }, []); // 绘制指定帧到canvas const drawFrame = (frameIndex: number) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx || !framesRef.current[frameIndex]) return; // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制当前帧,可根据实际需求调整绘制尺寸适配屏幕 ctx.drawImage(framesRef.current[frameIndex], 0, 0, canvas.width, canvas.height); }; // 滚动监听更新帧 useEffect(() => { if (!isFramesLoaded) return; return scrollY.onChange(() => { // 计算当前帧号,和原有逻辑一致 let frame = Math.min(Math.floor(scrollY.current * 0.1), TOTAL_FRAMES - 1); drawFrame(frame); }); }, [isFramesLoaded, scrollY]); return ( <div> <div style={{ height: 2400 }} className="bg-white"> <Head> <title>Avatar</title> <link rel="icon" href="/favicon.ico" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet" /> </Head> <div className="sticky top-0"> {/* 替换原来的motion.img为canvas */} <canvas ref={canvasRef} width={1920} {/* 替换为你的序列帧实际宽度 */} height={1080} {/* 替换为你的序列帧实际高度 */} style={{ opacity: opacityHero.get() }} className={`w-screen h-full z-10 ${opacityHero.get() === 0 ? "hidden" : ""}`} /> <motion.div style={{ opacity: opacitySecondary, x: xSecondary, }} className="text-primary z-0 fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" > Yes, we move. </motion.div> </div> </div> </div> ); }
额外优化提示
- 可以根据你的实际画布尺寸调整canvas标签的width和height属性,避免绘制时的拉伸变形
- 如果序列帧体积较大,可以添加加载状态提示,避免用户刚进入页面时看到空白
- 滚动监听的帧计算可以添加节流逻辑,进一步降低性能消耗
内容的提问来源于stack exchange,提问作者shanehanlon
相关产品推荐
相关产品推荐

