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

如何在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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.30 03:09:02