使用ngrx / store angular 2实现搜索

时间:2017-09-13 13:52:14

标签: angular search ngrx-store ngrx-effects

我正在尝试为以角度4编写的应用程序实现搜索功能。它基本上用于显示大量数据的表。我还添加了ngrx商店。 使用商店实现搜索应用程序的正确方法是什么? 目前,我每次都在清理商店,搜索查询,然后将我从异步调用收到的数据填充到后端。我在HTML中显示这些数据。异步调用是从效果文件中完成的。

3 个答案:

答案 0 :(得分:1)

我最近使用Angular 4和@ngrx实现了搜索功能。 我这样做的方法是调度EXECUTE_SEARCH操作,将查询字符串设置到商店并触发效果。该效果触发了异步调用。当异步调用返回时,我根据结果调度了FETCH_SUCCESSFUL操作或FETCH_FAILURE操作。如果成功,我将结果设置在我的商店中。

清除商店中的结果时,实际上取决于所需的行为。我的项目,我在FETCH_SUCCESSFUL上清除了结果,替换了旧的结果。在其他用例中,在执行新搜索时清除存储结果可能是合理的(在EXECUTE_SEARCH减速器中)。

答案 1 :(得分:0)

好吧,由于我长时间没有找到这个问题的答案,我采取了一种方法来保存来自后端的数据,然后按照以下方式搜索数据:

我实现了一个搜索效果,它会触发对后端的异步调用。从后端我返回搜索结果以及他们的ID。接收数据后,此效果将触发搜索完成操作。然后在这个reducer动作中,我曾经用名称searchIds将结果的id存储在我的状态中,并且我创建了一个名为entity的状态,这个状态基本上是以id为键的数据映射。

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

答案 2 :(得分:0)

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

由于每个搜索基本上都是唯一的,因此我也在清除结果。但是,由于结果列表可能很长,并且我不想全部显示它们,因此我加载了所有结果(以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;
    })
  );

}

减速器

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

数据源

这是必需的,因为我正在处理材料表和分页器。它也处理分页:表(实际上是数据源)请求页面,但是如果需要,效果将加载所有内容并返回该页面。当然,后续页面将不会进入服务器以获取更多数据。

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>

有很多代码,我认为可以对其进行改进,但是拥有一个惯用的ngrx代码来搜索SPA中的内容是一个好的开始。