使用Jest测试React中RxJS订阅超时如何正确取消订阅
问题根因
- 测试仅完成了组件渲染和快照校验,没有主动触发组件卸载,useEffect中返回的退订逻辑从未执行,rxjs的timer订阅持续持有事件循环引用,导致Jest无法正常结束进程。
- 组件内的
timerStart$、timerStop$、timer$直接写在组件函数体中,每次组件重渲染都会生成新的实例,旧实例的订阅无法被正常清理,存在固有内存泄漏隐患。 handleSliderItemClick中创建的timer(delay)没有调用subscribe也没有对应清理逻辑,属于隐藏的泄漏点。- 原测试用例未传入必填prop
onActiveItemChange,也可能引发意料之外的运行时错误。
修复步骤
1. 调整组件内部逻辑,完善订阅管理
把rxjs相关实例用useRef存储,避免重渲染丢失引用,同时补全所有订阅的清理逻辑:
import React, { useEffect, useState, useRef } from "react"; import { ISlide } from "./ISlide"; import styled from "styled-components"; import { BehaviorSubject, Subject, timer, Subscription } from "rxjs"; import { repeatWhen, take, takeUntil, takeWhile, tap } from "rxjs/operators"; interface ISliderProps { slides: ISlide[]; activeSlide?: number; onActiveItemChange: (index: number) => void; itemsPerPage?: number; delay?: number; interval?: number; loop?: boolean; } const SliderWrapper = styled.ul` /* 原有样式 */ `; const SliderItem = styled.li<{active: boolean}>` /* 原有样式 */ `; export const Slider = (props: ISliderProps) => { const { slides, activeSlide = 0, onActiveItemChange, itemsPerPage = 1, delay = 5000, interval = 5000, loop = true } = props; const [currentActiveSlide, setCurrentActiveSlide] = useState(activeSlide); const sliderCount = Math.ceil(slides.length / itemsPerPage); // 用useRef存储rxjs实例,避免重渲染丢失 const timerStart$ = useRef(new BehaviorSubject(null)).current; const timerStop$ = useRef(new Subject<null>()).current; // 存储所有订阅,统一清理 const subscriptions = useRef(new Subscription()).current; const timer$ = timer(delay, interval).pipe( takeWhile(() => loop), takeUntil(timerStop$), repeatWhen(() => timerStart$) ); useEffect(() => { const timerSubscription = timer$.subscribe({ next: (i) => { const nextSlide = i >= sliderCount ? i % sliderCount : i; onActiveItemChange(nextSlide); setCurrentActiveSlide(nextSlide); } }); // 把订阅加入统一管理 subscriptions.add(timerSubscription); // 卸载时清理所有订阅,同时complete掉subject return () => { subscriptions.unsubscribe(); timerStop$.complete(); timerStart$.complete(); }; // 依赖补全,参数变更时重新初始化订阅 }, [loop, delay, interval, sliderCount, onActiveItemChange]); const handleSliderItemClick = (i: number) => { setCurrentActiveSlide(i); timerStop$.next(null); // 补全订阅并加入统一管理 const clickTimerSub = timer(delay).pipe( take(1), tap(() => timerStart$.next(null)) ).subscribe(); subscriptions.add(clickTimerSub); }; return ( <> <SliderWrapper> {currentActiveSlide} {slides.map(({ id }, i) => <SliderItem key={id} aria-label={"scroll page number " + id} onClick={() => handleSliderItemClick(i)} active={i === currentActiveSlide} />)} </SliderWrapper> </> ); };
2. 调整测试用例,主动卸载组件
测试完成后调用unmount触发组件清理逻辑,也可以搭配假定时器进一步提升测试效率,无需使用异步done回调:
import React from "react"; import { Slider } from "../../../components/slider/Slider"; import renderer, { act } from "react-test-renderer"; import { ISlide } from "../../../components/slider/ISlide"; it("renders without crashing", () => { // 启用假定时器,避免真实定时器占用事件循环 jest.useFakeTimers(); const slides: ISlide[] = [ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }, { id: 8 } ]; let component; act(() => { component = renderer.create(<Slider itemsPerPage={3} slides={slides} activeSlide={0} onActiveItemChange={() => {}} />); // 执行所有待运行的定时器 jest.runAllTimers(); }); let tree = component.toJSON(); expect(tree).toMatchSnapshot(); // 主动卸载组件,触发清理逻辑 act(() => { component.unmount(); }); // 恢复定时器 jest.useRealTimers(); });
内容的提问来源于stack exchange,提问作者Owen Kelvin
相关产品推荐
相关产品推荐

