首页 Angular Angular 学习之路 20 – Todo App (4)

Angular 学习之路 20 – Todo App (4)

0 1.6K

上一章我们完成了待办事项的增加、删除、修改、完成等操作。在开始下面的需求之前,我们要解决一个之前遗留的 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类型,这正是我们需要的。由于新增加的待办事项默认completedfalse,因此this.allCompleted必定是false

deleteTodo()的参数是Todo类型,依旧使用filter()返回新的数组。此时,this.allCompleted的值应该由this.#todoList中所有未完成的事项的个数决定。虽然在函数实现中我们只使用了todo.id的值,但函数参数依旧使用了完整的Todo对象。之所以使用完整对象类型,是考虑到以后可能不仅需要id属性,还可能使用到别的属性值。

toggleTodo()比较特别:第一个参数是boolean类型的,第二个参数是可选的Todo类型。如果不存在第二个类型,则会将#todoList中所有的待办事项状态设置为第一个参数的值;如果存在第二个类型,则只会设置该todocompleted的值。注意到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 = '';
    }
  }

首先是HeaderComponentaddTodo()。这里我们将原本的代码修改为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

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2