Angular 学习之路 19 – Todo App (3)

上一章我们实现了待办事项 app 的基本功能,也就是回车添加新的待办。现在,我们要继续完善这个应用。

在应用顶部的输入框敲下回车,添加新的待办。当页面加载完毕时,这个 input 应该获得焦点,最好是使用 input 的属性autofocus。按下回车创建一个新的待办,将其追加到待办列表的末尾,然后清空输入框。记得要对 input 调用 .trim()函数,然后在创建新的待办之前检查非空。

我们要求,在添加新的待办之后,输入框应该清空。我们当然可以直接通过input的引用,将其value属性设置为空来实现。这在 jQuery 时代是标准做法。但 Angular 应该是数据驱动的,更好的做法是,将input的值绑定到一个变量,通过对这个变量的操作,影响到input的行为。

下面我们在HeaderComponent引入一个变量todoContent

...
export class HeaderComponent implements OnInit {

  todoContent = '';

  ...

}
...

todoContentstring类型的。在 TypeScript 中,如果给变量直接赋初始值,那么 TypeScript 就可以推断出变量的类型,因此类型就可以省略。这里我们就是利用这一特性,将todoContent初始化为空字符串,TypeScript 则推断出todoContent的类型为string

下面我们就可以改写模板:

<header class="header">
  <h1>todos</h1>
  <input #content class="new-todo" placeholder="What needs to be done?" autofocus
         [(ngModel)]="todoContent"
         (keydown.enter)="addTodo()">
</header>

首先我们修改了第4行。我们将前面创建的todoContent变量通过双向绑定赋值给ngModel前面我们详细介绍过双向数据绑定,这里不再赘述。

ngModel是 Angular 提供的用于 form 的双向绑定的指令。它可以绑定到诸如inputselect或者textarea这样的 form 标签。在实现上,它会绑定 form 元素的value属性,在值改变时发出ngModelChange事件。ngModel来自于FormsModule,因此要使用时必须先引入模块:

import { NgModule } from '@angular/core';
...
import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    ...
  ],
  imports: [
    ...
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

这样,我们将input.value属性绑定到了todoContent变量。那么,原来在调用addTodo()函数的时候传入的参数也就可以去掉了。同时,我们需要修改这个函数的实现:

  addTodo(): void {
    if (this.todoContent.trim().length > 0) {
      this.todoService.todoList.push({
        id: this.todoService.todoList.length,
        content: this.todoContent.trim(),
        completed: false,
        editing: false
      });
    }
    this.todoContent = '';
  }

注意,这里的函数参数已经被移除,取而代之的是之前我们的todoContent变量。由于该变量与input.value双向绑定,input.value的值会直接反应在todoContent变量,所以我们直接使用todoContent的值即可。另外,只有输入非空才允许加入列表,所以我们还得在添加之前判断这个输入字符串trim()之后的长度。最后,在加入列表之后,需要将其置空。这样就满足了之前的需求。

下面我们来看下一个需求:

一个待办有三种可能的交互:

  1. 点击选择框,将该待办标记为已完成。这一步骤需要更新其completed属性的值,然后切换其父元素<li>completed
  2. 双击<label>进入编辑模式,将 .editing 类添加到<li>
  3. 鼠标滑过待办列表,显示移除按钮(.destroy

点击选择框将待办标记为已完成,通过更新completed属性实现,同时需要为li添加completed类。那么,我们修改模板文件如下:

    <li *ngFor="let todo of todoList" [ngClass]="{ completed: todo.completed }">
      <div class="view">
        <input class="toggle" type="checkbox" [(ngModel)]="todo.completed">
        <label>{{ todo.content }}</label>
        <button class="destroy" (click)="deleteTodo(todo)"></button>
      </div>
    </li>

第3行,我们把todo.completedinputvalue绑定起来,这样,input的值切换的时候,会被保存在todo.completed中。父元素licompleted类的添加,则通过ngClass完成。

每个待办后面删除按钮的点击事件则使用(click)事件。我们需要实现一个deleteTodo()函数:

  deleteTodo(todo: Todo): void {
    this.todoList = this.todoList.filter(it => it.id !== todo.id);
  }

deleteTodo()函数用于移除特定的待办事项。需要移除的待办由参数传入,移除的操作则是通过filter,过滤掉 ID 相同的待办。

为了进入编辑模式,我们需要给label标签增加双击事件。双击label时,todo.editing设置为true,意味着这个待办事项正在编辑。当todo.editing设置为true时,li需要添加editing类,同时显示一个用于编辑的input

  <ul class="todo-list">
    <!-- These are here just to show the structure of the list items -->
    <!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
    <li *ngFor="let todo of todoList"
        [ngClass]="{
          completed: todo.completed,
          editing: todo.editing
        }">
      <div class="view">
        <input class="toggle" type="checkbox" [(ngModel)]="todo.completed">
        <label (dblclick)="todo.editing=true">{{ todo.content }}</label>
        <button class="destroy" (click)="deleteTodo(todo)"></button>
      </div>
      <input *ngIf="todo.editing"
             class="edit" type="text">
    </li>
  </ul>

第11行,label的双击事件dblclick的回调,将todo.editing设置为true,同时,li增加一个类。用于编辑的input则使用ngIf进行显示。不过现在有一个问题:当input显示时,焦点并不能直接在input上面。解决这一问题有几种办法:可以不使用ngIf,而是使用display:none;控制input的显示。另外,还可以使用指令完成类似的功能。这里,我们选择第二种思路:创建一个指令。

ng g d autofocus

我们使用上面的命令创建一个指令,然后修改指令的实现如下:

import { Directive, ElementRef, OnInit } from '@angular/core';

@Directive({
  selector: '[appAutofocus]'
})
export class AutofocusDirective implements OnInit {

  constructor(
    private readonly elementRef: ElementRef
  ) { }

  ngOnInit(): void {
    this.elementRef.nativeElement.focus();
  }

}

前面我们介绍过自定义指令。这里,我们同样注入ElementRef实例,用于获取指令所在的元素引用,然后使用其nativeElement属性获得引用的 DOM 元素,直接调用其focus()函数即可。

指令创建完毕后,我们需要修改模板:

...
      <input *ngIf="todo.editing"
             class="edit" type="text" appAutofocus>
...

这样,当输入框出现时,会自动调用focus()函数获得焦点。

焦点的问题解决之后,我们要为输入框添加事件:

...
      <input *ngIf="todo.editing"
             #editingInput
             class="edit" type="text" appAutofocus
             [ngModel]="todo.content"
             (blur)="stopEditing(todo, editingInput.value)"
             (keyup.enter)="stopEditing(todo, editingInput.value)"
             (keyup.escape)="cancelEditing(todo)">
...

事件实现如下:

...
  stopEditing(todo: Todo, content: string): void {
    if(todo.editing) {
      if (content.trim().length > 0) {
        todo.content = content;
      } else {
        this.todoList = this.todoList.filter(it => it.id !== todo.id);
      }
      todo.editing = false;
    }
  }

  cancelEditing(todo: Todo): void {
    todo.editing = false;
  }
...

注意,我们使用[ngModel]todo.content赋值给input.value。这里,我们并没有使用双向绑定,而是选择单向绑定,这是因为我们不想将input.value的改变直接赋值给todo.content。这样的实现可以让我们在取消编辑时恢复之前待办的内容。

Esc 键的keyup.escape事件中,仅仅将todo.editing设置为false。由于我们只对ngModel做了单向绑定,因此input输入值的改变并不会影响到todo.content,所以这里无需额外操作。

失去焦点的blur事件和回车按键的keyup.enter事件调用了同一个回调函数stopEditing()。在这个函数里面,首先需要判断todo.editing是否为true,只有todo.editingtrue时,才能够继续执行。这是因为如果 Esc 被按下,调用cancelEditing()回调之后,input由于ngIf的原因被隐藏,同样会触发blur事件。如果没有这个判断,那么当按下 Esc 之后,stopEditing()又会被调用,导致todo.content可能被更新。这不是我们说期望的。所以这一判断必不可少。

现在,我们已经实现了增加、删除、修改、完成等操作。下一章我们将继续实现后面所需的功能。

附件中是本章的完整项目代码:https://files.devbean.net/code/todomvc-app-2.zip

Leave a Reply