基于 HTTP 实现用户信息的 CURD

本章节,我们将基于 HTTP 实现用户信息的 CURD,即查询、修改、创建、删除用户。

修改用户

在用户详情视图中编辑用户的名字。 随着输入,用户的名字也跟着在页面顶部的标题区更新了。 但是当你点击“后退”按钮时,这些修改都丢失了。

如果你希望保留这些修改,就要把它们写回到服务器。

在用户详情模板(user-detail.component.html)的底部添加一个保存按钮,它绑定了一个 click 事件,事件绑定会调用组件中一个名叫 save() 的新方法:

<button (click)="save()">save</button>

在 user-detail.component.t 中添加如下的 save() 方法,它使用用户服务中的 updateUser() 方法来保存对用户名字的修改,然后导航回前一个视图。

save(): void {
    this.userService.updateUser(this.user)
        .subscribe(() => this.goBack());
}

添加 UserService.updateUser()

updateUser() 的总体结构和 getusers() 很相似,但它会使用 http.put() 来把修改后的用户保存到服务器上。

updateUser (user: User): Observable<any> {
    return this.http.put(this.usersURL, user, this.httpOptions).pipe(
        tap(_ => this.log(`updated user id=${user.id}`)),
        catchError(this.handleError<any>('updateUser'))
    );
}

HttpClient.put() 方法接受三个参数

  • URL 地址
  • 要修改的数据(这里就是修改后的用户)
  • 选项

URL 没变。用户 Web API 通过用户对象的 id 就可以知道要修改哪个用户。

用户 Web API 期待在保存时的请求中有一个特殊的头。 这个头是在 UserService 的 httpOptions 常量中定义的。

private httpOptions:Object = {
    headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};

刷新浏览器,修改用户名,保存这些修改,然后点击“后退”按钮。 现在,改名后的用户已经显示在列表中了。

添加新用户

要添加用户,本应用中只需要用户的名字。你可以使用一个和添加按钮成对的 input 元素。

把下列代码插入到 UsersComponent 模板(src/app/users/users.component.html)中标题的紧后面:

<div>
  <label>User Name:
    <input #userName />
  </label>
  <button (click)="add(userName.value); userName.value=''">
    add
  </button>
</div>

当点击事件触发时,调用组件的点击处理器,然后清空这个输入框,以便用来输入另一个名字。修改 src/app/users/users.component.ts 添加 add 方法:

add(name: string): void {
    name = name.trim();
    if (!name) { return; }
    this.userService.addUser({ name } as User)
        .subscribe(user => {
        this.users.push(user);
        });
}

当指定的名字非空时,这个处理器会用这个名字创建一个类似于 User 的对象(只缺少 id 属性),并把它传给服务的 addUser() 方法。

当 addUser 保存成功时,subscribe 的回调函数会收到这个新用户,并把它追加到 users 列表中以供显示。

此时,我们还需要创建 UserService.addUser() 方法。

添加 UserService.addUser()

往 UserService 类中添加 addUser() 方法。修改 src/app/User.service.ts 添加 addUser 方法:

addUser (user: User): Observable<User> {
    return this.http.post<User>(this.usersURL, user, this.httpOptions).pipe(
        tap((user: User) => this.log(`added user id=${user.id}`)),
        catchError(this.handleError<User>('addUser'))
    );
}

UserService.addUser() 和 updateUser 有两点不同。

它调用 HttpClient.post() 而不是 put()。

它期待服务器为这个新的用户生成一个 id,然后把它通过 Observable 返回给调用者。

刷新浏览器,并添加一些用户。

删除用户

用户列表中的每个用户都有一个删除按钮。点击删除按钮,就能删除用户。

把下列按钮(button)元素添加到 UsersComponent 的模板中,就在每个 <li> 元素中的用户名字后方。

<button class="delete" title="delete user"
  (click)="delete(user)">x</button>

这样,完整的用户列表(src/app/users/users.component.html)的 HTML 应该是这样的:

<h2>我的用户</h2>
<ul class="users">
    <li *ngFor="let user of users"> 
    <a routerLink="/detail/{{user.id}}">
      <span class="badge">{{user.id}}</span> {{user.name}}
    </a>
    <button class="delete" title="delete user"
  (click)="delete(user)">x</button>
  </li>
</ul>

<div>
  <label>User Name:
    <input #userName />
  </label>
  <button (click)="add(userName.value); userName.value=''">
    add
  </button>
</div>

要把删除按钮定位在每个用户条目的最右边,就要往 users.component.css 中添加一些 CSS:

button {
  background-color: #eee;
  border: none;
  padding: 5px 10px;
  border-radius: 4px;
  cursor: pointer;
  cursor: hand;
  font-family: Arial;
}

button:hover {
  background-color: #cfd8dc;
}

button.delete {
  position: relative;
  left: 194px;
  top: -32px;
  background-color: gray !important;
  color: white;
}

在 src/app/users/users.component.ts 中添加 delete() 方法:

delete(user: User): void {
    this.users = this.users.filter(h => h !== user);
    this.userService.deleteUser(user).subscribe();
}

虽然这个组件把删除用户的逻辑委托给了 userService,但仍保留了更新它自己的用户列表的职责。组件的 delete() 方法会在 userService 对服务器的操作成功之前,会先从列表中移除要删除的用户。

组件与 userService.delete() 返回的 Observable 还完全没有关联,所以必须订阅它。

添加 UserService.deleteUser() 方法

把 deleteuser() 方法添加到 UserService (src/app/user.service.ts)中,代码如下。

deleteUser (user: User | number): Observable<User> {
    const id = typeof user === 'number' ? user : user.id;
    const url = `${this.usersURL}/${id}`;

    return this.http.delete<User>(url, this.httpOptions).pipe(
        tap(_ => this.log(`deleted user id=${id}`)),
        catchError(this.handleError<User>('deleteUser'))
    );
}

其中:

  • 它调用了 HttpClient.delete。
  • URL 就是用户的资源 URL 加上要删除的用户的 id。
  • 你不用像 put 和 post 中那样需要发送数据。
  • 你仍要发送 httpOptions。

刷新浏览器,并试一下这个新的删除功能。

效果如下:

根据名字搜索用户

你将往仪表盘中加入用户搜索特性。 当用户在搜索框中输入名字时,你会不断发送根据名字过滤用户的 HTTP 请求。 你的目标是仅仅发出尽可能少的必要请求。

添加 UserService.searchUsers

先把 searchUsers 方法添加到 UserService (src/app/user.service.ts)中。

searchUsers(term: string): Observable<User[]> {
    if (!term.trim()) {
        return of([]);
    }
    return this.http.get<User[]>(`${this.usersURL}/?name=${term}`).pipe(
        tap(_ => this.log(`found Users matching "${term}"`)),
        catchError(this.handleError<User[]>('searchUsers', []))
    );
}

如果没有搜索词,该方法立即返回一个空数组。 剩下的部分和 getUsers() 很像。 唯一的不同点是 URL,它包含了一个由搜索词组成的查询字符串。

为仪表盘添加搜索功能

打开 DashboardComponent 的模板并且把用于搜索用户的元素 <app-user-search> 添加到 DashboardComponent 模板(src/app/dashboard/dashboard.component.html)的底部。

<h3>Top Users</h3>
<div class="grid grid-pad">
  <a *ngFor="let user of users" class="col-1-4"
    routerLink="/detail/{{user.id}}">
    <div class="module user">
      <h4>{{user.name}}</h4>
    </div>
  </a>
</div>

<app-user-search></app-user-search>

但目前,UserSearchComponent 还不存在,因此,Angular 找不到哪个组件的选择器能匹配上 <app-user-search>

创建 UserSearchComponent

使用 CLI 创建一个 UserSearchComponent。

ng generate component user-search

CLI 生成了 UserSearchComponent 相关的几个文件,并把该组件添加到了 AppModule 的声明中。以下是控制台输出:

ng generate component user-search

CREATE src/app/user-search/user-search.component.html (30 bytes)
CREATE src/app/user-search/user-search.component.spec.ts (657 bytes)
CREATE src/app/user-search/user-search.component.ts (288 bytes)
CREATE src/app/user-search/user-search.component.css (0 bytes)
UPDATE src/app/app.module.ts (1283 bytes)

把生成的 UserSearchComponent 的模板(src/app/user-search/user-search.component.html ),改成一个输入框和一个匹配到的搜索结果的列表。代码如下:

<div id="search-component">
  <h4>User Search</h4>

  <input #searchBox id="search-box" (keyup)="search(searchBox.value)" />

  <ul class="search-result">
    <li *ngFor="let user of users$ | async" >
      <a routerLink="/detail/{{user.id}}">
        {{user.name}}
      </a>
    </li>
  </ul>
</div>

修改 User-search.component.css ,添加相关的样式:

.search-result li {
  border-bottom: 1px solid gray;
  border-left: 1px solid gray;
  border-right: 1px solid gray;
  width:195px;
  height: 16px;
  padding: 5px;
  background-color: white;
  cursor: pointer;
  list-style-type: none;
}

.search-result li:hover {
  background-color: #607D8B;
}

.search-result li a {
  color: #888;
  display: block;
  text-decoration: none;
}

.search-result li a:hover {
  color: white;
}
.search-result li a:active {
  color: white;
}
#search-box {
  width: 200px;
  height: 20px;
}


ul.search-result {
  margin-top: 0;
  padding-left: 0;
}

当用户在搜索框中输入时,一个 keyup 事件绑定会调用该组件的 search() 方法,并传入新的搜索框的值。

AsyncPipe

观察:

<li *ngFor="let user of users$ | async" >

如你所愿,*ngFor 遍历渲染出了这些用户。

仔细看,你会发现*ngFor 是在一个名叫 users$ 的列表上迭代,而不是 users。

$是一个命名惯例,用来表明 users$ 是一个 Observable,而不是数组。

*ngFor 不能直接使用 Observable。 不过,它后面还有一个管道字符(|),后面紧跟着一个 async,它表示 Angular 的 AsyncPipe。

AsyncPipe 会自动订阅到 Observable,这样你就不用再在组件类中订阅了。

修正 UserSearchComponent 类

修改所生成的 UserSearchComponent 类及其元数据(src/app/user-search/user-search.component.ts ),代码如下:

import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs';

import {
   debounceTime, distinctUntilChanged, switchMap
 } from 'rxjs/operators';

import { User } from '../user';
import { UserService } from '../user.service';

@Component({
  selector: 'app-user-search',
  templateUrl: './user-search.component.html',
  styleUrls: ['./user-search.component.css']
})
export class UserSearchComponent implements OnInit {

  users$: Observable<User[]>;
  private searchTerms = new Subject<string>();

  constructor(private userService: UserService) {}

  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.users$ = this.searchTerms.pipe(
      // 等待 300ms
      debounceTime(300),

      // 忽略与前一次搜索内容相同的数据
      distinctUntilChanged(),

      // 当搜索的内容变更时,切换到新的搜索Observable
      switchMap((term: string) => this.userService.searchUsers(term)),
    );
  }

}

其中,

  • users$ 声明为一个 Observable;
  • searchTerms 属性声明成了 RxJS 的 Subject 类型。 Subject 既是可观察对象的数据源,本身也是 Observable。 你可以像订阅任何 Observable 一样订阅 Subject。 你还可以通过调用它的 next(value) 方法往 Observable 中推送一些值,就像 search() 方法中一样。
  • search() 是通过对文本框的 keystroke 事件的事件绑定来调用的。 每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。 searchTerms 变成了一个能发出搜索词的稳定的流。

串联 RxJS 操作符

如果每当用户击键后就直接调用 searchUseres() 将导致创建海量的 HTTP 请求,浪费服务器资源并消耗大量网络流量。

应该怎么做呢?ngOnInit() 往 searchTerms 这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchUsers() 的调用次数,并最终返回一个可及时给出用户搜索结果的可观察对象(每次都是 User[] )。详细观察如下代码:

this.users$ = this.searchTerms.pipe(
    // 等待 300ms
    debounceTime(300),

    // 忽略与前一次搜索内容相同的数据
    distinctUntilChanged(),

    // 当搜索的内容变更时,切换到新的搜索Observable
    switchMap((term: string) => this.userService.searchUsers(term)),
);

在这段代码中,

  • 在传出最终字符串之前,debounceTime(300) 将会等待,直到新增字符串的事件暂停了 300 毫秒。 这样你实际发起请求的间隔永远不会小于 300ms,减少了请求次数。
  • distinctUntilChanged() 会确保只在过滤条件变化时才发送请求。
  • switchMap() 会为每个从 debounce 和 distinctUntilChanged 中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。
  • 借助 switchMap 操作符, 每个有效的击键事件都会触发一次 HttpClient.get() 方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。
  • switchMap() 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。
  • 注意,取消前一个 searchUseres() 可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。

运行

再次运行本应用。在这个仪表盘中,在搜索框中输入一些文字,比如本例中的“l”。如果你输入的字符匹配上了任何现有用户的名字,你将会看到如下效果:

results matching ""

    No results matching ""