Angular2入门教程-2 实现TodoList App

这是Angular2入门教程的第二部分,第一部分介绍了Angular2的特性和概念,以及一个Angular2项目的结构的代码。这一部分,我们就基于上一部分的介绍,来开始开发我们的App。

我们要实现的,是一个TodoList(待办事宜)的APP。下面就是这个app最终的效果
angular2-todo-app.jpg

如果你们想查看这个教程最终的源文件,可以直接查看项目地址。在下面的讲解中,对很多css样式的定义,就没有列出来说明。如果你们要按照这个教程完成这个应用,需要自己从这里查看相应的样式文件,当然也可以根据喜好去定义样式。

系统设计

即使一个简单的实例,我们也要从Angular2的编程思想出发,对系统进行总体的设计。

组件设计

首先,我们多次提到,Angular2是组件化、模块化的,我们开发一个Angular2的应用,也应该将系统设计成一个个组件,而且一个组件有可能包含多个子组件。就好比html是一个树形结构的DOM,一个Angular2应用也应该是一个树形结构的组件树。

对于这个TodoList的应用,也就是一个appModule,它包含2个功能模块,about和todolist。其中todolist又包含2个组件,一个待办事宜的列表组件,和一个待办事宜的详情组件。列表组件里,我们又把每一个任务显示封装成一个组件。组件树就是下面这样:
angular2-todo-list-component.png

为了更好地演示Angular2的模块化的开发思路,我们又把todolist相关的几个组件封装到一个模块里,这样,这个应用就包含两个模块,about和todolist。下面就是基于模块化的组件图:
components.jpg

路由设计

接下来就考虑这个应用的页面跳转的逻辑,也就是路由设计。
这个应用的路由很简单,打开的时候,默认打开任务列表,点击一个任务时跳转到详情,点击详情里面的返回按钮,又回到列表。还有一个链接可以打开about页面。
routers.jpg

编码

上一部分我们讲到提供的项目模板提供了2个实例组件,这两个组件分别在2个文件夹里,我们保留about,把example文件夹删除,新建一个文件夹,叫todo,也就是说Todo模块会放在这个目录里面。上面设计的todo相关的组件有3个,list、item和detail。我们在todo文件夹里创建这3个目录,每个目录里面再创建相应的 .component.css、.component.html 和 .component.ts文件。

对于组件化的开发,我们可以采用自顶向下的开发流程,先开发根模块,再开发子模块;也可以自底向上的开发。对这个应用,我们采用混合的方式,先定义app模块和组件,然后定义好todo模块的定义,再开发todo模块的每个组件;最后我们再完善todo模块和路由,完成整个app的开发。对于业务代码的开发,大致流程是这样(由于一个Angular2应用的index文件和main.ts文件一般不需要修改,也跟具体业务开发没有多少关系,这里先不考虑):

  1. 先定义整个app的模块,AppModule。这个我们在上一部分的教程里面已经说明,我们先不用修改,当我们完成其他组件的开发以后,再需要完善这里面的内容。
  2. 定义app的路由。一般,我们都是在各个业务模块的定义里面,添加路由定义,然后在app路由里面引入各个模块的路由。而app模块的路由,在项目模板里面已经提供,开始无需修改,等开发完业务模块以后再修改即可。
  3. 再定义这个应用的根组件,AppComponent。这个我们在上一部分的教程里面也已经说明。我们只需要根据我们的设计修改app.component.html的内容。
  4. 定义Todo模块。这个阶段就需要开发todo模块需要的业务模型,包括model, service,还有就是TodoModule。我们先定义好模块的框架,等开发完成子组件以后,再修改TodoModule里面的内容。
  5. 开发todo模块的各个子组件,list, item和detail。
  6. 完善todo模块,定义todo模块的路由等。

在开始之前,如果还没有启动测试服务器,先启动:

1
npm start

这就会编译TypeScript文件,启动测试服务器,并监听文件修改,如果文件有修改,就会自动重新编译,然后刷新页面。打开浏览器,输入url: ‘http://localhost:3000‘ ,打开应用,就可以开始开发了。在开发过程中,不需要重新启动服务器,不需要刷新页面。

AppModule

模板中的app.module.ts文件先不修改,我们需要在开发完todo模块以后,在这里引入新的模块。

App Route

app的路由,我们直接使用项目模板提供的,暂时不需要修改。至于里面的定义及其语法描述,在上一部分介绍项目模板的时候已经说明。

AppComponent

AppComponent是app的入口,每个Angular2的应用都是先加载这个组件,一般这个组件只是包含应用的页面框架和样式。根据我们的页面设计,我们需要修改app.component.html。

1
2
3
4
5
<h1>Todos</h1>
<router-outlet></router-outlet>
<footer>
<a class="about" routerLink='/about'>About</a>
</footer>

在这个页面框架中,h1的标题的部分,下面的<router-outlet></router-outlet>就是根据路由定义加载相应的页面。最下面,有一个footer,里面有一个跳转到about页面的按钮。
AppComponent使用的样式就不多说了,你们可以直接查看实例的项目文件。在下面的说明中,就不会把样式也贴出来说明,读者可以自行查看实力项目的源文件。

Todo模块 - Todo Model

先在todo目录里面建一个文件todo.ts,这是我们的待办事宜的任务的定义:

1
2
3
4
5
6
7
8
export class Todo {
id: number;
title: string = '';
createdDate: Date = new Date();
complete: boolean = false;
constructor() { }
}

这个代码很直观,就是定义了几个属性,其中创建时间的初始值就是当前时间,是否完成的初始值是false.

Todo模块 - Todo Service

接下来,我们就写service的代码,我们创建一个todo.service.ts文件在todo目录里。他负责对任务的增删改查的处理。具体内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import {Injectable} from '@angular/core';
import {Todo} from './todo';
@Injectable()
export class TodoService {
// 为了生成一个自增的id,保存最后一个生成的id
lastId: number = 0;
todos: Todo[] = []; // 保存任务列表
constructor() {}
// 添加一个任务
addTodo(todo: Todo): TodoService {
if (!todo.id) {
todo.id = ++this.lastId;
}
this.todos.push(todo);
// 方法定义中指定返回类型是TodoService,所以这里返回this,也就是当前service对象。
return this;
}
// 从任务列表里删除一个任务
deleteTodoById(id: number): TodoService {
this.todos = this.todos.filter(todo => todo.id !== id);
return this;
}
// 更新一个任务
updateTodoById(id: number, values: Object = {}): Todo {
let todo = this.getTodoById(id);
if (!todo) {
return null;
}
Object.assign(todo, values); // 将更新的values对象的属性值赋给todo对象
return todo;
}
// 获取所有任务列表
getAllTodos(): Todo[] {
return this.todos;
}
// 根据Id获取任务
getTodoById(id: number): Todo {
return this.todos.filter(todo => todo.id === id).pop();
}
// 标记一个任务为完成/未完成
toggleTodoComplete(todo: Todo){
let updatedTodo = this.updateTodoById(todo.id, {
complete: !todo.complete
});
return updatedTodo;
}
}

在这个定义中,我们用@Injectable()标签来定义Service,这样,我们在应用的其他地方,就可以通过Angular2的依赖注入的特性,来自动获取该service对象的实例。

@Injectable()在Angular2中,叫Decorator,也就是装饰器,用来给下面的类TodoService添加额外的属性或方法。在Angular2中,大量使用这种装饰器来定义组件、模块、服务等。

Angular会维护一个service组件的容器,在应用中的某个地方需要用到这个TodoService的时候,我们不用自己创建这个对象的实例,而是通过Angular的Injector自动获取,这就是依赖注入。Angular的Injector会判断这个service的实例在容器中是否存在,如果不存在就创建一个放到容器里并返回,如果已存在,就返回这个实例。所以,在Angular的应用中,我们用service对象,除了实现业务逻辑,还可以用它来保存数据,或者在组件之剑传递参数。

需要注意的一点是,Angular2是组件化、模块化的,那么我们应该在哪一个组件范围内或者模块范围内来实现这个service的自动注入?还是说,在全局的应用系统范围内自动注入?所以,我们需要在一个组件或者模块的定义里面通过providers定义:

1
2
3
providers: [
TodoService, SomeOtherService
],

接下来我们在定义todo模块的时候,就需要用这种方式来定义TodoService,这样这个TodoService的实例在todo模块范围内就能够实现自动注入,并共用一个实例。

Todo模块

现在就可以开始写这个todo模块的定义:

1
2
3
4
5
6
7
8
9
10
11
12
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoService } from './todo.service';
@NgModule({
imports: [CommonModule, FormsModule ],
declarations: [],
providers: [TodoService]
})
export class TodoModule {}

在这里,我们定义了TodoModule,由于这个模块的子组件还没有开发,所以,declarations里面都是空的。我们上面说到,TodoService需要在整个todo模块范围内使用,所以我们在这个里面添加了providers: providers: [TodoService]

Todo组件 - item

在开发todo组件的时候,我们用自底向上的方式开发,先写item组件。这个组件是用于在list组件中显示每一个任务。对于每一个任务,我们可以标记这个任务已经完成,也可以彻底删除这个任务。然后,当点击一个任务的标题的时候,就会跳转到这个任务的详情页。下面就是根据这个需求编写的TodoItemComponent(item.componennt.ts文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Component, Input } from '@angular/core';
import { Router } from '@angular/router';
import { Todo } from '../todo';
import { TodoService } from '../todo.service';
@Component({
selector: 'todo-item',
templateUrl: 'app/todo/item/item.component.html',
styleUrls: ['app/todo/item/item.component.css']
})
export class TodoItemComponent {
@Input() todo: Todo;
constructor(private todoService: TodoService, private router: Router) { }
// 跳转到任务详情页
gotoDetail(todo) {
this.router.navigate(['/todo/detail', todo.id]);
}
// 标记一个任务完成/未完成
toggleTodoComplete(todo) {
this.todoService.toggleTodoComplete(todo);
}
// 删除一个任务
removeTodo(todo) {
this.todoService.deleteTodoById(todo.id);
}
}

在这个定义中,我们用@Component定义了一个组件。里面的selector: 'todo-item'表示在它的父组件(列表)的页面中,item组件的页面会显示到<todo-item>标签里面。
@Input() todo: Todo;这个表示在这个组件中有一个变量todo,它的值是从父组件获得的。
接下来就是他的构造函数:

1
constructor(private todoService: TodoService, private router: Router) { }

private todoService: TodoService这代表Angular会通过依赖注入的方式,将todoService作为一个内部属性。还有router也是通过注入的方式,将一个Router类型的对象作为属性。然后我们就可以在这个组件的其他方法里面使用这两个值。
下面的就是几个页面交互的方法,其他的都不用说吗,就看一下gotoDetail()方法,它使用Angular2的Router组建跳转到任务详情页面。

下面再看看item.componennt.html:

1
2
3
4
5
<div class="todo-item" [class.completed]="todo.complete">
<input class="toggle" type="checkbox" (click)="toggleTodoComplete(todo)" [checked]="todo.complete">
<label (click)="gotoDetail(todo)">{{todo.title}}</label>
<button class="destroy" (click)="removeTodo(todo)"></button>
</div>

这个模板里面的一些语法,可以参考官方的文档,这里只是简单说明一下。

这个[class.completed]="todo.complete"是根据todo变量的complete值,决定在当前这个div标签上是否要添加一个completed的class。

下面就是一个checkbox类型的input,(click)="toggleTodoComplete(todo)"这是给这个checkbox添加了一个点击事件,用户点击的时候调用toggleTodoComplete(todo),也就是上面TodoItemComponent里面的方法,如果这个任务未完成,它就更新他的状态为已经完成;如果已经完成的,就把状态更新为未完成。
后面的[checked]="todo.complete"表示根据这个任务的是否完成的状态todo.complete来设置这个checkbox是否是选中的状态。

再下面就是一个lebel表现,来显示这个任务的标题。这里用这种方式将组建里面的变量显示到页面上。它还添加了一个点击事件(click)="gotoDetail(todo)",用于在用户点击的时候跳转到详情页。

最后就是一个按钮,绑定了一个点击事件(click)="removeTodo(todo)",用来删除一个任务。

这个组件里面还有一个样式的定义文件item.component.css,这里就不多说了,你们可以直接查看实例的项目文件

Todo组件 - list

在list组件中,我们以列表的形式显示任务,在最上面还有一个新建任务的输入框。list.component.ts的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component } from '@angular/core';
import { Todo } from '../todo';
import { TodoService } from '../todo.service';
@Component({
selector: 'todo-list',
templateUrl: 'app/todo/list/list.component.html',
styleUrls: ['app/todo//list/list.component.css']
})
export class TodoListComponent {
newTodo: Todo = new Todo();
constructor(private todoService: TodoService) { }
addTodo() {
this.todoService.addTodo(this.newTodo);
this.newTodo = new Todo();
}
get todos() {
return this.todoService.getAllTodos();
}
}

这个就很简单,定义了模板和样式文件,在构造函数中注入了TodoServiceaddTodo方法在用户新建任务的时候调用。
下面是定义了一个属性todos,但是定义的方式比较特别:

1
2
3
get todos() {
return this.todoService.getAllTodos();
}

它的意思是说定义一个属性todos,同时定义了它的get方法。

下面是list.component.html的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<section class="todoapp">
<header class="header">
<input class="new-todo" placeholder="Get things done!" autofocus="" [(ngModel)]="newTodo.title" (keyup.enter)="addTodo()">
</header>
<section class="main" *ngIf="todos.length > 0">
<ul class="todo-list">
<todo-item *ngFor="let todo of todos" [todo]="todo">
</todo-item>
</ul>
</section>
<footer class="footer" *ngIf="todos.length > 0">
<span class="todo-count"><strong>{{todos.length}}</strong> {{todos.length == 1 ? 'item' : 'items'}} left</span>
</footer>
</section>

其中,[(ngModel)]="newTodo.title"是绑定了一个component的变量newTodo.title到这个输入框,这样你输入的内容会赋值到变量newTodo.title上,如果在TodoListComponent里修改了这个变量的值,它也会更新显示到页面上。
这个输入框还有一个事件绑定:(keyup.enter)="addTodo()",表示当用户敲’输入键’(就是enter键)抬起的时候,就会触发addTodo()方法。
下面的<section>部分是用列表显示任务,它用*ngIf="todos.length > 0"来判断,如果任务列表长度大于1,就显示这个列表,否则就不显示。
下面就是用列表显示所有的任务:

1
2
<todo-item *ngFor="let todo of todos" [todo]="todo">
</todo-item>

这里用了一个*ngFor的语法,代表循环遍历todos,然后用<todo-item>显示任务项。这个标签<todo-item>对应的我们定义的TodoItemComponent 里面的selector,所以TodoItemComponent组件的内容会显示到这个html标签里面。[todo]="todo"表示从list组件里面绑定当前的todo实例变量到item组件里面的todo变量上。这样绑定以后,我们在item的组件和页面里面就可以使用这个变量进行显示和操作。

Todo组件 - detail

在item组件里面,我们有一个点击事件是跳转到任务详情,下面就看看这个详情组件TodoDetailComponent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Todo } from '../todo';
import { TodoService } from '../todo.service';
@Component({
selector: 'todo-detail',
templateUrl: 'app/todo/detail/detail.component.html',
styleUrls: ['app/todo/detail/detail.component.css']
})
export class TodoDetailComponent implements OnInit {
selectedTodo: Todo;
constructor(private route: ActivatedRoute,
private router: Router,
private todoService: TodoService) {}
ngOnInit() {
let todoId = +this.route.snapshot.params['id'];
this.selectedTodo = this.todoService.getTodoById(todoId);
if (!this.selectedTodo) {
this.router.navigate(['/todo/list']);
}
}
goBack() {
window.history.back();
}
}

这个TodoDetailComponent有一个implements OnInit。这就是TypeScript的特性,意思就是这个组件实现了OnInit的接口,它有一个必须实现的方法ngOnInit()。当这个组件被创建的时候,这个ngOnInit()方法就会被调用,相当于一个初始化方法。
在这个初始化方法里面,我们从路由的参数里面获取了参数:

1
+this.route.snapshot.params['id'];

获取参数的方法有几种,这里用的snapshot,从它的字面意思也可以理解,它是用于这个页面是一次性的,每次跳转到这个页面后,会再跳转到其他页面,再次进来的时候会再重新初始化这个页面。而不是在当前页面,通过路由的变化而更新里面的内容。

然后,假如直接在地址栏输入一个url,像’/todo/detail/15’,如果这个id的任务不存在,就应该跳转到列表页:this.router.navigate(['/todo/list']);

todo 路由

我们完成了3个组建以后,就可以开始定义路由了。我们把todo模块需要的路由单独定义在一个文件’todo.routes.ts’里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Route } from '@angular/router';
import { TodoListComponent } from './list/list.component';
import { TodoDetailComponent } from './detail/detail.component';
export const TodoRoutes: Route[] = [
{
path: 'todo/list',
component: TodoListComponent
},
{
path: 'todo/detail/:id',
component: TodoDetailComponent
}
];

这就是定义了2个路由,分别是列表页和详情页,其中详情页路由有一个参数id,在url里面。在上面的detail组件里面,我们从参数里面获取了这个参数,用来获取任务信息。

完善todo模块

上面我们已经定义了todo模块,也就是TodoModule,但是当时我们还没有几个子组件,现在这些组件已经完成,我们就需要完善TodoModule,把这些组件都引入进来,下面就是这个模块的全部内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoListComponent } from './list/list.component';
import { TodoDetailComponent } from './detail/detail.component';
import { TodoItemComponent } from './item/item.component';
import { TodoService } from './todo.service';
@NgModule({
imports: [CommonModule, FormsModule ],
declarations: [TodoListComponent, TodoDetailComponent, TodoItemComponent],
providers: [TodoService]
})
export class TodoModule {}

在这个模块里面的declarations设置里面,我们把几个组件都加在这个里面,这就好像把几个组件一起打包到一个模块里。这样,我们在整个app的模块定义里面引入这个todo模块的时候,我们只需要引入这个TodoModule就可以,而不需要把这个模块里面的所有组件都一个个的引入。

将todo路由加到app路由里

上面我们定义好了todo模块的路由,我们还需要把这个路由加到整个app的路由定义里,不然是无法识别这些路由的。所以我们需要在app.routes.ts里面引入todo.routes。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Routes } from '@angular/router';
import { AboutRoutes } from './about/about.routes';
import { TodoRoutes } from './todo/todo.routes';
export const routes: Routes = [
{
path: '',
redirectTo: '/todo/list',
pathMatch: 'full'
},
...AboutRoutes,
...TodoRoutes
];

在导出的路由里,我们设置默认路径是’/todo/list’,然后把TodoRoutes加入到路由里。

将todo模块加到app模块里

最后,我们还需要在我们的app模块里面把todo模块引入进来,最终的app模块的内容就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { AboutComponent } from './about/about.component';
import { TodoModule } from './todo/todo.module';
import { routes } from './app.routes';
@NgModule({
imports: [BrowserModule, FormsModule, RouterModule.forRoot(routes), TodoModule],
declarations: [AppComponent, AboutComponent],
bootstrap: [AppComponent]
})
export class AppModule {}

到这里,整个应用应该就开发完成了。在这个实例中,我们了解了Angular2的组件、模块,还有一些简单的模板,也介绍了Angular2的依赖注入的特性和service,还有路由。对于Angular的双向绑定,我们虽然没有单独说明,但是在讲解模板和组件定义的时候也提到一些。上面这些,其实就是Angular2的几个基本特性,弄明白这些以后,基本上就可以开始开发一些简单的应用了。

坚持原创技术分享,您的支持将鼓励我继续创作!