子组件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
相关产品推荐
相关产品推荐

