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>元素持续占用网络资源:
- React列表渲染复用组件,旧视频组件未被卸载,
<video>仍保持媒体服务器连接 - 视频组件缺少卸载时的资源释放逻辑,即使组件被卸载,也未终止视频加载请求
- 视频默认预加载,未播放的视频也会主动请求媒体文件,放大流量消耗
具体修复步骤
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
相关产品推荐
相关产品推荐

