上一章我们实现了待办事项 app 的基本功能,也就是回车添加新的待办。现在,我们要继续完善这个应用。
在应用顶部的输入框敲下回车,添加新的待办。当页面加载完毕时,这个 input 应该获得焦点,最好是使用 input 的属性autofocus
。按下回车创建一个新的待办,将其追加到待办列表的末尾,然后清空输入框。记得要对 input 调用 .trim()
函数,然后在创建新的待办之前检查非空。
我们要求,在添加新的待办之后,输入框应该清空。我们当然可以直接通过input
的引用,将其value
属性设置为空来实现。这在 jQuery 时代是标准做法。但 Angular 应该是数据驱动的,更好的做法是,将input
的值绑定到一个变量,通过对这个变量的操作,影响到input
的行为。
下面我们在HeaderComponent
引入一个变量todoContent
:
... export class HeaderComponent implements OnInit { todoContent = ''; ... } ...
todoContent
是string
类型的。在 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 的双向绑定的指令。它可以绑定到诸如input
,select
或者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()
之后的长度。最后,在加入列表之后,需要将其置空。这样就满足了之前的需求。
下面我们来看下一个需求:
一个待办有三种可能的交互:
- 点击选择框,将该待办标记为已完成。这一步骤需要更新其
completed
属性的值,然后切换其父元素<li>
的completed
类 - 双击
<label>
进入编辑模式,将.editing
类添加到<li>
- 鼠标滑过待办列表,显示移除按钮(
.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.completed
与input
的value
绑定起来,这样,input
的值切换的时候,会被保存在todo.completed
中。父元素li
的completed
类的添加,则通过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.editing
为true
时,才能够继续执行。这是因为如果 Esc 被按下,调用cancelEditing()
回调之后,input
由于ngIf
的原因被隐藏,同样会触发blur
事件。如果没有这个判断,那么当按下 Esc 之后,stopEditing()
又会被调用,导致todo.content
可能被更新。这不是我们说期望的。所以这一判断必不可少。
现在,我们已经实现了增加、删除、修改、完成等操作。下一章我们将继续实现后面所需的功能。
附件中是本章的完整项目代码:https://files.devbean.net/code/todomvc-app-2.zip