Angular 從 0 到 1 (四)史上最簡單的 Angular 教程

接灰的電子產品發表於2016-12-26

第一節:初識Angular-CLI
第二節:登入元件的構建
第三節:建立一個待辦事項應用
第四節:進化!模組化你的應用
第五節:多使用者版本的待辦事項應用
第六節:使用第三方樣式庫及模組優化用
第七節:給元件帶來活力
Rx--隱藏在Angular 2.x中利劍
Redux你的Angular 2應用
第八節:查缺補漏大合集(上)
第九節:查缺補漏大合集(下)

第四節:進化!模組化你的應用

一個複雜元件的分拆

上一節的末尾我偷懶的甩出了大量程式碼,可能你看起來都有點暈了,這就是典型的一個功能經過一段時間的需求累積後,程式碼也不可避免的臃腫起來。現在我們看看怎麼分拆一下吧。

Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
image_1b11kjibcelb6upnb21su41dilm.png-59.5kB

我們的應用似乎可以分為Header,Main和Footer幾部分。首先我們來建立一個新的Component,鍵入ng g c todo/todo-footer。然後將src\app\todo\todo.component.html中的<footer>...</footer>段落剪下到src\app\todo\todo-footer\todo-footer.component.html中。

  <footer class="footer" *ngIf="todos?.length > 0">
    <span class="todo-count">
      <strong>{{todos?.length}}</strong> {{todos?.length == 1 ? 'item' : 'items'}} left
    </span>
    <ul class="filters">
      <li><a href="">All</a></li>
      <li><a href="">Active</a></li>
      <li><a href="">Completed</a></li>
    </ul>
    <button class="clear-completed">Clear completed</button>
  </footer>複製程式碼

觀察上面的程式碼,我們看到似乎所有的變數都是todos?.length,這提醒我們其實對於Footer來說,我們並不需要傳入todos,而只需要給出一個item計數即可。那麼我們來把所有的todos?.length改成itemCount

<footer class="footer" *ngIf="itemCount > 0">
  <span class="todo-count">
    <strong>{{itemCount}}</strong> {{itemCount == 1 ? 'item' : 'items'}} left
  </span>
  <ul class="filters">
    <li><a href="">All</a></li>
    <li><a href="">Active</a></li>
    <li><a href="">Completed</a></li>
  </ul>
  <button class="clear-completed">Clear completed</button>
</footer>複製程式碼

這樣的話也就是說如果在src\app\todo\todo.component.html中我們可以用<app-todo-footer [itemCount]="todos?.length"></app-todo-footer>去傳遞todo專案計數給Footer即可。所以在src\app\todo\todo.component.html中剛才我們剪下掉程式碼的位置加上這句吧。當然,如果要讓父元件可以傳遞值給子元件,我們還需要在子元件中宣告一下。@Input()是輸入型繫結的修飾符,用於把資料從父元件傳到子元件。

import { Component, OnInit, Input } from '@angular/core';

@Component({
  selector: 'app-todo-footer',
  templateUrl: './todo-footer.component.html',
  styleUrls: ['./todo-footer.component.css']
})
export class TodoFooterComponent implements OnInit {
  //宣告itemCount是可以一個可輸入值(從引用者處)
  @Input() itemCount: number;
  constructor() { }
  ngOnInit() {
  }
}複製程式碼

執行一下看看效果,應該一切正常!

類似的我們建立一個Header元件,鍵入ng g c todo/todo-header,同樣的把下面的程式碼從src\app\todo\todo.component.html中剪下到src\app\todo\todo-header\todo-header.component.html

<header class="header">
  <h1>Todos</h1>
  <input class="new-todo" placeholder="What needs to be done?" autofocus="" [(ngModel)]="desc" (keyup.enter)="addTodo()">
</header>複製程式碼

這段程式碼看起來有點麻煩,主要原因是我們好像不但需要給子元件輸入什麼,而且希望子元件給父元件要輸出一些東西,比如輸入框的值和按下Enter鍵的訊息等。當然你可能猜到了,Angular2裡面有@Input()就相應的有@Output()修飾符。
我們希望輸入框的佔位文字(沒有輸入的情況下顯示的預設文字)是一個輸入型的引數,在Enter鍵抬起時可以發射一個事件給父元件,同時我們也希望在輸入框輸入文字時父元件能夠得到這個字串。也就是說父元件呼叫子元件時看起來是下面的樣子,相當於我們自定義的元件中提供一些事件,父元件呼叫時可以寫自己的事件處理方法,而$event就是子元件發射的事件物件:

<app-todo-header 
    placeholder="What do you want"
    (onTextChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
</app-todo-header>複製程式碼

但是第三個需求也就是“在輸入框輸入文字時父元件能夠得到這個字串”,這個有點問題,如果每輸入一個字元都要回傳給父元件的話,系統會過於頻繁進行這種通訊,有可能會有效能的問題。那麼我們希望可以有一個類似濾波器的東東,它可以過濾掉一定時間內的事件。因此我們定義一個輸入型引數delay。

<app-todo-header 
    placeholder="What do you want"
    delay="400"
    (textChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
</app-todo-header>複製程式碼

現在的標籤引用應該是上面這個樣子,但我們只是策劃了它看起來是什麼樣子,還沒有做呢。我們一起動手看看怎麼做吧。
todo-header.component.html的模板中我們調整了一些變數名和引數以便讓大家不混淆子元件自己的模板和父元件中引用子元件的模板片段。

//todo-header.component.html
<header class="header">
  <h1>Todos</h1>
  <input
    class="new-todo"
    [placeholder]="placeholder"
    autofocus=""
    [(ngModel)]="inputValue"
    (keyup.enter)="enterUp()">
</header>複製程式碼

記住子元件的模板是描述子元件自己長成什麼樣子,應該有哪些行為,這些東西和父元件沒有任何關係。比如todo-header.component.html中的placeholder就是HTML標籤Input中的一個屬性,和父元件沒有關聯,如果我們不在todo-header.component.ts中宣告@Input() placeholder,那麼子元件就沒有這個屬性,在父元件中也無法設定這個屬性。父元件中的宣告為@Input()的屬性才會成為子元件對外可見的屬性,我們完全可以把@Input() placeholder宣告為@Input() hintText,這樣的話在引用header元件時,我們就需要這樣寫<app-todo-header hintText="What do you want" ...
現在看一下todo-header.component.ts

import { Component, OnInit, Input, Output, EventEmitter, ElementRef } from '@angular/core';
import {Observable} from 'rxjs/Rx';
import 'rxjs/Observable';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';

@Component({
  selector: 'app-todo-header',
  templateUrl: './todo-header.component.html',
  styleUrls: ['./todo-header.component.css']
})
export class TodoHeaderComponent implements OnInit {
  inputValue: string = '';
  @Input() placeholder: string = 'What needs to be done?';
  @Input() delay: number = 300;

  //detect the input value and output this to parent
  @Output() textChanges = new EventEmitter<string>();
  //detect the enter keyup event and output this to parent
  @Output() onEnterUp = new EventEmitter<boolean>();

  constructor(private elementRef: ElementRef) {
    const event$ = Observable.fromEvent(elementRef.nativeElement, 'keyup')
      .map(() => this.inputValue)
      .debounceTime(this.delay)
      .distinctUntilChanged();
    event$.subscribe(input => this.textChanges.emit(input));
  }
  ngOnInit() {
  }
  enterUp(){
    this.onEnterUp.emit(true);
    this.inputValue = '';
  }
}複製程式碼

下面我們來分析一下程式碼:
placeholder和delay作為2個輸入型變數,這樣<app-todo-header>標籤中就可以設定這兩個屬性了。
接下來我們看到了由@Output修飾的onTextChanges和onEnterUp,這兩個顧名思義是分別處理文字變化和Enter鍵抬起事件的,這兩個變數呢都定義成了EventEmitter(事件發射器)。我們會在子元件的邏輯程式碼中以適當的條件去發射對應事件,而父元件會接收到這些事件。我們這裡採用了2中方法來觸發發射器

  • enterUp:這個是比較常規的方法,在todo-header.component.html中我們定義了(keyup.enter)="enterUp()",所以在元件的enterUp方法中,我們直接讓onEnterUp發射了對應事件。
  • 構造器中使用Rx:這裡涉及了很多新知識,首先我們注入了ElementRef,這個是一個Angular中需要謹慎使用的物件,因為它可以讓你直接操作DOM,也就是HTML的元素和事件。同時我們使用了Rx(響應式物件),Rx是一個很複雜的話題,這裡我們不展開了,但我們主要是利用Observable去觀察HTML中的keyup事件,然後在這個事件流中做一個轉換把輸入框的值發射出來(map),應用一個時間的濾波器(debounceTime),然後應用一個篩選器(distinctUntilChanged)。這裡由於這個事件的發射條件是依賴於輸入時的當時條件,我們沒有辦法按前面的以模板事件觸發做處理。
    最後需要在todo.component.ts中加入對header輸出引數發射事件的處理
    onTextChanges(value) {
     this.desc = value;
    }複製程式碼

最後由於元件分拆後,我們希望也分拆一下css,這裡就直接給程式碼了
todo-header.component.css的樣式如下:

h1 {
    position: absolute;
    top: -155px;
    width: 100%;
    font-size: 100px;
    font-weight: 100;
    text-align: center;
    color: rgba(175, 47, 47, 0.15);
    -webkit-text-rendering: optimizeLegibility;
    -moz-text-rendering: optimizeLegibility;
    text-rendering: optimizeLegibility;
}
input::-webkit-input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
input::-moz-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
input::input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
.new-todo,
.edit {
    position: relative;
    margin: 0;
    width: 100%;
    font-size: 24px;
    font-family: inherit;
    font-weight: inherit;
    line-height: 1.4em;
    border: 0;
    color: inherit;
    padding: 6px;
    border: 1px solid #999;
    box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}
.new-todo {
    padding: 16px 16px 16px 60px;
    border: none;
    background: rgba(0, 0, 0, 0.003);
    box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
}複製程式碼

todo-footer.component.css的樣式如下

.footer {
    color: #777;
    padding: 10px 15px;
    height: 20px;
    text-align: center;
    border-top: 1px solid #e6e6e6;
}
.footer:before {
    content: '';
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    height: 50px;
    overflow: hidden;
    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
                0 8px 0 -3px #f6f6f6,
                0 9px 1px -3px rgba(0, 0, 0, 0.2),
                0 16px 0 -6px #f6f6f6,
                0 17px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
    float: left;
    text-align: left;
}
.todo-count strong {
    font-weight: 300;
}
.filters {
    margin: 0;
    padding: 0;
    list-style: none;
    position: absolute;
    right: 0;
    left: 0;
}
.filters li {
    display: inline;
}
.filters li a {
    color: inherit;
    margin: 3px;
    padding: 3px 7px;
    text-decoration: none;
    border: 1px solid transparent;
    border-radius: 3px;
}
.filters li a:hover {
    border-color: rgba(175, 47, 47, 0.1);
}
.filters li a.selected {
    border-color: rgba(175, 47, 47, 0.2);
}
.clear-completed,
html .clear-completed:active {
    float: right;
    position: relative;
    line-height: 20px;
    text-decoration: none;
    cursor: pointer;
}
.clear-completed:hover {
    text-decoration: underline;
}
@media (max-width: 430px) {
    .footer {
        height: 50px;
    }
    .filters {
        bottom: 10px;
    }
}複製程式碼

當然上述程式碼要從todo.component.css中刪除,現在的todo.component.css看起來是這個樣子

.todoapp {
    background: #fff;
    margin: 130px 0 40px 0;
    position: relative;
    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
                0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.main {
    position: relative;
    z-index: 2;
    border-top: 1px solid #e6e6e6;
}
.todo-list {
    margin: 0;
    padding: 0;
    list-style: none;
}
.todo-list li {
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #ededed;
}
.todo-list li:last-child {
    border-bottom: none;
}
.todo-list li.editing {
    border-bottom: none;
    padding: 0;
}
.todo-list li.editing .edit {
    display: block;
    width: 506px;
    padding: 12px 16px;
    margin: 0 0 0 43px;
}
.todo-list li.editing .view {
    display: none;
}
.todo-list li .toggle {
    text-align: center;
    width: 40px;
    /* auto, since non-WebKit browsers doesn't support input styling */
    height: auto;
    position: absolute;
    top: 0;
    bottom: 0;
    margin: auto 0;
    border: none; /* Mobile Safari */
    -webkit-appearance: none;
    appearance: none;
}
.todo-list li .toggle:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#ededed" stroke-width="3"/></svg>');
}
.todo-list li .toggle:checked:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#bddad5" stroke-width="3"/><path fill="#5dc2af" d="M72 25L42 71 27 56l-4 4 20 20 34-52z"/></svg>');
}
.todo-list li label {
    word-break: break-all;
    padding: 15px 60px 15px 15px;
    margin-left: 45px;
    display: block;
    line-height: 1.2;
    transition: color 0.4s;
}
.todo-list li.completed label {
    color: #d9d9d9;
    text-decoration: line-through;
}
.todo-list li .destroy {
    display: none;
    position: absolute;
    top: 0;
    right: 10px;
    bottom: 0;
    width: 40px;
    height: 40px;
    margin: auto 0;
    font-size: 30px;
    color: #cc9a9a;
    margin-bottom: 11px;
    transition: color 0.2s ease-out;
}
.todo-list li .destroy:hover {
    color: #af5b5e;
}
.todo-list li .destroy:after {
    content: '×';
}
.todo-list li:hover .destroy {
    display: block;
}
.todo-list li .edit {
    display: none;
}
.todo-list li.editing:last-child {
    margin-bottom: -1px;
}
label[for='toggle-all'] {
    display: none;
}
.toggle-all {
    position: absolute;
    top: -55px;
    left: -12px;
    width: 60px;
    height: 34px;
    text-align: center;
    border: none; /* Mobile Safari */
}
.toggle-all:before {
    content: '❯';
    font-size: 22px;
    color: #e6e6e6;
    padding: 10px 27px 10px 27px;
}
.toggle-all:checked:before {
    color: #737373;
}
/*
    Hack to remove background from Mobile Safari.
    Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
    .toggle-all,
    .todo-list li .toggle {
        background: none;
    }
    .todo-list li .toggle {
        height: 40px;
    }
    .toggle-all {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
        -webkit-appearance: none;
        appearance: none;
    }
}複製程式碼

封裝成獨立模組

現在我們的todo目錄下好多檔案了,而且我們觀察到這個功能相對很獨立。這種情況下我們似乎沒有必要將所有的元件都宣告在根模組AppModule當中,因為類似像子元件沒有被其他地方用到。Angular中提供了一種組織方式,那就是模組。模組和根模組很類似,我們先在todo目錄下建一個檔案src\app\todo\todo.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { HttpModule } from '@angular/http';
import { FormsModule } from '@angular/forms';

import { routing} from './todo.routes'

import { TodoComponent } from './todo.component';
import { TodoFooterComponent } from './todo-footer/todo-footer.component';
import { TodoHeaderComponent } from './todo-header/todo-header.component';
import { TodoService } from './todo.service';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    HttpModule,
    routing
  ],
  declarations: [
    TodoComponent,
    TodoFooterComponent,
    TodoHeaderComponent
  ],
  providers: [
    {provide: 'todoService', useClass: TodoService}
    ]
})
export class TodoModule {}複製程式碼

注意一點,我們沒有引入BrowserModule,而是引入了CommonModule。匯入 BrowserModule 會讓該模組公開的所有元件、指令和管道在 AppModule 下的任何元件模板中直接可用,而不需要額外的繁瑣步驟。CommonModule 提供了很多應用程式中常用的指令,包括 NgIf 和 NgFor 等。BrowserModule 匯入了 CommonModule 並且 重新匯出 了它。 最終的效果是:只要匯入 BrowserModule 就自動獲得了 CommonModule 中的指令。幾乎所有要在瀏覽器中使用的應用的 根模組 ( AppModule )都應該從 @angular/platform-browser 中匯入 BrowserModule 。在其它任何模組中都 不要匯入 BrowserModule,應該改成匯入 CommonModule 。 它們需要通用的指令。它們不需要重新初始化全應用級的提供商。
由於和根模組很類似,我們就不展開講了。需要做的事情是把TodoComponent中的TodoService改成用@Inject('todoService')來注入。但是注意一點,我們需要模組自己的路由定義。我們在todo目錄下建立一個todo.routes.ts的檔案,和根目錄下的類似。

import { Routes, RouterModule } from '@angular/router';
import { TodoComponent } from './todo.component';

export const routes: Routes = [
  {
    path: 'todo',
    component: TodoComponent
  }
];
export const routing = RouterModule.forChild(routes);複製程式碼

這裡我們只定義了一個路由就是“todo”,另外一點和根路由不一樣的是export const routing = RouterModule.forChild(routes);,我們用的是forChild而不是forRoot,因為forRoot只能用於根目錄,所有非根模組的其他模組路由都只能用forChild。下面就得更改根路由了,src\app\app.routes.ts看起來是這個樣子:

import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  },
  {
    path: 'login',
    component: LoginComponent
  },
  {
    path: 'todo',
    redirectTo: 'todo'
  }
];
export const routing = RouterModule.forRoot(routes);複製程式碼

注意到我們去掉了TodoComponent的依賴,而且更改todo路徑定義為redirecTo到todo路徑,但沒有給出元件,這叫做“無元件路由”,也就是說後面的事情是TodoModule負責的。
此時我們就可以去掉AppModule中引用的Todo相關的元件了。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { TodoModule } from './todo/todo.module';

import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryTodoDbService } from './todo/todo-data';

import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { AuthService } from './core/auth.service';
import { routing } from './app.routes';


@NgModule({
  declarations: [
    AppComponent,
    LoginComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    InMemoryWebApiModule.forRoot(InMemoryTodoDbService),
    routing,
    TodoModule
  ],
  providers: [
    {provide: 'auth',  useClass: AuthService}
    ],
  bootstrap: [AppComponent]
})
export class AppModule { }複製程式碼

而且此時我們注意到其實沒有任何一個地方目前還需引用<app-todo></app-todo>了,這就是說我們可以安全地把selector: 'app-todo',從Todo元件中的@Component修飾符中刪除了。

更真實的web服務

這裡我們不想再使用記憶體Web服務了,因為如果使用,我們無法將其封裝在TodoModule中。所以我們使用一個更“真”的web服務:json-server。使用npm install -g json-server安裝json-server。然後在todo目錄下建立todo-data.json

{
  "todos": [
    {
      "id": "f823b191-7799-438d-8d78-fcb1e468fc78",
      "desc": "blablabla",
      "completed": false
    },
    {
      "id": "dd65a7c0-e24f-6c66-862e-0999ea504ca0",
      "desc": "getting up",
      "completed": false
    },
    {
      "id": "c1092224-4064-b921-77a9-3fc091fbbd87",
      "desc": "you wanna try",
      "completed": false
    },
    {
      "id": "e89d582b-1a90-a0f1-be07-623ddb29d55e",
      "desc": "have to say good",
      "completed": false
    }
  ]
}複製程式碼

src\app\todo\todo.service.ts中更改

// private api_url = 'api/todos';
  private api_url = 'http://localhost:3000/todos';複製程式碼

並將addTodo和getTodos中then語句中的 res.json().data替換成res.json()。在AppModule中刪掉記憶體web服務相關的語句。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { TodoModule } from './todo/todo.module';

import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { AuthService } from './core/auth.service';
import { routing } from './app.routes';


@NgModule({
  declarations: [
    AppComponent,
    LoginComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    routing,
    TodoModule
  ],
  providers: [
    {provide: 'auth',  useClass: AuthService}
    ],
  bootstrap: [AppComponent]
})
export class AppModule { }複製程式碼

另外開啟一個命令視窗,進入工程目錄,輸入json-server ./src/app/todo/todo-data.json

欣賞一下成果吧

Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
image_1b12b5v4onlm16ai1bdn7pu143e9.png-165.7kB

完善Todo應用

在結束本節前,我們得給Todo應用收個尾,還差一些功能沒完成:

  • 從架構上來講,我們似乎還可以進一步構建出TodoList和TodoItem兩個元件
  • 全選並反轉狀態
  • 底部篩選器:All,Active,Completed
  • 清理已完成專案

TodoItem和TodoList元件

在命令列視窗鍵入ng g c todo/todo-item,angular-cli會十分聰明的幫你在todo目錄下建好TodoItem元件,並且在TodoModule中宣告。一般來說,如果要生成某個模組下的元件,輸入ng g c 模組名稱/元件名稱。 好的,類似的我們再建立一個TodoList控制元件,ng g c todo/todo-list。我們希望未來的todo.component.html是下面這個樣子的

//todo.component.html
<section class="todoapp">
  <app-todo-header
    placeholder="What do you want"
    (textChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
  </app-todo-header>
  <app-todo-list
    [todos]="todos"
    (onRemoveTodo)="removeTodo($event)"
    (onToggleTodo)="toggleTodo($event)"
    >
  </app-todo-list>
  <app-todo-footer [itemCount]="todos?.length"></app-todo-footer>
</section>複製程式碼

那麼TodoItem哪兒去了呢?TodoItem是TodoList的子元件,TodoItem的模板應該是todos迴圈內的一個todo的模板。TodoList的HTML模板看起來應該是下面的樣子:

<section class="main" *ngIf="todos?.length > 0">
  <input class="toggle-all" type="checkbox">
  <ul class="todo-list">
    <li *ngFor="let todo of todos" [class.completed]="todo.completed">
      <app-todo-item
        [isChecked]="todo.completed"
        (onToggleTriggered)="onToggleTriggered(todo)"
        (onRemoveTriggered)="onRemoveTriggered(todo)"
        [todoDesc]="todo.desc">
      </app-todo-item>
    </li>
  </ul>
</section>複製程式碼

那麼我們先從最底層的TodoItem看,這個元件怎麼剝離出來?首先來看todo-item.component.html

<div class="view">
  <input class="toggle" type="checkbox" (click)="toggle()" [checked]="isChecked">
  <label [class.labelcompleted]="isChecked" (click)="toggle()">{{todoDesc}}</label>
  <button class="destroy" (click)="remove(); $event.stopPropagation()"></button>
</div>複製程式碼

我們需要確定有哪些輸入型和輸出型引數

  • isChecked:輸入型引數,用來確定是否被選中,由父元件(TodoList)設定
  • todoDesc:輸入型引數,顯示Todo的文字描述,由父元件設定
  • onToggleTriggered:輸出型引數,在使用者點選checkbox或label時以事件形式通知父元件。在TodoItem中我們是在處理使用者點選事件時在toggle方法中發射這個事件。
  • onRemoveTriggered:輸出型引數,在使用者點選刪除按鈕時以事件形式通知父元件。在TodoItem中我們是在處理使用者點選按鈕事件時在remove方法中發射這個事件。
//todo-item.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-todo-item',
  templateUrl: './todo-item.component.html',
  styleUrls: ['./todo-item.component.css']
})
export class TodoItemComponent{
  @Input() isChecked: boolean = false;
  @Input() todoDesc: string = '';
  @Output() onToggleTriggered = new EventEmitter<boolean>();
  @Output() onRemoveTriggered = new EventEmitter<boolean>();

  toggle() {
    this.onToggleTriggered.emit(true);
  }
  remove() {
    this.onRemoveTriggered.emit(true);
  }
}複製程式碼

建立好TodoItem後,我們再來看TodoList,還是從模板看一下

<section class="main" *ngIf="todos?.length > 0">
  <input class="toggle-all" type="checkbox">
  <ul class="todo-list">
    <li *ngFor="let todo of todos" [class.completed]="todo.completed">
      <app-todo-item
        [isChecked]="todo.completed"
        (onToggleTriggered)="onToggleTriggered(todo)"
        (onRemoveTriggered)="onRemoveTriggered(todo)"
        [todoDesc]="todo.desc">
      </app-todo-item>
    </li>
  </ul>
</section>複製程式碼

TodoList需要一個輸入型引數todos,由父元件(TodoComponent)指定,TodoList本身不需要知道這個陣列是怎麼來的,它和TodoItem只是負責顯示而已。當然我們由於在TodoList裡面還有TodoITem子元件,而且TodoList本身不會處理這個輸出型引數,所以我們需要把子元件的輸出型引數再傳遞給TodoComponent進行處理。

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Todo } from '../todo.model';

@Component({
  selector: 'app-todo-list',
  templateUrl: './todo-list.component.html',
  styleUrls: ['./todo-list.component.css']
})
export class TodoListComponent {
  _todos: Todo[] = [];
  @Input()
  set todos(todos:Todo[]){
    this._todos = [...todos];
  }
  get todos() {
    return this._todos;
  }
  @Output() onRemoveTodo = new EventEmitter<Todo>();
  @Output() onToggleTodo = new EventEmitter<Todo>();

  onRemoveTriggered(todo: Todo) {
    this.onRemoveTodo.emit(todo);
  }
  onToggleTriggered(todo: Todo) {
    this.onToggleTodo.emit(todo);
  }
}複製程式碼

上面程式碼中有一個新東東,就是在todos()方法前我們看到有setget兩個訪問修飾符。這個是由於我們如果把todos當成一個成員變數給出的話,在設定後如果父元件的todos陣列改變了,子元件並不知道這個變化,從而不能更新子元件本身的內容。所以我們把todos做成了方法,而且通過get和set修飾成屬性方法,也就是說從模板中引用的話可以寫成{{todos}}。通過標記set todos()@Input我們可以監視父元件的資料變化。

現在回過頭來看一下todo.component.html,我們看到(onRemoveTodo)="removeTodo($event)",這句是為了處理子元件(TodoList)的輸出型引數(onRemoveTodo),而$event其實就是這個事件反射器攜帶的引數(這裡是todo:Todo)。我們通過這種機制完成元件間的資料交換。

//todo.component.html
<section class="todoapp">
  <app-todo-header
    placeholder="What do you want"
    (textChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
  </app-todo-header>
  <app-todo-list
    [todos]="todos"
    (onRemoveTodo)="removeTodo($event)"
    (onToggleTodo)="toggleTodo($event)"
    >
  </app-todo-list>
  <app-todo-footer [itemCount]="todos?.length"></app-todo-footer>
</section>複製程式碼

講到這裡大家可能要問是不是過度設計了,這麼少的功能用得著這麼設計嗎?是的,本案例屬於過度設計,但我們的目的是展示出更多的Angular實戰方法和特性。

填坑,完成漏掉的功能

現在我們還差幾個功能:全部反轉狀態(ToggleAll),清除全部已完成任務(Clear Completed)和狀態篩選器。我們的設計方針是邏輯功能放在TodoComponent中,而其他子元件只負責表現。這樣的話,我們先來看看邏輯上應該怎麼完成。

用路由引數傳遞資料

首先看一下過濾器,在Footer中我們有三個過濾器:All,Active和Completed,點選任何一個過濾器,我們只想顯示過濾後的資料。

Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
image_1b17mtibdkjn105l1ojl1dgr9il9.png-6.5kB

這個功能其實有幾種可以實現的方式,第一種我們可以按照之前講過的元件間傳遞資料的方式設定一個@Output的事件發射器來實現。但本節中我們採用另一種方式,通過路由傳遞引數來實現。Angular2可以給路由新增引數,最簡單的一種方式是比如/todo是我們的TodoComponent處理的路徑,如果希望攜帶一個filter引數的話,可以在路由定義中寫成

  {
    path: 'todo/:filter',
    component: TodoComponent
  }複製程式碼

這個:filter是一個參數列達式,也就是說例如todo/ACTIVE就意味著引數filter='ACTIVE'。看上去有點像子路由,但這裡我們使用一個元件去處理不同路徑的,所以todo/後面的資料就被當作路由引數來對待了。這樣的話就比較簡單了,我們在todo-footer.component.html中把幾個過濾器指向的路徑寫一下,注意這裡和需要使用Angular2特有的路由連結指令(routerLink)

  <ul class="filters">
    <li><a routerLink="/todo/ALL">All</a></li>
    <li><a routerLink="/todo/ACTIVE">Active</a></li>
    <li><a routerLink="/todo/COMPLETED">Completed</a></li>
  </ul>複製程式碼

當然我們還需要在todo.routes.ts中增加路由引數到路由陣列中

  {
    path: 'todo/:filter',
    component: TodoComponent
  }複製程式碼

根路由定義也需要改寫一下,因為原來todo不帶引數時,我們直接重定向到todo模組即可,但現在有引數的話應該重定向到預設引數是“ALL”的路徑;

  {
    path: 'todo',
    redirectTo: 'todo/ALL'
  }複製程式碼

現在開啟todo.component.ts看看怎麼接收這個引數:

  1. 引入路由物件 import { Router, ActivatedRoute, Params } from '@angular/router';
  2. 在構造中注入ActivatedRouteRouter
    constructor(
     @Inject('todoService') private service,
     private route: ActivatedRoute,
     private router: Router) {}複製程式碼
    然後在ngOnInit()中新增下面的程式碼,一般的邏輯程式碼如果需要在ngOnInit()中呼叫。
    ngOnInit() {
     this.route.params.forEach((params: Params) => {
       let filter = params['filter'];
       this.filterTodos(filter);
     });
    }複製程式碼
    this.route.params返回的是一個Observable,裡面包含著所以傳遞的引數,當然我們這個例子很簡單隻有一個,就是剛才定義的filter。當然我們需要在元件內新增對各種filter處理的方法:呼叫service中的處理方法後對todos陣列進行操作。元件中原有的getTodos方法已經沒有用了,刪掉吧。
    filterTodos(filter: string): void{
     this.service
       .filterTodos(filter)
       .then(todos => this.todos = [...todos]);
    }複製程式碼
    最後我們看看在todo.service.ts中我們如何實現這個方法
  // GET /todos?completed=true/false
  filterTodos(filter: string): Promise<Todo[]> {
    switch(filter){
      case 'ACTIVE': return this.http
                        .get(`${this.api_url}?completed=false`)
                        .toPromise()
                        .then(res => res.json() as Todo[])
                        .catch(this.handleError);
      case 'COMPLETED': return this.http
                          .get(`${this.api_url}?completed=true`)
                          .toPromise()
                          .then(res => res.json() as Todo[])
                          .catch(this.handleError);
      default:
        return this.getTodos();
    }
  }複製程式碼

至此大功告成,我們來看看效果吧。現在輸入http://localhost:4200/todo進入後觀察瀏覽器位址列,看到了吧,路徑自動被修改成了http://localhost:4200/todo/ALL,我們的在跟路由中定義的重定向起作用了!

Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
image_1b17o06nv10ob13d6pb1f5613pnm.png-137.8kB

現在,試著點選其中某個todo更改其完成狀態,然後點選Active,我們看到不光路徑變了,資料也按照我們期待的方式更新了。
Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
image_1b17o6qjlb31grg1o7edjm1q4l13.png-128kB

批量修改和批量刪除

ToggleAll和ClearCompleted的功能其實是一個批量修改和批量刪除的過程。
todo-footer.component.html中增加Clear Completed按鈕的事件處理

<button class="clear-completed" (click)="onClick()">Clear completed</button>複製程式碼

Clear Completed在Footer中,所以我們需要給Footer元件增加一個輸出型引數onClearonClick()事件處理方法

//todo-footer.component.ts
...
  @Output() onClear = new EventEmitter<boolean>();
  onClick(){
    this.onClear.emit(true);
  }
...複製程式碼

類似的,ToggleAll位於TodoList中,所以在todo-list.component.html中為其增加點選事件

<input class="toggle-all" type="checkbox" (click)="onToggleAllTriggered()">複製程式碼

todo-list.component.ts中增加一個輸出型引數onToggleAll和onToggleAllTriggered的方法

  @Output() onToggleAll = new EventEmitter<boolean>();
  onToggleAllTriggered() {
    this.onToggleAll.emit(true);
  }複製程式碼

在父元件模板中新增子元件中剛剛宣告的新屬性,在todo.component.html中為app-todo-list和app-todo-footer新增屬性:

  ...
  <app-todo-list
    ...
    (onToggleAll)="toggleAll()"
    >
  </app-todo-list>
  <app-todo-footer
    ...
    (onClear)="clearCompleted()">
  </app-todo-footer>
  ...複製程式碼

最後在父元件(todo.component.ts)中新增對應的處理方法。最直覺的做法是迴圈陣列,執行已有的toggleTodo(todo: Todo)removeTodo(todo: Todo)。我們更改一下todo.component.ts,增加下面兩個方法:

  toggleAll(){
    this.todos.forEach(todo => this.toggleTodo(todo));
  }

  clearCompleted(){
    const todos = this.todos.filter(todo=> todo.completed===true);
    todos.forEach(todo => this.removeTodo(todo));
  }複製程式碼

先儲存一下,點選一下輸入框左邊的下箭頭圖示或者右下角的“Clear Completed”,看看效果

Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
image_1b1c8if181tld15hlj531aasi8a9.png-140kB

大功告成!慢著,等一下,哪裡好像不太對。讓我們回過頭再看看toggleAll方法和clearCompleted方法。目前的實現方式有個明顯問題,那就是現在的處理方式又變成同步的了(this.todos.forEach()是個同步方法),如果我們的處理邏輯比較複雜的話,現在的實現方式會導致UI沒有響應。但是如果不這麼做的話,對於一系列的非同步操作我們怎麼處理呢?Promise.all(iterable)就是應對這種情況的,它適合把一系列的Promise一起處理,直到所有的Promise都處理完(或者是異常時reject),之後也返回一個Promise,裡面是所有的返回值。

let p1 = Promise.resolve(3);
let p2 = 1337;
let p3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, "foo");
}); 

Promise.all([p1, p2, p3]).then(values => { 
  console.log(values); // [3, 1337, "foo"] 
});複製程式碼

但是還有個問題,我們目前的toggleTodo(todo: Todo)removeTodo(todo: Todo)並不返回Promise,所以也需要小改造一下:

//todo.component.ts片段
toggleTodo(todo: Todo): Promise<void> {
    const i = this.todos.indexOf(todo);
    return this.service
      .toggleTodo(todo)
      .then(t => {
        this.todos = [
          ...this.todos.slice(0,i),
          t,
          ...this.todos.slice(i+1)
          ];
        return null;
      });
  }
  removeTodo(todo: Todo): Promise<void>  {
    const i = this.todos.indexOf(todo);
    return this.service
      .deleteTodoById(todo.id)
      .then(()=> {
        this.todos = [
          ...this.todos.slice(0,i),
          ...this.todos.slice(i+1)
        ];
        return null;
      });
  }
  toggleAll(){
    Promise.all(this.todos.map(todo => this.toggleTodo(todo)));
  }
  clearCompleted(){
    const completed_todos = this.todos.filter(todo => todo.completed === true);
    const active_todos = this.todos.filter(todo => todo.completed === false);
    Promise.all(completed_todos.map(todo => this.service.deleteTodoById(todo.id)))
      .then(() => this.todos = [...active_todos]);
  }複製程式碼

現在再去試試效果,應該一切功能正常。當然這個版本其實還是有問題的,本質上還是在迴圈呼叫toggleTodoremoveTodo,這樣做會導致多次進行HTTP連線,所以最佳策略應該是請伺服器後端同學增加一個批處理的API給我們。但是伺服器端的程式設計不是本教程的範疇,這裡就不展開了,大家只需記住如果在生產環境中切記要減少HTTP請求的次數和縮減傳送資料包的大小。說到減小HTTP互動資料的大小的話,我們的todo.service.ts中可以對toggleTodo方法做點改造。原來的put方法是將整個todo資料上傳,但其實我們只改動了todo.completed屬性。如果你的web api是符合REST標準的話,我們可以用Http的PATCH方法而不是PUT方法,PATCH方法會只上傳變化的資料。

  // It was PUT /todos/:id before
  // But we will use PATCH /todos/:id instead
  // Because we don't want to waste the bytes those don't change
  toggleTodo(todo: Todo): Promise<Todo> {
    const url = `${this.api_url}/${todo.id}`;
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    return this.http
            .patch(url, JSON.stringify({completed: !todo.completed}), {headers: this.headers})
            .toPromise()
            .then(() => updatedTodo)
            .catch(this.handleError);
  }複製程式碼

最後其實Todo的所有子元件其實都沒有用到ngInit,所以不必實現NgInit介面,可以去掉ngInit方法和相關的介面引用。

本節程式碼: github.com/wpcfan/awes…

紙書出版了,比網上內容豐富充實了,歡迎大家訂購!
京東連結:item.m.jd.com/product/120…

Angular 從 0 到 1 (四)史上最簡單的 Angular 教程
Angular從零到一

第一節:Angular 2.0 從0到1 (一)
第二節:Angular 2.0 從0到1 (二)
第三節:Angular 2.0 從0到1 (三)
第四節:Angular 2.0 從0到1 (四)
第五節:Angular 2.0 從0到1 (五)
第六節:Angular 2.0 從0到1 (六)
第七節:Angular 2.0 從0到1 (七)
第八節:Angular 2.0 從0到1 (八)

相關文章