上一章我们完成了待办事项的增加、删除、修改、完成等操作。在开始下面的需求之前,我们要解决一个之前遗留的 bug。
bug 的触发方式是,首先添加若干待办事项,然后利用删除按钮全部删除,此时,不能再添加新的待办事项。输入之后,列表始终为空。
下面我们先看一下增加待办事项的操作:
export class HeaderComponent implements OnInit {
...
addTodo(): void {
if (this.todoContent.trim().length > 0) {
this.todoService.todoList.push({
id: this.todoService.todoList.length,
content: this.todoContent,
completed: false,
editing: false
});
this.todoContent = '';
}
}
...
} 我们看到,每次新增待办事项,其实是向this.todoService.todoList数组追加元素。这样没有问题,而且符合数据驱动的设计。接下来看删除的操作:
export class TodoListComponent implements OnInit {
todoList: Todo[] = this.todoService.todoList;
...
deleteTodo(todo: Todo): void {
this.todoList = this.todoList.filter(it => it.id !== todo.id);
}
...
} 注意看这里的删除操作,利用filter()运算符将id相同的待办事项过滤掉,剩下的重新赋值给this.todoList,这样,this.todoList中保存的就是没有相同id的待办事项,也就是删除了相同id的待办事项列表。
然后再来看列表显示的部分:
<section *ngIf="todoList.length > 0" class="main">
<input id="toggle-all" class="toggle-all" type="checkbox">
<label for="toggle-all">Mark all as complete</label>
<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"
#editingInput
class="edit" type="text" appAutofocus
[ngModel]="todo.content"
(blur)="stopEditing(todo, editingInput.value)"
(keyup.enter)="stopEditing(todo, editingInput.value)"
(keyup.escape)="cancelEditing(todo)">
</li>
</ul>
</section>
... 我们是通过ngFor遍历todoList数组实现的显示。要记住,todoList数组是在类中使用this.todoService.todoList赋值的。
问题就出现在这里:删除操作将this.todoList重新赋值,而ngFor依旧使用的是类创建时赋给的this.todoService.todoList这个数组的引用。这就导致之后新增的待办事项其实是追加到了全新的this.todoService.todoList里面,而不是用于显示的原始的那个数组。
明白了问题之后,我们就知道怎么修改了:简单地移除所有的this.todoList,将原this.todoList全部替换为this.todoService.todoList,这样始终使用的是最新的数组引用,就没有问题了。
修改之后的完整代码打包:https://files.devbean.net/code/todomvc-app-3.zip
接下来,我们来看有关 Mark all as complete 选择框的相关需求:
checkbox 将所有待办修改为与其自身相同的状态。记得在点击了“Clear completed”按钮之后,清空已选择的状态。在某个待办完成或未完成时,“Mark all as complete”选择框的状态应该随之改变。例如,当所有待办都标记为已完成时,这个选择框也应该被选中。
对于 Mark all as complete 选择框,简单的思路是,监听选择框的事件,然后将todoList中所有待办事项的completed状态全部改变。选择框位于TodoListComponent。首先在TodoListComponent模板添加响应事件:
<section *ngIf="todoService.todoList.length > 0" class="main"> <input #toggleAll id="toggle-all" class="toggle-all" type="checkbox" (change)="toggleAllComplete(toggleAll.checked)"> <label for="toggle-all">Mark all as complete</label> ...
我们选择监听change事件。这是 DOM 标准事件,原本参数是ChangeEvent类型,不过,我们只希望获取checkbox的选择状态。所以,我们给选择框添加一个模板引用,然后直接使用其checked属性。checked属性是boolean类型,即这个checkbox是否被选中。这正是我们所需要的。
然后,我们在TodoListComponent类中添加toggleAllComplete()的实现:
toggleAllComplete(checked: boolean): void {
this.todoService.todoList.forEach(it => it.completed = checked);
} 事件函数并不复杂,我们只需要把todoService.todoList中每一个待办事项的completed属性设置为与之前checkbox相同的状态。Angular 会检测到数据的变化,然后自动更新界面的显示。这也正是数据驱动的最大特点:我们只关心底层的数据,至于页面的渲染,则全部交给框架去完成。这样,我们就完成了 Mark all as complete 选择框的功能。
然而,对于后面的需求,“在某个待办完成或未完成时,“Mark all as complete”选择框的状态应该随之改变”,这种简单的实现就有点问题了。因为我们没有变量记录选择框的状态,因此也就不能主动去修改其状态。所以,我们需要引入一个变量,绑定到 Mark all as complete 选择框的当前状态,通过修改这个变量达到修改状态的目的。
下面需要考虑的是,这个变量放在哪里。当然,我们可以直接在TodoListComponent里面增加这样一个变量。但这不是一个合适的位置:新增操作是在HeaderComponent中完成的,新增之后是需要修改这个变量的,如果变量存在于TodoListComponent,势必将两个组件耦合起来。这不符合面向对象的设计方法。其实我们已经有个好地方了,一个所有组件都可以平等访问的地方:TodoService。把变量放在TodoService,是其成为一个数据共享中心;各个组件需要获取数据的时候,只要通过这个服务即可。这是一个不错的想法。
export class TodoService {
allCompleted = false;
todoList: Todo[] = [];
constructor() { }
} 现在,我们只需要将allCompleted与 Mark all as complete 选择框绑定。一切都还不错,只是有一个问题:如何修改allCompleted的值?
回顾一下前面的代码,我们每次都是直接对todoService.todoList数组进行修改的。理论上,只要todoService.todoList有了变化,就需要重新计算allCompleted的值。那么,难道每次修改数组之后,都要再加上对allCompleted的修改吗?这样太不方便了,很容易忘记,从而导致bug。所以,我们应该将todoService.todoList数组的所有操作全部封装起来,外界只能调用我们封装好的方法。
下面我们先来修改TodoService。
export class TodoService {
allCompleted = false;
get todoList(): Todo[] {
return this.#todoList;
}
#todoList: Todo[] = [];
constructor() { }
addTodo(todo: Omit<Todo, 'id'>): void {
this.#todoList.push({
id: this.#todoList.length,
...todo
});
this.allCompleted = false;
}
deleteTodo(todo: Todo): void {
this.#todoList = this.#todoList.filter(it => it.id !== todo.id);
this.allCompleted = this.#todoList.filter(it => !it.completed).length === 0;
}
toggleTodo(completed: boolean, todo?: Todo): void {
if (!todo) {
this.#todoList.forEach(it => it.completed = completed);
this.allCompleted = completed;
} else {
todo.completed = completed;
this.allCompleted = this.#todoList.filter(it => !it.completed).length === 0;
}
}
} TodoService增加了allCompleted属性,用于记录所有待办事项的状态。原来的todoList改为私有的。注意这里有一个问题,我们选择了#todoList语法,也可以使用
private todoList: Todo[] = [];
两种语法都是正确的,区别在于,#todoList是类私有变量的标准语法,而private则是 TypeScript 的实现。
由于#todoList是私有变量,那么就需要有一个方法能够让外部类使用这个数组。这里我们使用了get函数。当然,也可以直接把todoList设置成public的,但这并不符合面向对象设计的要求,所以暂不考虑。
接下来,addTodo()函数,用于向#todoList数组追加新的待办事项。注意这个函数的参数类型是Omit<Todo, 'id'>。Omit<>是 TypeScript 内置的工具类型(详情见这篇文章)。为什么不直接使用Todo类型呢?主要是因为id的值使用的是#todoList.length,而#todoList现在是私有变量,外部类已经不能访问到;并且id属性作为一个内部 ID,不应该由外部调用者决定其值,所以本不应该作为参数的值传入。Omit<Todo, 'id'>类型返回移除id之后的Todo类型,这正是我们需要的。由于新增加的待办事项默认completed为false,因此this.allCompleted必定是false。
deleteTodo()的参数是Todo类型,依旧使用filter()返回新的数组。此时,this.allCompleted的值应该由this.#todoList中所有未完成的事项的个数决定。虽然在函数实现中我们只使用了todo.id的值,但函数参数依旧使用了完整的Todo对象。之所以使用完整对象类型,是考虑到以后可能不仅需要id属性,还可能使用到别的属性值。
toggleTodo()比较特别:第一个参数是boolean类型的,第二个参数是可选的Todo类型。如果不存在第二个类型,则会将#todoList中所有的待办事项状态设置为第一个参数的值;如果存在第二个类型,则只会设置该todo的completed的值。注意到this.allCompleted的设置方式,如果没有第二个参数,则this.allCompleted的值就是第一个的值;如果有第二个参数,则this.allCompleted的值需要根据数组中的元素状态去判断。原本toggleTodo()函数可以拆分为两个,这里只是为了简单,根据第二个参数去做判断。
TodoService已经完成了,接下来是修改完善组件的实现。
addTodo(): void {
if (this.todoContent.trim().length > 0) {
this.todoService.addTodo({
content: this.todoContent,
completed: false,
editing: false
});
this.todoContent = '';
}
} 首先是HeaderComponent的addTodo()。这里我们将原本的代码修改为this.todoService.addTodo()的实现。
TodoListComponent的模板也需要修改:
<section *ngIf="todoService.todoList.length > 0" class="main">
<input #toggleAll id="toggle-all" class="toggle-all" type="checkbox"
[(ngModel)]="todoService.allCompleted"
(change)="toggleAllComplete(toggleAll.checked)">
<label for="toggle-all">Mark all as complete</label>
<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 todoService.todoList"
[ngClass]="{
completed: todo.completed,
editing: todo.editing
}">
<div class="view">
<input #toggleOne class="toggle" type="checkbox"
[(ngModel)]="todo.completed"
(change)="toggleComplete(toggleOne.checked, todo)">
<label (dblclick)="todo.editing=true">{{ todo.content }}</label>
<button class="destroy" (click)="deleteTodo(todo)"></button>
</div>
<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)">
</li>
</ul>
</section>
... HTML模板中,两个checkbox都使用ngModel双向绑定,然后监听change事件,事件回调则修改为:
...
deleteTodo(todo: Todo): void {
this.todoService.deleteTodo(todo);
}
stopEditing(todo: Todo, content: string): void {
if(todo.editing) {
if (content.trim().length > 0) {
todo.content = content;
} else {
this.todoService.deleteTodo(todo);
}
todo.editing = false;
}
}
cancelEditing(todo: Todo): void {
todo.editing = false;
}
toggleComplete(checked: boolean, todo: Todo): void {
this.todoService.toggleTodo(checked, todo);
}
toggleAllComplete(checked: boolean): void {
this.todoService.toggleTodo(checked);
}
... 至此,我们基本完成了前面所说的有关 Mark all as complete 选择框的需求。
本章最后的完整代码:https://files.devbean.net/code/todomvc-app-4.zip