(二)使用 Angular CLI 创建 Todo App

在本篇文章中,我们将学会:

  • 使用 AngularCLI 初始化 Todo 应用程序
  • 创建代表个人待办事项的 Todo 类
  • 创建 TodoDataService 服务,用来创建,更新和删除待办事项
  • 使用 AppComponent 组件显示用户界面

我们的应用程序架构会像这样:

本文将讨论标有红色边框的项目,以外的部分会在后续文章中说明。

使用 AngularCLI 初始化 Todo 应用程序


安装 Angular CLI,在命令行中运行以下命令:

$ npm install -g @angular/cli

这将会在系统全局安装 ng 命令

验证是否成功安装 Angular CLI,可在命令行运行:

$ ng version

在我本机运行上述命令,则输出以下结果:

Angular CLI: 7.3.7
Node: 10.15.0
OS: win32 x64
Angular:

那么你已经成功安装Angular CLI了,现在你可以用它来创建Todo应用:

$ ng new todo-app

新的 todo-app 目录被创建,应用程序的目录结构如下:

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
todo-app
│ angular.json
│ package-lock.json
│ package.json
│ README.md
│ tsconfig.json
│ tslint.json

├─e2e
│ │ protractor.conf.js
│ │ tsconfig.e2e.json
│ └─src
│ app.e2e-spec.ts
│ app.po.ts
└─src
│ browserslist
│ favicon.ico
│ index.html
│ karma.conf.js
│ main.ts
│ polyfills.ts
│ styles.css
│ test.ts
│ tsconfig.app.json
│ tsconfig.spec.json
│ tslint.json

├─app
│ app-routing.module.ts
│ app.component.css
│ app.component.html
│ app.component.spec.ts
│ app.component.ts
│ app.module.ts

├─assets

└─environments
environment.prod.ts
environment.ts

你可以进入新生成的目录:

$ cd todo-app

启动Angular CLI开发服务器:

$ ng serve

这将会启动本地开发服务器,可以在浏览器中输入以下URL访问:

http://localhost:4200/

Angular CLI开发服务器支持LiveReload,修改的代码将会自动reload,反映到画面上。

这真是太方便了!

创建Todo类


让我们使用Angular CLI创建Todo类:

ng g class Todo

这将会创建以下文件:

src/app/todo.spec.ts
src/app/todo.ts

打开 src/app/todo.ts

1
2
export class Todo {
}

修改如下

1
2
3
4
5
6
7
8
9
export class Todo {
id: number;
title: string = '';
complete: boolean = false;

constructor(values: Object = {}) {
Object.assign(this, values);
}
}

在这个类定义里面,我们为每个Todo实例声名三个属性

  • id: number, 代办项目的唯一ID
  • title: string, 代办项目的标题
  • complete: boolean, 该代办项目是否完了

我们还定义了一个构造函数,可以很容易的像以下这样得到Todo的实例

1
2
3
4
let todo = new Todo({
title: 'Read SitePoint article',
complete: false
});

再来看一下测试类:

1
2
3
4
5
6
7
import { Todo } from './todo';

describe('Todo', () => {
it('should create an instance', () => {
expect(new Todo()).toBeTruthy();
});
});

让我们添加一些测试用例来验证构造函数是否正常工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {Todo} from './todo';

describe('Todo', () => {
it('should create an instance', () => {
expect(new Todo()).toBeTruthy();
});

it('should accept values in the constructor', () => {
let todo = new Todo({
title: 'hello',
complete: true
});
expect(todo.title).toEqual('hello');
expect(todo.complete).toEqual(true);
});
});

运行测试:

$ ng test

这将会调用 Karma test runner 运行所有单元测试。结果如下:

1
2
3
4
5
6
7
8
 11% building 13/13 modules 0 active19 04 2019 10:57:09.848:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 10:57:09.855:INFO [karma-server]: Karma v4.0.1 server started at http://0.0.0.0:9876/
19 04 2019 10:57:09.856:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
19 04 2019 10:57:09.862:INFO [launcher]: Starting browser Chrome 19 04 2019 10:57:14.044:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 10:57:14.097:INFO [Chrome 73.0.3683 (Windows 10.0.0)]: Connected on socket 417UpstoOGPuv9eNAAAA with id 87200179
Chrome 73.0.3683 (Windows 10.0.0): Executed 5 of 5 SUCCESS (0.117 secs / 0.163 secs)
TOTAL: 5 SUCCESS
TOTAL: 5 SUCCESS

创建 TodoDataService 服务


TodoDataService 用来管理代办项目。
以后我们将会学习如何使用REST API,现在我们把数据直接定义在内存中。

创建服务:

ng g service TodoData

输出如下:

1
2
CREATE src/app/todo-data.service.spec.ts (344 bytes)
CREATE src/app/todo-data.service.ts (137 bytes)

TodoDataService 的代码如下
src/app/todo-data.service.ts

1
2
3
4
5
6
7
8
9
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})
export class TodoDataService {

constructor() { }
}

单元测试用的代码如下
src/app/todo-data.service.spec.ts

1
2
3
4
5
6
7
8
9
10
11
12
import { TestBed } from '@angular/core/testing';

import { TodoDataService } from './todo-data.service';

describe('TodoDataService', () => {
beforeEach(() => TestBed.configureTestingModule({}));

it('should be created', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
expect(service).toBeTruthy();
});
});

打开src/app/todo-data.service.ts,修改TodoDataService如下:

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
57
58
59
60
import { Injectable } from '@angular/core';
import {Todo} from './todo';

@Injectable({
providedIn: 'root'
})
export class TodoDataService {

// Placeholder for last id so we can simulate
// automatic incrementing of ids
lastId: number = 0;

// Placeholder for todos
todos: Todo[] = [];

constructor() { }

// Simulate POST /todos
addTodo(todo: Todo): TodoDataService {
if (!todo.id) {
todo.id = ++this.lastId;
}
this.todos.push(todo);
return this;
}

// Simulate DELETE /todos/:id
deleteTodoById(id: number): TodoDataService {
this.todos = this.todos.filter(todo => todo.id !== id);
return this;
}

// Simulate PUT /todos/:id
updateTodoById(id: number, values: Object = {}): Todo {
let todo = this.getTodoById(id);
if (!todo) {
return null;
}
Object.assign(todo, values);
return todo;
}

// Simulate GET /todos
getAllTodos(): Todo[] {
return this.todos;
}

// Simulate GET /todos/:id
getTodoById(id: number): Todo {
return this.todos.filter(todo => todo.id === id).pop();
}

// Toggle todo complete
toggleTodoComplete(todo: Todo){
let updatedTodo = this.updateTodoById(todo.id, {
complete: !todo.complete
});
return updatedTodo;
}
}

打开src/app/todo-data.service.spec.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
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import { TestBed } from '@angular/core/testing';
import {Todo} from './todo';
import { TodoDataService } from './todo-data.service';

describe('TodoDataService', () => {
beforeEach(() => TestBed.configureTestingModule({}));

it('should be created', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
expect(service).toBeTruthy();
});

describe('#getAllTodos()', () => {
it('should return an empty array by default', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
expect(service.getAllTodos()).toEqual([]);
});
it('should return all todos', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo1 = new Todo({title: 'Hello 1', complete: false});
let todo2 = new Todo({title: 'Hello 2', complete: true});
service.addTodo(todo1);
service.addTodo(todo2);
expect(service.getAllTodos()).toEqual([todo1, todo2]);
});
});

describe('#save(todo)', () => {
it('should automatically assign an incrementing id', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo1 = new Todo({title: 'Hello 1', complete: false});
let todo2 = new Todo({title: 'Hello 2', complete: true});
service.addTodo(todo1);
service.addTodo(todo2);
expect(service.getTodoById(1)).toEqual(todo1);
expect(service.getTodoById(2)).toEqual(todo2);
});
});

describe('#deleteTodoById(id)', () => {
it('should remove todo with the corresponding id', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo1 = new Todo({title: 'Hello 1', complete: false});
let todo2 = new Todo({title: 'Hello 2', complete: true});
service.addTodo(todo1);
service.addTodo(todo2);
expect(service.getAllTodos()).toEqual([todo1, todo2]);
service.deleteTodoById(1);
expect(service.getAllTodos()).toEqual([todo2]);
service.deleteTodoById(2);
expect(service.getAllTodos()).toEqual([]);
});

it('should not removing anything if todo with corresponding id is not found', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo1 = new Todo({title: 'Hello 1', complete: false});
let todo2 = new Todo({title: 'Hello 2', complete: true});
service.addTodo(todo1);
service.addTodo(todo2);
expect(service.getAllTodos()).toEqual([todo1, todo2]);
service.deleteTodoById(3);
expect(service.getAllTodos()).toEqual([todo1, todo2]);
});

});

describe('#updateTodoById(id, values)', () => {
it('should return todo with the corresponding id and updated data', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo = new Todo({title: 'Hello 1', complete: false});
service.addTodo(todo);
let updatedTodo = service.updateTodoById(1, {
title: 'new title'
});
expect(updatedTodo.title).toEqual('new title');
});

it('should return null if todo is not found', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo = new Todo({title: 'Hello 1', complete: false});
service.addTodo(todo);
let updatedTodo = service.updateTodoById(2, {
title: 'new title'
});
expect(updatedTodo).toEqual(null);
});

});

describe('#toggleTodoComplete(todo)', () => {
it('should return the updated todo with inverse complete status', () => {
const service: TodoDataService = TestBed.get(TodoDataService);
let todo = new Todo({title: 'Hello 1', complete: false});
service.addTodo(todo);
let updatedTodo = service.toggleTodoComplete(todo);
expect(updatedTodo.complete).toEqual(true);
service.toggleTodoComplete(todo);
expect(updatedTodo.complete).toEqual(false);
});
});

});

运行测试:

$ ng test

结果如下:

1
2
3
4
5
6
7
 11% building 12/12 modules 0 active19 04 2019 11:45:20.342:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 11:45:20.350:INFO [karma-server]: Karma v4.0.1 server started at http://0.0.0.0:9876/
19 04 2019 11:45:20.351:INFO [launcher]: Launching browsers Chrome with concurrency unlimited 11% building 15/15 modules 0 active19 04 2019 11:45:20.378:INFO [launcher]: Starting browser Chrome 19 04 2019 11:45:24.851:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 11:45:24.902:INFO [Chrome 73.0.3683 (Windows 10.0.0)]: Connected on socket 8KiTWeIsmEK4BogfAAAA with id 63883826
Chrome 73.0.3683 (Windows 10.0.0): Executed 14 of 14 SUCCESS (0.119 secs / 0.236 secs)
TOTAL: 14 SUCCESS
TOTAL: 14 SUCCESS

编辑 AppComponent 组件


当我们初始化Todo应用程序的时候,Angular CLI 自动生成 AppComponent 组件:

1
2
3
4
src/app/app.component.css
src/app/app.component.html
src/app/app.component.spec.ts
src/app/app.component.ts

打开src/app/app.component.html,更新成如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<section class="todoapp">
<header class="header">
<h1>Todos</h1>
<input class="new-todo" placeholder="What needs to be done?" autofocus="" [(ngModel)]="newTodo.title" (keyup.enter)="addTodo()">
</header>
<section class="main" *ngIf="todos.length > 0">
<ul class="todo-list">
<li *ngFor="let todo of todos" [class.completed]="todo.complete">
<div class="view">
<input class="toggle" type="checkbox" (click)="toggleTodoComplete(todo)" [checked]="todo.complete">
<label>{{todo.title}}</label>
<button class="destroy" (click)="removeTodo(todo)"></button>
</div>
</li>
</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>

  • [property]=”expression”:set property of an element to the value of expression
  • (event)=”statement”: execute statement when event occurred
  • [(property)]=”expression”: create two-way binding with expression
  • [class.special]=”expression”: add special CSS class to element when the value of expression is truthy
  • [style.color]=”expression”: set color CSS property to the value of expression
    英文原文更贴切

让我们来看一下代码的含义:

1
<input class="new-todo" placeholder="What needs to be done?" autofocus="" [(ngModel)]="newTodo.title" (keyup.enter)="addTodo()">

  • [(ngModel)]=”newTodo.title”: adds a two-way binding between the input value and newTodo.title
    需要在app.module.ts中引入FormsModule
  • (keyup.enter)=”addTodo()”: tells Angular to execute addTodo() when the enter key was pressed while typing in the input element
1
<section class="main" *ngIf="todos.length > 0">
  • *ngIf=”todos.length > 0”: only show the section element and all its children when there is at least one todo
1
<li *ngFor="let todo of todos" [class.completed]="todo.complete">
  • *ngFor=”let todo of todos”: loop over all todos and assign current todo to a variable called todo for each iteration
  • [class.completed]=”todo.complete”: apply CSS class completed to li element when todo.complete is truthy
1
2
3
4
5
<div class="view">
<input class="toggle" type="checkbox" (click)="toggleTodoComplete(todo)" [checked]="todo.complete">
<label></label>
<button class="destroy" (click)="removeTodo(todo)"></button>
</div>
  • (click)=”toggleTodoComplete(todo)”: execute toggleTodoComplete(todo) when the checkbox is clicked
  • [checked]=”todo.complete”: assign the value of todo.complete to the property checked of the element
  • (click)=”removeTodo(todo)”: execute removeTodo(todo) when the destroy button is clicked

组件类AppComponent定义在src/app/app.component.ts
Angular CLI 已经自动生成了一些基本代码:

1
2
3
4
5
6
7
8
9
10
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'todo-app';
}

我们可以加入我们自己的逻辑。

我们需要在AppComponent注入TodoDataService服务
首先引入TodoDataService并在Component注解里的providers数组里声明

1
2
3
4
5
6
7
8
9
10
// Import class so we can register it as dependency injection token
import {TodoDataService} from './todo-data.service';

@Component({
// ...
providers: [TodoDataService]
})
export class AppComponent {
// ...
}

The AppComponent’s dependency injector will now recognize the TodoDataService class as a dependency injection token and return a single instance of TodoDataService when we ask for it.

Angular’s dependency injection system accepts a variety of dependency injection recipes. The syntax above is a shorthand notation for the Class provider recipe that provides dependencies using the singleton pattern. Check out Angular’s dependency injection documentation for more details.

Now that the components dependency injector knows what it needs to provide, we ask it to inject the TodoDataService instance in our component by specifying the dependency in the AppComponent constructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Import class so we can use it as dependency injection token in the constructor
import {TodoDataService} from './todo-data.service';

@Component({
// ...
})
export class AppComponent {

// Ask Angular DI system to inject the dependency
// associated with the dependency injection token `TodoDataService`
// and assign it to a property called `todoDataService`
constructor(private todoDataService: TodoDataService) {
}

// Service is now available as this.todoDataService
toggleTodoComplete(todo) {
this.todoDataService.toggleTodoComplete(todo);
}
}

The use of public or private on arguments in the constructor is a shorthand notation that allows us to automatically create properties with that name, so:

1
2
3
4
5
class AppComponent {

constructor(private todoDataService: TodoDataService) {
}
}

This is a shorthand notation for:

1
2
3
4
5
6
7
8
class AppComponent {

private todoDataService: TodoDataService;

constructor(todoDataService: TodoDataService) {
this.todoDataService = todoDataService;
}
}

AppComponent类最终修改为:

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
import {Component} from '@angular/core';
import {Todo} from './todo';
import {TodoDataService} from './todo-data.service';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
providers: [TodoDataService]
})
export class AppComponent {

newTodo: Todo = new Todo();

constructor(private todoDataService: TodoDataService) {
}

addTodo() {
this.todoDataService.addTodo(this.newTodo);
this.newTodo = new Todo();
}

toggleTodoComplete(todo) {
this.todoDataService.toggleTodoComplete(todo);
}

removeTodo(todo) {
this.todoDataService.deleteTodoById(todo.id);
}

get todos() {
return this.todoDataService.getAllTodos();
}
}

在用浏览器确认结果之前,先运行一下单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
 11% building 12/12 modules 0 active19 04 2019 16:17:43.842:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 16:17:43.849:INFO [karma-server]: Karma v4.0.1 server started at http://0.0.0.0:9876/
19 04 2019 16:17:43.849:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
19 04 2019 16:17:43.855:INFO [launcher]: Starting browser Chrome 19 04 2019 16:17:48.227:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 16:17:48.278:INFO [Chrome 73.0.3683 (Windows 10.0.0)]: Connected on socket Cf5Oz6xR4SIbGUxlAAAA with id 5913544
Chrome 73.0.3683 (Windows 10.0.0) AppComponent should create the app FAILED
Can't bind to 'ngModel' since it isn't a known property of 'input'. ("">
<h1>Todos</h1>
<input class="new-todo" placeholder="What needs to be done?" autofocus="" [ERROR ->][(ngModel)]="newTodo.title" (keyup.enter)="addTodo()">
</header>
<section class="main" *ngIf="tod"): ng:///DynamicTestModule/AppComponent.html@3:78
Error: Template parse errors:
at syntaxError (node_modules/@angular/compiler/fesm5/compiler.js:2430:1)

错误信息:Can’t bind to ‘ngModel’ since it isn’t a known property of ‘input’. (“”>
原因是Angular编译器不识别ngModel,使用TestBed.createComponent()方法的时候,FormsModule没用被AppComponent载入。
打开src/app/app.component.spec.ts,引入FormsModule,更新并追加正确的测试用例:

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
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';
import { Todo } from './todo';

describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule,
FormsModule
],
declarations: [
AppComponent
],
}).compileComponents();
}));

it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});

it(`should have a newTodo todo`, () => {
let fixture = TestBed.createComponent(AppComponent);
let app = fixture.debugElement.componentInstance;
expect(app.newTodo instanceof Todo).toBeTruthy()
});

it('should display "Todos" in h1 tag', () => {
let fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
let compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Todos');
});
});

运行测试用例,结果如下:

1
2
3
4
5
6
7
11% building 14/14 modules 0 active19 04 2019 16:31:18.154:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 16:31:18.161:INFO [karma-server]: Karma v4.0.1 server started at http://0.0.0.0:9876/
19 04 2019 16:31:18.162:INFO [launcher]: Launching browsers Chrome with concurrency unlimited 11% building 15/15 modules 0 active19 04 2019 16:31:18.173:INFO [launcher]: Starting browser Chrome 19 04 2019 16:31:22.213:WARN [karma]: No captured browser, open http://localhost:9876/
19 04 2019 16:31:22.276:INFO [Chrome 73.0.3683 (Windows 10.0.0)]: Connected on socket T2dVMSPq7ZeNBQsmAAAA with id 8843769
Chrome 73.0.3683 (Windows 10.0.0): Executed 14 of 14 SUCCESS (0.203 secs / 0.265 secs)
TOTAL: 14 SUCCESS
TOTAL: 14 SUCCESS

启动应用程序

ng serve -o

画面表示如下:

Author: jimmy367
Link: http://www.ohtudou.com/2019/04/16/learn-angular-part2/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
支付宝打赏