Angular Modal Service触发栈溢出错误,单元测试失败求助
Angular ModalService 单元测试栈溢出问题修复
问题描述
项目中使用如下ModalService时,单元测试抛出RangeError: Maximum Call Stack Size exceed错误,且依赖该服务的组件单元测试也无法通过。
Modal.service.ts
import { DOCUMENT } from '@angular/common'; import { ComponentFactoryResolver, Inject, Injectable, Injector, TemplateRef, } from '@angular/core'; import { Subject } from 'rxjs'; import { ModalComponent } from '../components/modal/modal.component'; @Injectable() export class ModalService { private modalNotifier?: Subject<string>; constructor( private resolver: ComponentFactoryResolver, private injector: Injector, @Inject(DOCUMENT) private document: Document ) {} open(content: TemplateRef<any>, options?: { size?: string; title?: string }) { const modalComponentFactory = this.resolver.resolveComponentFactory( ModalComponent ); const contentViewRef = content.createEmbeddedView(null); const modalComponent = modalComponentFactory.create(this.injector, [ contentViewRef.rootNodes, ]); modalComponent.instance.size = options?.size; modalComponent.instance.title = options?.title; modalComponent.instance.closeEvent.subscribe(() => this.closeModal()); modalComponent.instance.submitEvent.subscribe(() => this.submitModal()); modalComponent.hostView.detectChanges(); this.document.body.appendChild(modalComponent.location.nativeElement); this.modalNotifier = new Subject(); return this.modalNotifier?.asObservable(); } closeModal() { this.modalNotifier?.complete(); } submitModal() { this.modalNotifier?.next('confirm'); this.closeModal(); } }
问题原因
- 订阅未限制触发次数:
closeEvent和submitEvent的订阅未做次数限制,若组件内部事件触发逻辑异常,可能导致循环调用。 - 未清理组件实例与DOM:关闭模态框时仅完成
Subject,未销毁组件实例和移除DOM节点,多次调用open会累积组件,引发栈溢出。 - 测试环境DOM模拟不完整:服务直接操作真实
document,测试中未正确模拟DOM环境,导致组件渲染时的变更检测循环。
解决方案
1. 优化服务逻辑,添加资源清理
修改ModalService实现OnDestroy,限制订阅触发次数,关闭时销毁组件并清理DOM:
// Modal.service.ts 优化后 import { DOCUMENT } from '@angular/common'; import { ComponentFactoryResolver, Inject, Injectable, Injector, TemplateRef, OnDestroy, } from '@angular/core'; import { Subject, take } from 'rxjs'; import { ModalComponent } from '../components/modal/modal.component'; @Injectable() export class ModalService implements OnDestroy { private modalNotifier?: Subject<string>; private modalComponent?: ModalComponent; constructor( private resolver: ComponentFactoryResolver, private injector: Injector, @Inject(DOCUMENT) private document: Document ) {} open(content: TemplateRef<any>, options?: { size?: string; title?: string }) { // 先关闭已有模态框,避免重复创建 this.closeModal(); const modalComponentFactory = this.resolver.resolveComponentFactory(ModalComponent); const contentViewRef = content.createEmbeddedView(null); this.modalComponent = modalComponentFactory.create(this.injector, [contentViewRef.rootNodes]); this.modalComponent.instance.size = options?.size; this.modalComponent.instance.title = options?.title; // 使用take(1)确保订阅只触发一次 this.modalComponent.instance.closeEvent.pipe(take(1)).subscribe(() => this.closeModal()); this.modalComponent.instance.submitEvent.pipe(take(1)).subscribe(() => this.submitModal()); this.modalComponent.hostView.detectChanges(); this.document.body.appendChild(this.modalComponent.location.nativeElement); this.modalNotifier = new Subject(); return this.modalNotifier.asObservable(); } closeModal() { if (this.modalComponent) { this.modalComponent.destroy(); this.modalComponent.location.nativeElement.remove(); this.modalComponent = undefined; } this.modalNotifier?.complete(); this.modalNotifier = undefined; } submitModal() { this.modalNotifier?.next('confirm'); this.closeModal(); } ngOnDestroy() { this.closeModal(); } }
2. 编写单元测试时模拟依赖
使用Angular测试工具模拟ComponentFactoryResolver、DOCUMENT等依赖,避免真实组件渲染:
// modal.service.spec.ts import { TestBed } from '@angular/core/testing'; import { DOCUMENT } from '@angular/common'; import { ComponentFactoryResolver, Injector } from '@angular/core'; import { ModalService } from './modal.service'; import { ModalComponent } from '../components/modal/modal.component'; import { Subject } from 'rxjs'; describe('ModalService', () => { let service: ModalService; let mockCFR: jasmine.SpyObj<ComponentFactoryResolver>; let mockDocument: jasmine.SpyObj<Document>; beforeEach(() => { // 模拟ModalComponent实例 const mockModalInstance = { size: '', title: '', closeEvent: new Subject<void>(), submitEvent: new Subject<void>(), destroy: jasmine.createSpy('destroy') }; const mockComponentFactory = jasmine.createSpyObj('ComponentFactory', ['create']); mockComponentFactory.create.and.returnValue({ instance: mockModalInstance, hostView: { detectChanges: jasmine.createSpy('detectChanges') }, location: { nativeElement: document.createElement('div') } }); mockCFR = jasmine.createSpyObj('ComponentFactoryResolver', ['resolveComponentFactory']); mockCFR.resolveComponentFactory.and.returnValue(mockComponentFactory); mockDocument = jasmine.createSpyObj('Document', ['appendChild']); TestBed.configureTestingModule({ providers: [ ModalService, { provide: ComponentFactoryResolver, useValue: mockCFR }, { provide: Injector, useValue: jasmine.createSpyObj('Injector', []) }, { provide: DOCUMENT, useValue: mockDocument } ] }); service = TestBed.inject(ModalService); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should close modal and clean resources on closeEvent', () => { const mockTemplate = jasmine.createSpyObj('TemplateRef', ['createEmbeddedView']); mockTemplate.createEmbeddedView.and.returnValue({ rootNodes: [] }); const modal$ = service.open(mockTemplate); let isClosed = false; modal$.subscribe({ complete: () => isClosed = true }); // 触发关闭事件 const modalInstance = mockCFR.resolveComponentFactory(ModalComponent).create().instance; modalInstance.closeEvent.next(); expect(isClosed).toBeTrue(); expect(modalInstance.destroy).toHaveBeenCalled(); }); it('should emit confirm and close on submitEvent', () => { const mockTemplate = jasmine.createSpyObj('TemplateRef', ['createEmbeddedView']); mockTemplate.createEmbeddedView.and.returnValue({ rootNodes: [] }); const modal$ = service.open(mockTemplate); let isConfirmed = false; modal$.subscribe({ next: (val) => val === 'confirm' && (isConfirmed = true) }); // 触发提交事件 const modalInstance = mockCFR.resolveComponentFactory(ModalComponent).create().instance; modalInstance.submitEvent.next(); expect(isConfirmed).toBeTrue(); expect(modalInstance.destroy).toHaveBeenCalled(); }); });
3. 组件测试中模拟ModalService
在依赖该服务的组件测试中,提供模拟服务,避免真实DOM操作:
// example.component.spec.ts import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ExampleComponent } from './example.component'; import { ModalService } from '../modal/services/modal.service'; import { of } from 'rxjs'; describe('ExampleComponent', () => { let component: ExampleComponent; let fixture: ComponentFixture<ExampleComponent>; let mockModalService: jasmine.SpyObj<ModalService>; beforeEach(async () => { mockModalService = jasmine.createSpyObj('ModalService', ['open']); mockModalService.open.and.returnValue(of('confirm')); await TestBed.configureTestingModule({ declarations: [ExampleComponent], providers: [{ provide: ModalService, useValue: mockModalService }] }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ExampleComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should call modal service on button click', () => { const button = fixture.nativeElement.querySelector('.open-modal-btn'); button.click(); expect(mockModalService.open).toHaveBeenCalled(); }); });
内容的提问来源于stack exchange,提问作者Gobinath M
相关产品推荐
相关产品推荐

