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

React视频轮播组件未卸载致媒体服务器请求过载排查

问题描述

我部署了基于React + TypeScript的单页应用(SPA)更新页面,页面包含一个视频轮播组件,默认展示5个视频,每5秒自动切换视频列表(用户播放视频时暂停切换)。测试环境表现正常,但上线后因真实流量较大,导致存储媒体文件的媒体服务器崩溃。我推测是轮播组件未正确卸载视频组件,导致已不在轮播中展示的视频仍保持网络请求连接,但对网络相关问题并不熟悉。以下是相关组件代码,请求排查该架构中潜在的网络或内存泄漏问题:


LandingPage组件

import { FC, useEffect, useState } from 'react';

//types
import { Testimonial } from './LandingPage.d';

// components
import SocietyInfoMobile from 'components/SocietyInfoMobile/SocietyInfoMobile';
import CtaBlock from './CtaBlock/CtaBlock.component';
import TestimonialCard from './TestimonialCard/TestimonialCard.component';
import TestimonialsCarousel from './TestimonialsCarousel';
import TestimonialsCarouselMobile from './TestimonialsCarousel/TestimonialsCarouselMobile'; 

// hooks
import useAppSelector from 'hooks/useAppSelector';
import { useGoogleReCaptcha } from "react-google-recaptcha-v3";

//utils
import { getTestimonials } from './services/LandingPage.services';

//assets
import { ReactComponent as BulletIcon } from './assets/bulletIcon.svg';

//components
import LoadingMask from 'components/ui/LoadingMask';

// style
import { Page, TopCardsRow, ProfitsCard, BulletsCard, BulletsItem, BulletsBox, AllTestimonialsBox, AllTestimonialsTitle, AllTestimonials } from './style/LandingPage.style';


const LandingPage: FC<{}> = () => {
    const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
    const [isLoading, setIsLoading] = useState(true);

    const { isAppMobile } = useAppSelector( state => state.ui);
    const { executeRecaptcha } = useGoogleReCaptcha();

    const filterSortTestimonials = (testimonials: Testimonial[]) => {
        return [...testimonials
                .filter(t => t.visible && t.video)
            ]
    }

    const getAllTestimonials = async () => {
        try {
            if (!executeRecaptcha || typeof window === 'undefined') return;
            setTimeout(async () => {
                const reCaptchaResponse = await executeRecaptcha('testimonials');
                const response: Testimonial[] = await getTestimonials(reCaptchaResponse);
                console.log('response', response);
                setTestimonials(filterSortTestimonials(response));
                setIsLoading(false);
            }, 100); // Add a delay of 100ms or adjust accordingly
        } catch (error) {
            console.log(error);
        }
    }

    useEffect(() => {
        if (executeRecaptcha) {
            getAllTestimonials();
        }
    }, [executeRecaptcha]);

    return (
        <Page>
            <TopCardsRow>
                {/* Other stuff */}
            {isLoading && (
                    <LoadingMask
                        isCenter
                        size='big'
                    />
            )}

            {isAppMobile && !isLoading && (
                <TestimonialsCarouselMobile testimonials={testimonials}/>
            )}

            {!isAppMobile && !isLoading && (
                <TestimonialsCarousel testimonials={testimonials}/>
            )}
            
            {/* Other Stuff */}
        </Page>
    );
}

export default LandingPage;

TestimonialsCarousel组件

import { FC, useEffect, useRef, useState } from "react";

//types
import { TestimonialsCarouselProps} from './TestimonialsCarousel.component.d'

//components
import TestimonialCard from '../TestimonialCard/TestimonialCard.component'

// assets
import { ReactComponent as ArrowLeftSVG } from "./assets/arrow-left-icon.svg";
import { ReactComponent as ArrowRightSVG } from "./assets/arrow-right-icon.svg";

//style
import { CardBack, CardFront, CardWrapper, Carousel, Dot, Dots, NextButton, PrevButton, SliderWrapper, TestimonialsCarouselBox } from "./style/TestimonialsCarousel.component.style";
import { getCenteredSubArray } from "./TestimonialsCarousel.helper";

const TestimonialsCarousel: FC<TestimonialsCarouselProps> = ({ testimonials }) => {
  const [currentSlide, setCurrentSlide] = useState(0);
  const [isVideoPaused, setIsVideoPaused] = useState(true);

  const intervalRef = useRef<NodeJS.Timeout>();

  const startInterval = () => {
    intervalRef.current = setInterval(() => {  
      handleNextClick();
    }, 5000);
  }

  const stopInterval = () => {
    clearInterval(intervalRef.current);
  }

  const handlePrevClick = () => {
    setCurrentSlide(currentSlide => currentSlide === 0 ? testimonials.length - 1 : currentSlide - 1);
    stopInterval();
    startInterval();
    setIsVideoPaused(true)
  };

  const handleNextClick = () => {  
    setCurrentSlide(currentSlide => currentSlide === testimonials.length - 1 ? 0 : currentSlide + 1);
    stopInterval();
    startInterval();
    setIsVideoPaused(true)
  };

  const handleClickCard = (title: string, cIndex: number) => { 
      cIndex > 0 ? handleNextClick() : handlePrevClick();
  }


  useEffect(() => {
    if (isVideoPaused) startInterval();
    if (!isVideoPaused) stopInterval();
  }, [isVideoPaused]);

  // const handleMouseEnter = () => {
  //   setIsHovering(true);
  // }
  
  // const handleMouseLeave = () => {
  //   setIsHovering(false);
  // }


  return (
    <TestimonialsCarouselBox >
        <Carousel>
            <PrevButton onClick={handlePrevClick}>
                <ArrowLeftSVG/>
            </PrevButton>

            <CardWrapper>
                {getCenteredSubArray(testimonials, currentSlide, 5).map((testimonial, index) => {
                return (
                    <TestimonialCard 
                      key={testimonial.description} 
                      testimonial={testimonial} 
                      cIndex={index-2} 
                      inCarousel={true} 
                      handleClickCard={handleClickCard}
                      setIsVideoPaused={setIsVideoPaused}
                    />
                );
                })}
            </CardWrapper>
            
            <NextButton onClick={handleNextClick}>
                <ArrowRightSVG/>
            </NextButton>
        </Carousel>
        <Dots>
            {getCenteredSubArray(testimonials, currentSlide, 7).map((testimonial, index) => {
                    return (
                        <Dot key={testimonial.description} cIndex={index-3} />
                    );
                    })}
        </Dots> 
    </TestimonialsCarouselBox>
  );
};



  export default TestimonialsCarousel;

TestimonialCard组件

import { FC, useEffect, useState } from 'react';

//hooks
import useAppSelector from 'hooks/useAppSelector';
import {useSwipeable} from 'react-swipeable'

// types
import { TestimonialCardProps } from './TestimonialCard.d';

//components
import VideoPlayerLight from 'components/VideoPlayerLight';

// style
import {
    TestimonialWrapper,
    VideoCard,
    InfoCard,
    TestimonialTitle,
    TestimonialTag,
    TestimonialInfo,
    TestimonialLabel,
    DarkOverlay,
    TestimonialProfilePic,
    TestimonialTagBox,
    TestimonialProfilePicBackground
} from "./style/TestimonialCard.style";


const TestimonialCard: FC<TestimonialCardProps> = ({
    testimonial,
    cIndex,
    inCarousel,
    handleClickCard,
    onSwipe,
    setIsVideoPaused
}) => {

    const [opacity, setOpacity] = useState(onSwipe !== undefined ? 0 : 1);

    // Handle null/undefined properties using optional chaining and default values
    const videoUrl = testimonial?.video?.url ?? '';
    const coverUrl = testimonial?.videoCover?.url ?? '';
    const profilePicUrl = testimonial?.profilePicture?.url ?? '';
    const title = testimonial?.title ?? '';
    const description = testimonial?.description ?? '';

    const { isAppMobile } = useAppSelector( state => state.ui);

    const onClickHandler = () => {
        if (handleClickCard && title && cIndex) handleClickCard(title, cIndex)
    }

    const swipeHandlers = useSwipeable({
        onSwipedLeft: () => {
            if (onSwipe)onSwipe('left');
        },
        onSwipedRight: () => {
            if (onSwipe) onSwipe('right');
        }});

    
    useEffect(() => {        
        setTimeout(() => {
            setOpacity(1);
          }, 50);
        return () => setOpacity(0);
    }, []);


    return (
        <TestimonialWrapper
            key={description}
            opacity={opacity}
            inCarousel={inCarousel} 
            cIndex={cIndex} 
            onClick={onClickHandler}
            {...swipeHandlers} // add swipe handlers
        >
            {cIndex !== undefined && !isAppMobile && <DarkOverlay cIndex={cIndex} /> }
            <VideoCard inCarousel={inCarousel}>
                <VideoPlayerLight videoUrl={videoUrl} posterUrl={coverUrl} setIsVideoPaused={setIsVideoPaused}/>
            </VideoCard>

            {/* Other stuff */}
           
        </TestimonialWrapper>
    );

};

export default TestimonialCard;

VideoPlayerLight组件

import { FC, useState, useRef } from 'react';

//types
import {VideoPlayerLightProps} from './VideoPlayerLight.component.d'
import { PlayButton, VideoElement, VideoPlayerContainer } from './style/VideoPlayerLight.component.style';

//assets
import { ReactComponent as PlayOverlayButtonSVG } from './assets/play-overlay-button.svg';


const VideoPlayerLight: FC<VideoPlayerLightProps> = ({ videoUrl, posterUrl, setIsVideoPaused }) => {
    const videoRef = useRef<HTMLVideoElement>(null);
    const [playing, setPlaying] = useState(false);
    const [showControls, setShowControls] = useState(false);
  
    const handlePlayButtonClick = () => {
      if (videoRef.current) {
        videoRef.current.play();
        setPlaying(true);
        setShowControls(true);        
        if (setIsVideoPaused) setIsVideoPaused(false);
      }
    };
  
    const handleVideoClick = () => {
      if (videoRef.current && !playing) {
        videoRef.current.play();
        setPlaying(true);
        setShowControls(true);
        if (setIsVideoPaused) setIsVideoPaused(false);
      }
    };
  
    return (
      <VideoPlayerContainer>
        <VideoElement
          ref={videoRef}
          src={videoUrl}
          poster={posterUrl}
          onClick={handleVideoClick}
          controls={showControls}
        />
        {!playing && (
            <PlayButton onClick={handlePlayButtonClick}>
                <PlayOverlayButtonSVG/>
            </PlayButton>
        )}
      </VideoPlayerContainer>
    );
  };

export default VideoPlayerLight;

问题排查与修复方案

核心问题

上线后媒体服务器崩溃的根本原因是轮播切换时,已移出视野的视频组件未被正确清理,导致<video>元素持续占用网络资源:

  1. React列表渲染复用组件,旧视频组件未被卸载,<video>仍保持媒体服务器连接
  2. 视频组件缺少卸载时的资源释放逻辑,即使组件被卸载,也未终止视频加载请求
  3. 视频默认预加载,未播放的视频也会主动请求媒体文件,放大流量消耗

具体修复步骤

1. 给VideoPlayerLight添加卸载清理逻辑

组件卸载时,暂停视频、清空资源链接,强制终止网络请求:

import { FC, useState, useRef, useEffect } from 'react';

// ... 原有代码

const VideoPlayerLight: FC<VideoPlayerLightProps> = ({ videoUrl, posterUrl, setIsVideoPaused }) => {
    const videoRef = useRef<HTMLVideoElement>(null);
    const [playing, setPlaying] = useState(false);
    const [showControls, setShowControls] = useState(false);

    // 新增:组件卸载时清理视频资源
    useEffect(() => {
        return () => {
            if (videoRef.current) {
                videoRef.current.pause();
                videoRef.current.src = ''; // 清空src终止请求
                videoRef.current.load(); // 触发资源释放
            }
            if (setIsVideoPaused) setIsVideoPaused(true);
        };
    }, []);

    // ... 原有方法

    return (
      <VideoPlayerContainer>
        <VideoElement
          ref={videoRef}
          src={videoUrl}
          poster={posterUrl}
          onClick={handleVideoClick}
          controls={showControls}
          preload="none" // 新增:默认不预加载视频
        />
        {!playing && (
            <PlayButton onClick={handlePlayButtonClick}>
                <PlayOverlayButtonSVG/>
            </PlayButton>
        )}
      </VideoPlayerContainer>
    );
};

2. 优化轮播组件的key值

使用唯一ID替换description作为组件key,避免React复用旧视频组件,确保切换时旧组件被完全卸载:

// 在TestimonialsCarousel组件中修改TestimonialCard的key
<TestimonialCard 
  key={testimonial.id} // 替换为testimonial的唯一ID字段
  testimonial={testimonial} 
  cIndex={index-2} 
  inCarousel={true} 
  handleClickCard={handleClickCard}
  setIsVideoPaused={setIsVideoPaused}
/>

3. 修复轮播定时器的内存泄漏

在TestimonialsCarousel组件中,添加组件卸载时的定时器清理逻辑:

// 修改TestimonialsCarousel中的useEffect
useEffect(() => {
    if (isVideoPaused) startInterval();
    if (!isVideoPaused) stopInterval();

    // 新增:组件卸载时清理定时器
    return () => stopInterval();
}, [isVideoPaused]);

4. 轮播切换时强制暂停所有视频

在handlePrevClick和handleNextClick中,除了设置setIsVideoPaused(true),还可以通过全局ref集合控制所有视频暂停(可选,依赖组件卸载清理的话可省略)。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.22 22:39:52