Angular2入门教程-2 实现TodoList App
http://codin.im/2016/09/15/angular2-tutorial-2-todolist-app/
这是Angular2入门教程的第二部分,第一部分介绍了Angular2的特性和概念,以及一个Angular2项目的结构的代码。这一部分,我们就基于上一部分的介绍,来开始开发我们的App。
我们要实现的,是一个TodoList(待办事宜)的APP。下面就是这个app最终的效果
如果你们想查看这个教程最终的源文件,可以直接查看项目地址。在下面的讲解中,对很多css样式的定义,就没有列出来说明。如果你们要按照这个教程完成这个应用,需要自己从这里查看相应的样式文件,当然也可以根据喜好去定义样式。
系统设计
即使一个简单的实例,我们也要从Angular2的编程思想出发,对系统进行总体的设计。
组件设计
首先,我们多次提到,Angular2是组件化、模块化的,我们开发一个Angular2的应用,也应该将系统设计成一个个组件,而且一个组件有可能包含多个子组件。就好比html是一个树形结构的DOM,一个Angular2应用也应该是一个树形结构的组件树。
对于这个TodoList的应用,也就是一个appModule,它包含2个组件,about和todolist。其中todolist又包含2个组件,一个待办事宜的列表组件,和一个待办事宜的详情组件。列表组件里,我们又把每一个任务显示封装成一个组件。组件树就是下面这样:
下面是把list组件和它的子组件item的图:
路由设计
接下来就考虑这个应用的页面跳转的逻辑,也就是路由设计。
这个应用的路由很简单,打开的时候,默认打开任务列表,点击一个任务时跳转到详情,点击详情里面的返回按钮,又回到列表。还有一个链接可以打开about页面。
编码
上一部分我们讲到提供的项目模板提供了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文件一般不需要修改,也跟具体业务开发没有多少关系,这里先不考虑):
- 先定义整个app的模块,AppModule。这个我们在上一部分的教程里面已经说明,我们先不用修改,当我们完成其他组件的开发以后,再需要完善这里面的内容。
- 定义app的路由。一般,我们都是在各个业务模块的定义里面,添加路由定义,然后在app路由里面引入各个模块的路由。而app模块的路由,在项目模板里面已经提供,开始无需修改,等开发完业务模块以后再修改即可。
- 再定义这个应用的根组件,AppComponent。这个我们在上一部分的教程里面也已经说明。我们只需要根据我们的设计修改app.component.html的内容。
- 定义Todo模块。这个阶段就需要开发todo模块需要的业务模型,包括model, service,还有就是TodoModule。我们先定义好模块的框架,等开发完成子组件以后,再修改TodoModule里面的内容。
- 开发todo模块的各个子组件,list, item和detail。
- 完善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(); } }
|
这个就很简单,定义了模板和样式文件,在构造函数中注入了TodoService
,addTodo
方法在用户新建任务的时候调用。
下面是定义了一个属性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的几个基本特性,弄明白这些以后,基本上就可以开始开发一些简单的应用了。