[译]Angular2 和 Rxjs : 一个简单的带搜索功能的列表

1,398 阅读7分钟
原文链接: coyee.com

介绍

有必要介绍一下背景,我有一段很长的时间非常痛苦,脑海里总是被响应式和功能性方法环绕着。 我读了一些教程和试图去应用一点 Rxjs但是都没有太大的成功。当然,我成功的完成了基本的东西,但是却没有真正的理解。

在这段时间里,我告诉自己必须结束这些。像任何计算机科学,如果你只是一个模仿者, 这意味着什么。 所以我开始阅读这个文档 : RxJS。我得说这真是大开眼界。特别是介绍。 我鼓励大家仔细阅读。 这是非常牛逼的东西!!!!

在这篇文档的开始部分,我仍然想分享一些代码,我把它和介绍页放在一起。

上下文

我们将通过显示每个帖子的ID和标题来构建一个简单的帖子列表。 这个列表将提供分页和一个搜索框用来通过帖子的标题过滤。

帖子的列表数据来自一个简单的REST JSON api GET /posts :

{
  "items ": [
    {
      "id ": 1,
      "title ": "Title of my post 1 ",
      "text ": "Text of my post 1 "
    }
  ],
  "total ": 5
}

我们有一个数组的帖子和查询字符串匹配的项的总数 (总数可以超过返回的帖子的数量)。

可用的参数变量 :

  • limit : 限制API返回的结果数(默认10)
  • page : 偏移量 (page - 1) * limit
  • search : 筛选的结果 (在标题中类似'%search%')

我想你已经有一个工作组件用来显示列表。假设这个组件在文件 'app/components/post/post-list.component.ts'中已经定义好了。

初始化接口和服务

所以我们要查询一个 HTTP API 返回帖子集合。 我们需要 :

  • 一个实体对象来代表我们的帖子
  • 描述API响应的接口
  • 当然,一个服务来执行查询的API

让我们从帖子实体类开始:

我把它放在app/components/post/post.entity.ts 文件中:

export class Post {
  id: number

  title: string

  text: string
}

然后是API响应接口 (如果你使用JSON规范,会有很多API可用)。 创建一个文件app/services/api/list-result.interface.ts :

export interface ListResult<T> {
    items: T[]

    total: number
}

最后是查询API的服务app/components/post/post.service.ts :

import { Injectable } from '@angular/core'
import { Observable } from 'rxjs/Observable'
import { URLSearchParams } from '@angular/http'
import { HTTP } from '@angular/http'

import { Post } from './post.entity'
import { ListResult } from '../../services/api/list-result.interface'

@Injectable()
export class PostService {
  constructor(protected http: HTTP) {}

  list(search: string = null, page: number = 1, limit: number = 10): Observable<ListResult<Post>> {
    var params = new URLSearchParams();
    if (search) params.set('search', search)
    if (page) params.set('page', String(page))
    if (limit) params.set('limit', String(limit))

    return this.http.get('http://myapidomain.com/post', { search: params }).map(res => res.json())
  }
}

好了,我们已经在组件中开始我们的工作了。但首先,让我来解释一下Rxjs和Observable(可观察的)。

Rxjs +Observable === Streams

为什么我喜欢使用Rxjs,上面的文章介绍中有提到 ? 因为两个原因。 首先,允许我们创建可观察的对象。一个字概括: STREAMS(流)。 它提供了一个很好的方式来描述它,通过使用ASCII流的方式。 每个人使用Rxjs的人都应该使用ASCII来描述流和描述流之间的不同转化。我们会这样做的。

那么,对应于我们的列表来看下。 事实上非常简单, 就两个流(stream) :

  • 第一个, 在搜索字段中的输入。
  • 然后,点击分页菜单时,页码的变化。

这些事件可以使用本地Rxjs功能观察到,但是在Angular没有暴露其视图组件的事件 (查看在github上的这个讨论)。所以观察这些事件的方法是通过使用 Subject。 这个实体既是观察者又是可被观察的对象。 作为一个观察者,它暴露了一个你可以调用的下一个方法来传递一个值,它将暴露给所有订阅者。 Angular2 使用了这个文档 debouncing search field中的例子来作为示例。 (我们也将使用这些)

就像上面说的那样,让我们先介绍下我们的流(stream):

首先搜索字段。

它应该做什么 ?

  1. 输入搜索字段中的值
  2. 为了更好的用户体验,防止页面抖动(其实就是限制发送请求时间间隔,防止短时间内发送多次请求)
  3. 只有不通的值才允许通过
  4. 如果搜索被触发了,则总是返回第一页数据

如果所有这些条件都满足, 我们可以查询这个API来获取匹配搜索文本的结果。

ASCII :

searchSource:     ---t----i--t--l-e---------------------------------------------c--->
                  vvvvvvvvv debounce vvvvvvvvvvvvvvvvvvvvvvvcccvvvvvvvvvvvvvvvvvvvvvv
                  vvvvvvvvv distinct vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
                  ---------------------(1sec)---title------------------------------->
                  vvvvvvvvv map vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
observableSource: ------------------------------{"search": "title", "page": 1}------>

然后是第二个流, 分页。它更简单。 每次点击页码就将这个数字放入流中 (c = click, C = completed)。

pageSource:      ---c------------------------------------c------------------------------C->
                 ---1------------------------------------3-------------------------------->
                 vvvvvvvvv map vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
observablePage:  ---{"search ": this.search, "page ": 1}---{"search ": this.search, "page ": 3}

无论两个流中发生了什么,当我们接收到一个值得时候,它会触发一次API查询。 所以让我们来合并它们。 我将会使用QueryParam这个对象来描述这些 {"search ": this.search, "page ": this.page}。

observableSource: --QueryParam------------------------------------------>
observablePage:   -------------------------QueryParam------------------->
                  vvvvvvvvv merge vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
                  --TriggerAPIcall---------TriggerAPIcall--------------->
                  vvvvvvvvv flatmap vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
observable:       -------- ListResult<Post> --------- ListResult<Post> --

好的, 我们描述了一个流为我们观察到的每个事件触发API调用。当我们收到一个API的响应,我们只需要更新视图响应内容。

让我们来看下代码:

代码

正如我们上面描述的流, 我们几乎只能描述它,因为它是流(抽象的东西)。

让我们来看下搜索这个可观察的对象:

terms: string = " "
private searchTermStream = new Subject<string>()

ngOnInit() {
  const searchSource = this.searchTermStream
    .debounceTime(1000)
    .distinctUntilChanged()
    .map(searchTerm => {
      this.terms = searchTerm
      return {search: searchTerm, page: 1}
    })
}

search(terms: string) {
  this.searchTermStream.next(terms)
}

正如你所看到的, 代码几乎和上面的ASCII码描述的没有差别 (terms是搜索字段的当前值)。

分页流:

private pageStream = new Subject<number>()

ngOnInit() {
  const pageSource = this.pageStream.map(pageNumber => {
    this.page = pageNumber
    return {search: this.terms, page: pageNumber}
  })
}

goToPage(page: number) {
  this.pageStream.next(page)
}

我们合并了两个流并触发一个 API 调用:

ngOnInit() {
  ...
  const source = pageSource
    .merge(searchSource)
    .startWith({search: this.terms, page: this.page})
    .mergeMap((params: {search: string, page: number}) => {
      return this.postService.list(params.search, params.page)
    })
    .share()
}

我们从这个可观察对象中分离出了帖子列表集合(items)以及帖子总数(total)两个可观察对象:

total$: Observable<number>
items$: Observable<Post[]>

ngOnInit() {
  ...

  this.total$ = source.pluck('total')
  this.items$ = source.pluck('items')
}

我们现在又两个属性可以用到我们的组件:total$ 和items$。两者都是可观察到的视图来显示可用的列表匹配的帖子和文章的总数查询参数。

让我们把所有这些视图链接起来 :

<div class="input-group input-group-sm " style="margin-bottom: 10px; ">
  <input #term (keyup)="search(term.value) " value="
                                        {{ terms }}" class="form-control"                                        placeholder="Search" autofocus> <div class=
                                        "input-group-btn"> <button type="submit" class=
                                            "btn btn btn-default btn-flat"><i class="fa fa-search">
                                                </i></button> </div>
                                                    </div> <table class="table table-striped table-hover">
                                                        <tbody> <tr> <th>id
                                                        </th> <th>title</th>
                                                            </tr> <tr *ngFor="let post of items$ | async">
                                                            <td>
                                                            {{ post.id }}</td> <td>
                                                            {{ post.title }}</td> </tr>
                                                            </tbody> </table> <pagination                                                            [total]="total$ | async" [page]="page" (goTo)=
                                                            "goToPage($event)" [params]="
                                                                {q: terms}"></pagination>
                                                                
                                                                
                                                                

我们这里有什么 ?

  • 搜索字段使用了局部变量#term以及监听了keyup事件。 这个监听器触发我们控制器的方法,将搜索字段中的值推到与搜索可观察流相关联的主题(Subject)中。.
  • 我已经将分页抽取成为了一个可重用的独立组件。后面我会贴出代码。 你应该注意到goToPage调用。它将页面传递给分页组件和对应的可观察的流中。
  • async管道用在total$ 和items$ 变量上,允许视图直接使用观察者对象。你不必去订阅或者退订任何东西。让Angular为你做这些。

结论

一切都安排好了。 你有一个列表,当用户点击分页按钮或者使用搜索框进行搜索的时候将会被更新。 我希望一切都很清楚。 不要犹豫,如果在这篇文章中有任何问题或错误,请发一条信息给我。

请看以下的完整代码 (以及一个分页组件)

完整代码

列表组件 :

import { Component, OnInit } from '@angular/core'
import { Observable } from 'rxjs/Observable'
import { Subject } from 'rxjs/Subject'
import { RouteParams } from '@angular/router-deprecated'

import { Post } from './post.entity'
import { PostService } from './post.service'
import { PaginationComponent } from '../pagination/pagination.component'

@Component({
    selector: 'post-list',
    templateUrl: '/app/components/post/post-list.html',
    directives: [PaginationComponent],
    providers: [PostService]
})
export class PostListComponent implements OnInit {
  total$: Observable<number>
  items$: Observable<Post[]>

  page: number = 1
  terms: string = ""

  private searchTermStream = new Subject<string>()
  private pageStream = new Subject<number>()

  constructor(protected params: RouteParams, protected postService: PostService) {
    this.page = parseInt(params.get('page')) || 1
    this.terms = params.get('q') || ""
  }

  ngOnInit() {
      const pageSource = this.pageStream.map(pageNumber => {
        this.page = pageNumber
        return {search: this.terms, page: pageNumber}
      })

      const searchSource = this.searchTermStream
        .debounceTime(1000)
        .distinctUntilChanged()
        .map(searchTerm => {
          this.terms = searchTerm
          return {search: searchTerm, page: 1}
        })

      const source = pageSource
        .merge(searchSource)
        .startWith({search: this.terms, page: this.page})
        .mergeMap((params: {search: string, page: number}) => {
          return this.postService.list(params.search, params.page)
        })
        .share()

      this.total$ = source.pluck('total')
      this.items$ = source.pluck('items')
  }

  search(terms: string) {
    this.searchTermStream.next(terms)
  }

  goToPage(page: number) {
    this.pageStream.next(page)
  }
}

视图与上面一致。

分页组件:

import * as _ from 'lodash' # sorry use lodash for this example (another dependency ...)
import { Component, Input, EventEmitter, Output } from '@angular/core'
import { ROUTER_DIRECTIVES, Router } from '@angular/router-deprecated'
import { Location } from '@angular/common'

@Component({
    selector: 'pagination',
    templateUrl: '/app/components/pagination/pagination.html',
    directives: [ROUTER_DIRECTIVES]
})
export class PaginationComponent {
  totalPage: number = 0

  @Input()
  params: {[key: string]: string | number} = {}

  @Input()
  total: number = 0

  @Input()
  page: number = 1

  @Output()
  goTo: EventEmitter<number> = new EventEmitter<number>()

  constructor(protected _location: Location, protected _router: Router) {}

  totalPages() {
    # 10 items per page per default
    return Math.ceil(this.total / 10)
  }

  rangeStart() {
    return Math.floor(this.page / 10) * 10 + 1
  }

  pagesRange() {
    return _.range(this.rangeStart(), Math.min(this.rangeStart() + 10, this.totalPages() + 1))
  }

  prevPage() {
    return Math.max(this.rangeStart(), this.page - 1)
  }

  nextPage() {
    return Math.min(this.page + 1, this.totalPages())
  }

  pageParams(page: number) {
    let params = _.clone(this.params)
    params['page'] = page
    return params
  }

  pageClicked(page: number) {
    # this is not ideal but it works for me
    const instruction = this._router.generate([
      this._router.root.currentInstruction.component.routeName,
      this.pageParams(page)
    ])
    # We change the history of the browser in case a user press refresh
    this._location.go('/'+instruction.toLinkUrl())
    this.goTo.next(page)
  }
}

分页视图:

<ul *ngIf="totalPages() > 1" class="pagination pagination-sm no-margin pull-right">
  <li *ngIf="page != 1"><a (click)="pageClicked(prevPage())">«</a></li>
  <li *ngFor="let p of pagesRange()"><a (click)="pageClicked(p)">{{p}}</a></li>
  <li *ngIf="totalPages() > page"><a (click)="pageClicked(nextPage())">»</a></li>
</ul>