上一章我们完成了待办事项的增加、删除、修改、完成等操作。在开始下面的需求之前,我们要解决一个之前遗留的 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