使用 ngrx/store 在 Angular 2 中实现搜索

3

我正在尝试为一个使用Angular 4编写的应用程序实现搜索功能。它主要是针对显示大量数据的表格。我还添加了ngrx store。 在具有store的应用程序中实现搜索的正确方法是什么? 目前,每次进行搜索查询时,我都会清除存储器,然后用从后端异步调用收到的数据填充它。然后我将这些数据显示在HTML中。异步调用是从effects文件中进行的。


你能详细说明一下吗?搜索是否会触发异步调用,还是更像一个筛选功能,其中“搜索查询”仅应用于接收到的数据集? - amu
是的,搜索会触发异步调用。 - Arjun Singh
3个回答

3

这是一个老问题,但我认为它需要更具体的例子。

由于每个搜索基本上都是唯一的,我也清除了结果。但是,由于结果列表可能很长,我不想全部显示它们,所以我加载了所有结果(由API配置的合适值为前提),但使用分页来显示它们。

以下使用了 Angular 7 + ngrx/store。

动作

import { Action } from "@ngrx/store";
import { PostsSearchResult } from "../models/posts-search-result";

export enum PostsSearchActionType {
    PostsSearchResultRequested = "[View Search Results] Search Results Requested",
    PostsSearchResultLoaded = "[Search Results API] Search Results Loaded",

    PostsSearchResultsClear = "[View Search Results Page] Search Results Page Clear",
    PostsSearchResultsPageRequested = "[View Search Results Page] Search Results Page Requested",
    PostsSearchResultsPageLoaded = "[Search Results API] Search Results Page Loaded",
    PostsSearchResultsPageCancelled = "[Search Results API] Search Results Page Cancelled",
}

export class PostsSearchResultsClearAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsClear;

  constructor() {
  }
}

export class PostsSearchPageRequestedAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsPageRequested;

  constructor(public payload: { searchText: string }) {
  }
}

export class PostsSearchRequestedAction implements Action {
    readonly type = PostsSearchActionType.PostsSearchResultRequested;

    constructor(public payload: { searchText: string }) {
    }
}

export class PostsSearchLoadedAction implements Action {
    readonly type = PostsSearchActionType.PostsSearchResultLoaded;

    constructor(public payload: { results: PostsSearchResult[] }) {
    }
}

export class PostsSearchResultsPageLoadedAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsPageLoaded;

  constructor(public payload: { searchResults: PostsSearchResult[] }) {
  }
}

export class PostsSearchResultsPageCancelledAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsPageCancelled;
}

export type PostsSearchAction =
    PostsSearchResultsClearAction |
    PostsSearchRequestedAction |
    PostsSearchLoadedAction |
    PostsSearchPageRequestedAction |
    PostsSearchResultsPageLoadedAction |
    PostsSearchResultsPageCancelledAction;

效果

只有一种效果是在需要时加载数据。即使使用分页显示数据,搜索结果也会一次性从服务器获取。

import { Injectable } from "@angular/core";
import { Actions, Effect, ofType } from "@ngrx/effects";
import { Store, select } from "@ngrx/store";
import { AppState } from "src/app/reducers";

import {  mergeMap, map, catchError, tap, switchMap } from "rxjs/operators";
import { of } from "rxjs";
import { PostsService } from "../services/posts.service";
// tslint:disable-next-line:max-line-length
import { PostsSearchRequestedAction, PostsSearchActionType, PostsSearchLoadedAction, PostsSearchPageRequestedAction, PostsSearchResultsPageCancelledAction, PostsSearchResultsPageLoadedAction } from "./posts-search.actions";
import { PostsSearchResult } from "../models/posts-search-result";
import { LoggingService } from "src/app/custom-core/general/logging-service";
import { LoadingStartedAction } from "src/app/custom-core/loading/loading.actions";
import { LoadingEndedAction } from "../../custom-core/loading/loading.actions";

@Injectable()
export class PostsSearchEffects {

  constructor(private actions$: Actions, private postsService: PostsService, private store: Store<AppState>,
    private logger: LoggingService) {
  }

  @Effect()
  loadPostsSearchResults$ = this.actions$.pipe(
    ofType<PostsSearchRequestedAction>(PostsSearchActionType.PostsSearchResultRequested),
    mergeMap((action: PostsSearchRequestedAction) => this.postsService.searchPosts(action.payload.searchText)),
    map((results: PostsSearchResult[]) => {
      return new PostsSearchLoadedAction({ results: results });
    })
  );

  @Effect()
  loadSearchResultsPage$ = this.actions$.pipe(
      ofType<PostsSearchPageRequestedAction>(PostsSearchActionType.PostsSearchResultsPageRequested),

    switchMap(({ payload }) => {
      this.logger.logTrace("loadSearchResultsPage$ effect triggered for type PostsSearchResultsPageRequested");

      this.store.dispatch(new LoadingStartedAction({ message: "Searching ..."}));

      return this.postsService.searchPosts(payload.searchText).pipe(
        tap(_ => this.store.dispatch(new LoadingEndedAction())),
        catchError(err => {
          this.store.dispatch(new LoadingEndedAction());
          this.logger.logErrorMessage("Error loading search results: " + err);

          this.store.dispatch(new PostsSearchResultsPageCancelledAction());
          return of(<PostsSearchResult[]>[]);
        })
      );
    }),
      map(searchResults => {
        // console.log("loadSearchResultsPage$ effect searchResults: ", searchResults);
      const ret = new PostsSearchResultsPageLoadedAction({ searchResults });
      this.logger.logTrace("loadSearchResultsPage$ effect PostsSearchResultsPageLoadedAction: ", ret);
      return ret;
    })
  );

}

Reducers

These handle the dispatched actions. Each search will trigger a clear of existing information. However, each page request will used the already loaded information.

import { EntityState, EntityAdapter, createEntityAdapter } from "@ngrx/entity";
import { PostsSearchResult } from "../models/posts-search-result";
import { PostsSearchAction, PostsSearchActionType } from "./posts-search.actions";


export interface PostsSearchListState extends EntityState<PostsSearchResult> {
}

export const postsSearchAdapter: EntityAdapter<PostsSearchResult> = createEntityAdapter<PostsSearchResult>({
  selectId: r => `${r.questionId}_${r.answerId}`
});

export const initialPostsSearchListState: PostsSearchListState = postsSearchAdapter.getInitialState({
});

export function postsSearchReducer(state = initialPostsSearchListState, action: PostsSearchAction): PostsSearchListState {

  switch (action.type) {

    case PostsSearchActionType.PostsSearchResultsClear:
      console.log("PostsSearchActionType.PostsSearchResultsClear called");
      return postsSearchAdapter.removeAll(state);

    case PostsSearchActionType.PostsSearchResultsPageRequested:
      return state;

    case PostsSearchActionType.PostsSearchResultsPageLoaded:
      console.log("PostsSearchActionType.PostsSearchResultsPageLoaded triggered");
      return postsSearchAdapter.addMany(action.payload.searchResults, state);

    case PostsSearchActionType.PostsSearchResultsPageCancelled:
      return state;

    default: {
      return state;
    }
  }
}

export const postsSearchSelectors = postsSearchAdapter.getSelectors();

选择器

import { createFeatureSelector, createSelector } from "@ngrx/store";
import { PostsSearchListState, postsSearchSelectors } from "./posts-search.reducers";
import { Features } from "../../reducers/constants";
import { PageQuery } from "src/app/custom-core/models/page-query";

export const selectPostsSearchState = createFeatureSelector<PostsSearchListState>(Features.PostsSearchResults);

export const selectAllPostsSearchResults = createSelector(selectPostsSearchState, postsSearchSelectors.selectAll);

export const selectSearchResultsPage = (page: PageQuery) => createSelector(
  selectAllPostsSearchResults,
  allResults => {
    const startIndex = page.pageIndex * page.pageSize;
    const pageEnd = startIndex + page.pageSize;
    return allResults
      .slice(startIndex, pageEnd);
  }
);

export const selectSearchResultsCount = createSelector(
  selectAllPostsSearchResults,
  allResults => allResults.length
);

数据源

这是必须的,因为我正在使用一个 Material 表格和分页器。它也涉及到分页:表格(实际上是数据源)请求一个页面,但如果需要,它会加载所有数据并返回该页面的内容。当然,后续页面将不会再去服务器获取更多数据。

import {CollectionViewer, DataSource} from "@angular/cdk/collections";
import {Observable, BehaviorSubject, of, Subscription} from "rxjs";
import {catchError, tap, take} from "rxjs/operators";
import { AppState } from "../../reducers";
import { Store, select } from "@ngrx/store";
import { PageQuery } from "src/app/custom-core/models/page-query";
import { LoggingService } from "../../custom-core/general/logging-service";
import { PostsSearchResult } from "../models/posts-search-result";
import { selectSearchResultsPage } from "../store/posts-search.selectors";
import { PostsSearchPageRequestedAction } from "../store/posts-search.actions";


export class SearchResultsDataSource implements DataSource<PostsSearchResult> {

 public readonly searchResultSubject = new BehaviorSubject<PostsSearchResult[]>([]);
 private searchSubscription: Subscription;

 constructor(private store: Store<AppState>, private logger: LoggingService) {
 }

 loadSearchResults(page: PageQuery, searchText: string) {

   this.logger.logTrace("SearchResultsDataSource.loadSearchResults started for page ", page, searchText);

   this.searchSubscription = this.store.pipe(
     select(selectSearchResultsPage(page)),
     tap(results => {
       // this.logger.logTrace("SearchResultsDataSource.loadSearchResults results ", results);

       if (results && results.length > 0) {
         this.logger.logTrace("SearchResultsDataSource.loadSearchResults page already in store ", results);
         this.searchResultSubject.next(results);
       } else {
         this.logger.logTrace("SearchResultsDataSource.loadSearchResults page not in store and dispatching request ", page);
         this.store.dispatch(new PostsSearchPageRequestedAction({ searchText: searchText}));
       }
     }),
     catchError(err => {
       this.logger.logTrace("loadSearchResults failed: ", err);
       return of([]);
     })
   )
   .subscribe();
 }

 connect(collectionViewer: CollectionViewer): Observable<PostsSearchResult[]> {
   this.logger.logTrace("SearchResultsDataSource: connecting data source");
   return this.searchResultSubject.asObservable();
 }

 disconnect(collectionViewer: CollectionViewer): void {
   console.log("SearchResultsDataSource: disconnect");
   this.searchResultSubject.complete();
 }
}

组件代码

搜索结果组件将搜索词作为查询参数接收,并转向数据源以加载相应的页面。

import { Component, OnInit, ViewChild, OnDestroy, AfterViewInit } from "@angular/core";
import { Store, select } from "@ngrx/store";
import { AppState } from "src/app/reducers";
import { PostsSearchResultsClearAction } from "../../store/posts-search.actions";
import { ActivatedRoute, Router, ParamMap } from "@angular/router";
import { tap, map } from "rxjs/operators";
import { environment } from "../../../../environments/environment";
import { MatPaginator } from "@angular/material";
import { SearchResultsDataSource } from "../../services/search-results.datasource";
import { LoggingService } from "src/app/custom-core/general/logging-service";
import { PageQuery } from "src/app/custom-core/models/page-query";
import { Subscription, Observable } from "rxjs";
import { selectSearchResultsCount, selectAllPostsSearchResults } from "../../store/posts-search.selectors";

@Component({
  // tslint:disable-next-line:component-selector
  selector: "posts-search-results",
  templateUrl: "./posts-search-results.component.html",
  styleUrls: ["./posts-search-results.component.css"]
})
export class PostsSearchResultsComponent implements OnInit, OnDestroy, AfterViewInit {

  appEnvironment = environment;

  searchResultCount$: Observable<number>;

  dataSource: SearchResultsDataSource;
  displayedColumns = ["scores", "searchResult", "user"];
  searchText: string;
  searchSubscription: Subscription;

  @ViewChild(MatPaginator) paginator: MatPaginator;

  constructor(private store: Store<AppState>,
      private route: ActivatedRoute,
      private logger: LoggingService) {

    console.log("PostsSearchResultsComponent constructor");
  }

  ngOnInit() {
    console.log("PostsSearchResultsComponent ngOnInit");

    this.dataSource = new SearchResultsDataSource(this.store, this.logger);
    const initialPage: PageQuery = {
      pageIndex: 0,
      pageSize: 10
    };

    // request search results based on search query text
    this.searchSubscription = this.route.paramMap.pipe(
      tap((params: ParamMap) => {
        this.store.dispatch(new PostsSearchResultsClearAction());

        this.searchText = <string>params.get("searchText");
        console.log("Started loading search result with text", this.searchText);
        this.dataSource.loadSearchResults(initialPage, this.searchText);

      })
    ).subscribe();

    // this does not work due to type mismatch
    // Type 'Observable<MemoizedSelector<object, number>>' is not assignable to type 'Observable<number>'.
    // Type 'MemoizedSelector<object, number>' is not assignable to type 'number'.
    this.searchResultCount$ = this.store.pipe(
      select(selectSearchResultsCount));
  }

  ngOnDestroy(): void {
    console.log("PostsSearchResultsComponent ngOnDestroy called");
    if (this.searchSubscription) {
      this.searchSubscription.unsubscribe();
    }
  }

  loadQuestionsPage() {

   const newPage: PageQuery = {
     pageIndex: this.paginator.pageIndex,
     pageSize: this.paginator.pageSize
   };

   this.logger.logTrace("Loading questions for page: ", newPage);
   this.dataSource.loadSearchResults(newPage, this.searchText);
  }

  ngAfterViewInit() {

   this.paginator.page.pipe(
     tap(() => this.loadQuestionsPage())
   )
     .subscribe();
  }

  // TODO: move to a generic place
  getTrimmedText(text: string) {
    const size = 200;
    if (!text || text.length <= size) {
      return text;
    }

    return text.substring(0, size) + "...";
  }
}

组件标记

<h2>{{searchResultCount$ | async}} search results for <i>{{searchText}} </i></h2>

<mat-table [dataSource]="dataSource">
  <ng-container matColumnDef="scores">
    <mat-header-cell *matHeaderCellDef></mat-header-cell>
    <mat-cell *matCellDef="let result">
      <div class="question-score-box small-font">
        {{result.votes}}<br /><span class="small-font">score</span>
      </div>
      <div [ngClass]="{'answer-count-box': true, 'answer-accepted': result.isAnswered}" *ngIf="result.postType == 'question'">
        {{result.answerCount}}<br /><span class="small-font" *ngIf="result.answerCount == 1">answer</span><span class="small-font" *ngIf="result.answerCount != 1">answers</span>
      </div>
    </mat-cell>
  </ng-container>

  <ng-container matColumnDef="searchResult">
    <mat-header-cell *matHeaderCellDef></mat-header-cell>
    <mat-cell *matCellDef="let result">
      <a [routerLink]="['/posts', result.questionId]" [routerLinkActive]="['link-active']" id="questionView"
         [innerHTML]="'Q: ' + result.title" *ngIf="result.postType == 'question'">
      </a>
      <a [routerLink]="['/posts', result.questionId]" [routerLinkActive]="['link-active']" id="questionView"
         [innerHTML]="'A: ' + result.title" *ngIf="result.postType == 'answer'">
      </a>
      <span class="medium-font">{{getTrimmedText(result.body)}}</span>
    </mat-cell>
  </ng-container>

  <ng-container matColumnDef="user">
    <mat-header-cell *matHeaderCellDef></mat-header-cell>
    <mat-cell *matCellDef="let result">
      <div class="q-list-user-details">
        <span class="half-transparency">
          {{result.postType == 'question' ? 'Asked' : 'Added'}} on {{result.createDateTime | date: 'mediumDate'}}
          <br />
        </span>

        <a [routerLink]="['/users', result.creatorSoUserId]" [routerLinkActive]="['link-active']" id="addedByView">
          {{result.creatorName}}
        </a>
      </div>
    </mat-cell>
  </ng-container>

  <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>

  <mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>

<mat-paginator #paginator
               [length]="searchResultCount$ | async"
               [pageIndex]="0"
               [pageSize]="10"
               [pageSizeOptions]="[5, 10, 25, 100]">
</mat-paginator>

<!-- <hr/> -->
<div *ngIf="!appEnvironment.production">
  {{(dataSource?.searchResultSubject | async) | json}}
</div>

这里有很多代码,我认为它可以改进,但是对于在你的SPA中搜索内容而言,拥有符合习惯的ngrx代码是一个不错的开始。


2

我最近使用Angular 4和@ngrx实现了一个搜索功能。我的做法是通过派发EXECUTE_SEARCH操作将查询字符串设置到存储区并触发一个effect。该effect触发异步调用。当异步调用返回时,根据结果,我会派发FETCH_SUCCESSFUL操作或FETCH_FAILURE操作。如果成功,我会将结果设置在存储区。

清除存储区中的结果取决于所需的行为。在我的项目中,我基于FETCH_SUCCESSFUL替换旧结果以清除存储区中的结果。在其他用例中,当您执行新搜索时(在EXECUTE_SEARCH reducer中),清除存储区中的结果可能是合理的。


1

由于我长时间没有找到这个问题的答案,所以我采取了一种保存从后端获取的所有数据并按以下方式搜索数据的方法:

我实现了一个搜索效果,它会触发一个异步调用到后端。从后端返回的是搜索结果和它们的ID。在接收到数据后,此效果将触发搜索完成操作。然后在此减速器操作中,我使用了一个名为searchIds的状态来存储结果的ID,并创建了一个名为entities的状态,它基本上是一个具有ID作为键的数据映射。

从后端接收到的数据将被过滤以检查它是否已经存在于存储中,如果不存在,则将其附加到entities中。之后,我订阅了一个选择器,它基本上查找searchIds中存在的键并仅从entities返回那些数据。由于它已经是一个具有ID作为键的映射,因此根据searchIds进行搜索非常有效,而且我也不必清除已经拥有的数据。这反过来维护了@ngrx/store的真正目的,即缓存我收到的所有数据。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接