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

子组件Output触发错误Observable,页面数据异常问题排查

技术问题:NgRx状态更新导致父组件错误订阅数据

子组件为模态框,其搜索框输入后向父组件发送事件,父组件通过NgRx Store Facade查询数据。父组件初始化时订阅所有Facade的Observable,再将数据传给子组件渲染。

预期行为

  • 子组件搜索,将查询字符串发送给父组件
  • 父组件接收事件,通过Facade发起查询
  • Facade向Store分发Action,Effect执行获取新数据
  • 父组件订阅的Observable接收更新数据,传递给子组件
  • 子组件渲染新数据

当前行为

  • 子组件搜索,成功发送查询字符串给父组件
  • 父组件成功接收事件并通过Facade查询
  • Facade成功分发Action,Effect成功获取新数据
  • 父组件接收数据时,先通过错误的Observable传递,再通过目标Observable传递
  • 子组件渲染新数据,但模态框背后的页面数据损坏

本质问题:子组件的事件触发了父组件的两个订阅:第一个订阅收到Device[]类型数据并传给列表组件导致其崩溃,第二个订阅同样收到Device[]数据却渲染正常。


代码片段

父组件

@Component({
    selector: 'app-marketplace',
    templateUrl: './marketplace.component.html',
    styleUrls: ['./marketplace.component.sass'],
})
export class MarketplaceComponent {
    public limit: number = 18;
    public offset: number = 0;

    public postings!: Posting[] | null;
    public devices!: Device[] | null;

    constructor(
        public modal: ModalService,
        private postingFacade: PostingFacade,
        private userFacade: UserFacade
    ) {
        this.postingFacade.postingData$.subscribe({
            next: (data: Posting[] | null) => (this.postings = data),
            error: (err: string | null) => console.log(err),
        });
        this.postingFacade.devicesData$.subscribe({
            next: (data: Device[] | null) => (this.devices = data),
            error: (err: string | null) => console.log(err),
        });
    }

    searchPostings(query: string): void {
        this.limit = 18;
        this.postingFacade.queryPostings(query, this.limit, this.offset);
    }

    fetchDeviceList(query: string): void {
        this.postingFacade.queryDevices(query, 10);
    }

    fetchDeviceDetails(key: string): void {
        this.postingFacade.getDeviceDetails(key);
    }
}

子组件

@Component({
    selector: 'app-create',
    templateUrl: './create.component.html',
    styleUrls: ['./create.component.sass'],
})
export class CreateComponent implements AfterViewInit {
    public display$!: Observable<boolean>;

    @Input() user!: User | null;
    @Input() devices!: Device[] | null;
    @Input() deviceDetails!: Device | null;
    @Output() requestDeviceList = new EventEmitter<string>();

    @ViewChild('searchInput') searchDevice!: ElementRef<HTMLInputElement>;

    constructor(private modal: ModalService, private fb: FormBuilder) {
        this.display$ = this.modal.watch();
    }

    ngAfterViewInit() {
        fromEvent(this.searchDevice.nativeElement, 'input')
            .pipe(
                debounceTime(1000),
                distinctUntilChanged(),
                map((e: Event) => (e.target as HTMLInputElement).value)
            )
            .subscribe({
                next: (res) => {
                    this.deviceDetails = null;
                    this.requestDeviceList.emit(res);
                },
                error: (err) => console.log(err),
            });
    }
}

Facade

@Injectable()
export class PostingFacade {
    constructor(private readonly store: Store<PostingState>) {}

    public readonly postingData$: Observable<Posting[] | null> = this.store.pipe(
        select(postingSelectors.getPostings)
    );

    public readonly devicesData$: Observable<Device[] | null> = this.store.pipe(
        select(postingSelectors.getPostingDevices)
    );

    public initPostingsData(): void {
        this.store.dispatch(postingActions.PostingInit({ limit: 18, offset: 0 }));
    }

    public loadMorePostings(limit: number, offset: number): void {
        this.store.dispatch(postingActions.PostingLoadMore({ limit, offset }));
    }

    public queryPostings(query: string, limit: number, offset: number): void {
        this.store.dispatch(postingActions.PostingSearch({ query, limit, offset }));
    }

    public queryDevices(query: string, limit: number): void {
        this.store.dispatch(postingActions.PostingLoadDevices({ query, limit }));
    }

    public getDeviceDetails(key: string): void {
        this.store.dispatch(postingActions.PostingLoadDeviceDetails({ key }));
    }
}

Effects

public readonly getPostings$: Observable<any> = createEffect(() =>
    this.actions$.pipe(
        ofType(PostingActionNames.PostingInit),
        map(({ limit, offset }) => PostingActions.PostingInit({ offset, limit })),
        switchMap(({ limit, offset }) =>
            this.postingService
                .getPostings(limit, offset)
                .pipe(
                    map((data: Posting[]) => PostingActions.PostingInitSuccess({ data }))
                )
        ),
        catchError((error: string | null) =>
            of(PostingActions.PostingInitFailure({ error }))
        )
    )
);

public readonly searchDevices$: Observable<any> = createEffect(() =>
    this.actions$.pipe(
        ofType(PostingActionNames.PostingLoadDevices),
        map(({ query, limit }) =>
            PostingActions.PostingLoadDevices({ query, limit })
        ),
        switchMap(({ query, limit }) =>
            this.postingService
                .searchDevices(query, limit)
                .pipe(
                    map((data: Device[]) =>
                        PostingActions.PostingLoadDevicesSuccess({ data })
                    )
                )
        ),
        catchError((error: string | null) =>
            of(PostingActions.PostingLoadDevicesFailure({ error }))
        )
    )
);

Reducer

export const _postingReducer = createReducer(
    initialPostingState,
    on(postingActions.PostingInit, (state) => ({
        ...state,
        loaded: false,
        error: null,
    })),
    on(postingActions.PostingInitSuccess, (state, { data }) => ({
        ...state,
        postings: data,
        loaded: true,
        error: null,
    })),
    on(postingActions.PostingInitFailure, (state, { error }) => ({
        ...state,
        loaded: false,
        error,
    })),
    on(postingActions.PostingLoadDevices, (state) => ({
        ...state,
        loaded: false,
        error: null,
    })),
    on(postingActions.PostingLoadDevicesSuccess, (state, { data }) => ({
        ...state,
        devices: data,
        loaded: true,
        error: null,
    })),
    on(postingActions.PostingLoadDevicesFailure, (state, { error }) => ({
        ...state,
        loaded: false,
        error,
    })),
);

export function postingReducer(
    state: PostingState | undefined,
    action: Action
) {
    return _postingReducer(state, action);
}

Selectors

export const getPostings = createSelector(
    getPostingState,
    (state: PostingState) => state.postings
);

export const getPostingDevices = createSelector(
    getPostingState,
    (state: PostingState) => state.devices
);

Services

getPostings(limit: number, offset: number): Observable<any> {
    this.http
        .post(
            `${server}/postings/list`,
            { limit, offset },
            {
                headers: this.headers,
            }
        )
        .subscribe((res) => this.request.next(res));
    return this.request;
}

searchDevices(query: string, limit: number): Observable<any> {
    this.http
        .post(
            `${server}/device/list/search/?query=${query}`,
            { limit },
            {
                headers: this.headers,
            }
        )
        .subscribe((res) => this.request.next(res));
    return this.request;
}

State

export const POSTING_FEATURE_KEY = 'posting';

export const initialPostingState: PostingState = {
    postings: null,
    postingsDetails: null,
    devices: null,
    devicesDetails: null,
    create: null,
    user: null,
    loaded: false,
    error: null,
};

export interface PostingState {
    postings: Posting[] | null;
    postingsDetails: Posting | null;
    devices: Device[] | null;
    devicesDetails: Device | null;
    create: Posting | null;
    user: User | null;
    loaded: boolean;
    error: null | string;
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.20 12:03:30