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

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();
  }
}

问题原因

  1. 订阅未限制触发次数:closeEvent和submitEvent的订阅未做次数限制,若组件内部事件触发逻辑异常,可能导致循环调用。
  2. 未清理组件实例与DOM:关闭模态框时仅完成Subject,未销毁组件实例和移除DOM节点,多次调用open会累积组件,引发栈溢出。
  3. 测试环境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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.04 06:12:02