Angularとは

Angularとは、JavaScriptのとっても有名なWebアプリケーションフレームワークです。メインで作っているのはGoogleさんですが、色んなモジュールがあり、コミュニティや個人レベルでの開発者の方もたくさん関わって日々便利になるように努力してくれています🙏

TODO

Todoアプリを作ってみよう

Todo Component を作る

ではさっそく、Angular2のコードを書いてみよう。Angular2はコンポーネントベースになりましたので、まずはngコマンドで<Todo/>コンポーネントを作ります。以下のコマンドで作れます。

ng g component todo

するとtodo/というディレクトリができて、その下がこんな感じの構造になったと思います。

.
├── todo.component.css
├── todo.component.html
├── # todo.component.spec.ts
└── todo.component.ts

またapp.module.tsでは、TodoComponentというものを読み込むように書き換わっていると思うので確認します。

import { AppComponent } from './app.component';
import { TodoComponent } from './todo/todo.component';

@NgModule({
  declarations: [AppComponent, TodoComponent],
  imports: [BrowserModule, FormsModule, HttpModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

@NgModuleは、ngコマンドで生成するものみたいなものをひとまとめにする為のものです。declarationsは、ComponentやDirective、Pipeを。importsには、別のModuleを。providersには、Serviceを。bootstrapはエントリーポイント(ベース)となるComponentを指定します。

Todo Component を編集する

編集するファイルは、importfrom先を見たら分かるように、./todo/todo.component.tsを編集すれば良さそうです。

簡単なTodoのタスクアイテムのインターフェースを定義して、itemsプロパティに入れるようにしたいと思います。

interface Task {
  content: string,
  done: boolean
}

@Component({
  selector: 'todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css']
})
export class TodoComponent {
  private todoItems: Task[]
}

@Componentというデコレーターが出てきましたが、上記の設定はざっとこんな事を設定しています。<todo></todo>要素を使えるようにして、それはtodo.component.htmlの中身を処理した後のHTML置き換えられ、その範囲だけに有効なtodo.component.cssを適用(<head/>に注入)します。

とりあえず初期データを入れて、ブラウザで表示させてみましょう。TodoComponentクラスを編集します。

export class TodoComponent {
  private todoItems: Task[]

  constructor() {
    this.todoItems = [
      {content: 'foo', done: false},
      {content: 'bar', done: false}
    ];
  }
}

itemsfoobarなタスクを入れてみました。次にtodo.component.htmlを編集してこんな感じにします。

<section>
  <h1>Todo</h1>
  <ul>
    <li *ngFor="let task of todoItems">
      <div>{{task.content}}</div>
    </li>
  </ul>
</section>

ngForを使ってitemsをループ処理しています。

そして、エントリーポイントとなるapp.component.htmlをこのように編集します。

<todo></todo>

今の状態を、ブラウザで確認してこの画像のようになっていればココまで大丈夫です。

create todo app | first view

Task の状態を Toggle できるようにする

上記セクションで記述したtodo.componentを編集して、チェックボックスをクリックするとタスクを完了して、再度クリックしたらタスク完了をキャンセルできる感じにしてみたいと思います。

todo.component.htmlをこのようにします。

<section>
  <h1>Todo</h1>
  <ul>
    <li *ngFor="let task of todoItems">
      <div style=display:inline>
        <div *ngIf="!task.done">{{task.content}}</div>
        <del *ngIf="task.done">{{task.content}}</del>
      </div>
      <input type="checkbox" (change)="onChange(task)">
    </li>
  </ul>
</section>

ngIfは、値がtrueだと表示されてfalseだと要素自体がなくなるDirectiveです。つまり、item.donetrueになった時、<del></del>要素で囲むようにします。

item.doneをToggleする方法ですが、<input/>changeイベントを登録して、チェックが変わるたびにそのitemdoneプロパティのboolを反対にするような方法でやりたいと思います。

多分Angular2を知らない人は、(change)という見慣れない属性値を見て、さっそく困惑していると思いますが僕もです。

Outputという機能で、この機能を使って外部のコンポーネントに対してこのコンポーネントのデータを渡したりすることができます。これは@Output() <eventName> = new EventEmitter()という感じで定義することができます。this.<eventName>.emit()した時、Output属性の値に指定したメソッドが呼び出されます。

ただ、じゃあ上記なら@Output() changeとすればいいのだろうと思うかもしれませんが、一般的なDOMイベント(changeclick)はの場合は、理解が不十分なので間違ってるかもしれませんが、いちいち@Output宣言はいりません。その要素でDOMイベントが起きると、@Outputされたイベントの如く(emitみたいな)トリガーされて、Output値のメソッド(上記ならonChange)を実行させることができます。

onChangeメソッドを実装します。このメソッドの内容はただ渡されたitemdoneプロパティのBool値を反転させるだけです。TodoComponentはこうなりました。

export class TodoComponent {
  private todoItems: Task[]

  constructor() {
    this.todoItems = [...];
  }

  onChange(task: Task) {
    task.done = !task.done;
  }
}

これで、input[type=checkbox]をクリックしたら、線が引かれたり消えたりするようになったと思います。

タスクを追加するためのフォームコンポーネントを作る

新しくtodo-formコンポーネントを作ります。

ng g component todo-form

そして、todo-form/todo-form.component.htmltodo-form/todo-form.component.tsをそれぞれこんな感じに編集します。

<form (submit)="onSubmit(todoForm.value)" [formGroup]="todoForm" novalidate>
  <input type="text" formControlName="content">
  <input type="submit" [disabled]="todoForm.invalid">
</form>
import {Component, OnInit} from '@angular/core';
import {FormGroup, FormControl, Validators} from '@angular/forms';

@Component({
  selector: 'todo-form',
  templateUrl: './todo-form.component.html',
  styleUrls: ['./todo-form.component.css']
})
export class TodoFormComponent implements OnInit {
  private todoForm: FormGroup

  ngOnInit() {
    this.todoForm = new FormGroup({
      content: new FormControl('', Validators.required)
    });
  }

  onSubmit(formValue: {content: string}) {
    console.log(formValue);
    this.todoForm.reset();
  }
}

[formGroup]にはFormGroupのインスタンスを指定します。この[...]という書き方はAttribute bindingと言って、設定した値は、実際にはその値を評価した結果に置き換わって設定されます。

[formGroup]に属する入力フォームには、HTMLでいうname属性のようにformControlNameという属性を設定します。これで名前を設定すると、その入力フォームに関する設定をFormControlクラスで設定できるようになります。

new FormGroup(controls)時に、constrolsを渡す必要がありますが、この時のkeyになる名前が先程出てきたformControlNameで指定した名前です。その値には、FormControlのインスタンスを渡します。

FormControlの第一引数は、初期値が2つ目にはValidatorを設定します。今回はnew FormControl('', Validators.required)とValidatorは1つしか設定してませんが、複数設定したい場合は配列にして羅列することもできます。

実はAngular2にはFormの作成方法にはFormsModuleReactiveFormsModuleの2種類があって上記の説明は後者のものになります。Form部分のテストを書く時、前者ではブラウザで検証しなければいけないのに対して、後者はクラスをインスタンスして簡単に値を操作できるのでテストを書きやすくなるようです。

FormsModuleはAngular1のようなngModelを使用して値を管理するタイプ。ここでは詳しく調べませんがここのサンプルコードを見るとよくわかると思います。

今現在app.component.tsで読み込まれているのはFormsModuleなのでこれをReactiveFormsModuleに変更します。ReactiveFormsModuleFormsModuleと同じ@angular/formsからimportできます。

...
import {ReactiveFormsModule} from '@angular/forms';
...
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    ReactiveFormsModule,
    HttpModule
  ],
  providers: []
  ...
})
...

とりあえずここまでで、何かを入力するとsubmitが押せるようになって、押すとconsoleにログが出るとこまでできました。

共通で使えるService(Model)を作る

Serviceファイルをngコマンドで作ります。以下のコマンドを実行すると、todo.service.tsファイルが作られます。

ng g service todo

出来たファイルの中身はこんな感じになっているはずです。

import {Injectable} from '@angular/core';

@Injectable()
export class TodoService {
  constructor() { }
}

@Injectableというデコレーターを付けたものを@NgModuleprovidersへ渡すと、そのインスタンスを様々なClassのconstructorで持ってくることができます。つまり、app.module.tsを以下のように編集します。

...
import {TodoService} from './todo.service';
...
@NgModule({
  declarations: [...],
  imports: [...],
  providers: [TodoService],
  bootstrap: [...]
})
export class AppModule {}

これでComponentのconstructorでもこんな感じでこのServiceのインスタンスを使えるようになりました。

@import {TodoService} from '../todo.service';
@Component({...})
class Component {
  constructor(todoService: TodoService) {}
}

todo.componentに書いたTodoに関する情報をtodo.serviceに移して、いくらか操作するためのメソッドなどを実装していきます。

TodoService

import {Injectable} from '@angular/core';

// Keyは適当に変えてください😇
export const LOCALSTRAGE_KEY = 'javascript.nju33.work/start-angular2';

export class Task {
  constructor(public content: string, public done: boolean = false) {}
}

@Injectable()
export class TodoService {
  private tasks: Task[]

  constructor() {
    const item = localStorage.getItem(LOCALSTRAGE_KEY);
    if (item) {
      this.tasks = JSON.parse(item);
    } else {
      this.tasks = [];
    }
  }

  init() {
    this.tasks = this.tasks.filter(task => !task.done);
    this.save();
  }

  add(content: string) {
    this.tasks.unshift(new Task(content));
  }

  get(): Task[] {
    return this.tasks;
  }

  remove(index: number) {
    this.tasks.splice(index, 1);
  }

  save(): void {
    localStorage.setItem(LOCALSTRAGE_KEY, JSON.stringify(this.tasks));
  }
}

上記の内容はこんな感じです。

  • constructorでローカルストレージからデータを取得、無かったらただの[]を返す
  • initdoneなTaskを削除
  • addはTaskの追加
  • getはすべてのTaskを返す
  • removeindexの位置のTaskを削除
  • saveはlocalStorageへデータを保存

では、このサービスを今まで作ってきたComponentに組み込みます。まずは、todo-form.component.tsです。

TodoFormComponent

import {Component, OnInit, Output, EventEmitter} from '@angular/core';
import {FormGroup, FormControl, Validators} from '@angular/forms';
import {TodoService} from '../todo.service';

@Component({
  selector: 'todo-form',
  templateUrl: './todo-form.component.html',
  styleUrls: ['./todo-form.component.css']
})
export class TodoFormComponent implements OnInit {
  @Output() todoUpdate: EventEmitter<any> = new EventEmitter();
  private todoForm: FormGroup;

  constructor(private todoService: TodoService) {}

  ngOnInit() {
    this.todoForm = new FormGroup({
      content: new FormControl('', Validators.required)
    });
  }

  onSubmit(formValue: {content: string}) {
    this.todoService.add(formValue.content);
    this.todoForm.reset();
    this.todoUpdate.emit();
  }
}

追加したのは、todoUpdateイベントを作ったことです。onSubmitでデータが追加されるとこのイベントがトリガーされて、そのタイミングで親(AppComponent)でも何か処理できるようになりました。

TodoComponent

次にtodo/todo.component.tsを編集します。

import {
  Component,
  Input,
  Output,
  EventEmitter,
  OnInit,
  DoCheck,
  KeyValueDiffers
} from '@angular/core';
import {TodoService, Task} from '../todo.service';

@Component({
  selector: 'todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css']
})
export class TodoComponent implements OnInit, DoCheck {
  @Input() todoItems: Task[];
  @Output() completeChange: EventEmitter<Task> = new EventEmitter();
  private todoDiffers: any[];

  constructor(private differs: KeyValueDiffers) {}

  ngOnInit() {
    this.todoDiffers = this.todoItems.map(item => {
      return this.differs.find(item).create(null);
    });
  }

  ngDoCheck() {
    const changesArr = this.todoItems.filter((item, i) => {
      if (typeof this.todoDiffers[i] === 'undefined') {
        this.todoDiffers.unshift(this.differs.find(item).create(null));
        return item;
      }
      const changes = this.todoDiffers[i].diff(item);
      return changes;
    });

    if (changesArr.length > 0) {
      this.completeChange.emit();
    }
  }

  onChange(task: Task) {
    task.done = !task.done;
  }
}

こっちは、まずtaskItemsをInputで取得するように変更しました。そして、ライフサイクルの1つで自動で実行されるngDoCheckメソッドを定義して、Taskオブジェクトに何らかのデータ変更が起きた場合、completeChangeイベントが起きるように修正しました。また新しく追加されたデータの場合はdifferを作成して、変更があったと振る舞うようにしてます。

ちなみにngOnChangesという変更を察知して実行されるメソッドもありますが、こっちはstringnumberbooleanみたいなプリミティブな値しか察知できないみたいです。

@Inputでデータを取得する際の注意点ですが、必ずライフサイクルの1つであるngOnInitメソッドで行う必要があります。constructorで使おうとしてもundefinedとなってしまいます。

オブジェクトデータのチェックにはKeyValueDiffersを使います。const differ = this.differs.find(initData).create(null)という感じでinitData(最初のデータ)を渡した後、differ.diff(nextData)とすると、変更があった場合はそのオブジェクトを返して、変更がない場合はnullを返してくれます。

AppComponent周辺の編集

以上を踏まえてapp.component.htmlを編集します。

<todo [todoItems]="todoItems" (completeChange)="onCompleteChange($event)"></todo>
<todo-form (todoUpdate)="onTodoUpdate()"></todo-form>

やっていることはこんな感じです。

  • TodoComponentでcompleteChangeがトリガーされるとAppComponentのonCompleteChangeメソッドが実行
  • TodoFormComponentでtodoUpdateがトリガーされるとAppComponentのonTodoUpdateメソッドが実行
  • TodoComponentにAppComponentのtodoItemsを渡す

上記であげたようなメソッドを実装します。

import {Component, ApplicationRef} from '@angular/core';
import {TodoService} from './todo.service';
import {Task} from './todo.service';

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

  constructor(private todoService: TodoService) {
    this.todoService.init();
    this.todoItems = this.todoService.get();
  }

  handleTodoUpdate() {
    this.todoItems = this.todoService.get();
    this.todoService.save();
  }

  onTodoUpdate() {
    this.handleTodoUpdate();
  }

  onCompleteChange() {
    this.handleTodoUpdate();
  }
}

onTodoUpdateonCompleteChangeもただ、TodoServiceのsave()コマンドでローカルストレージへ保存しているだけです。constructorでは最初ローカルストレージからデータを読み込んだ時に、initメソッドでtask.doneが完了しているものを削除しています。

ローカルストレージへデータを格納することでデータを保持できるようになりました。ここまでで、リロードした時に前回の状態が復活すると思いますが、task.contentには線が引かれているのにcheckboxにはチェックが入ってないような状態になってしまいます。これを直すには、input[type=checkbox][checked]を追加します。

<input type="checkbox" (change)="onChange(task)" [checked]="task.done">

色々改善

進歩を表示

何個中何個完了してるのか表示してみたいと思います。大げさかもしれませんが、まだPipeを使っていなかったので使って実装してみたいと思います。

ng g pipe complete-length
# installing pipe
#   create src/app/complete-length.pipe.spec.ts
#   create src/app/complete-length.pipe.ts
#   update src/app/app.module.ts

complete-length.pipe.tsが作られました。この中のtransformメソッドに処理内容を書いていきます。

value | pipe:arg1:arg2みたい感じでPipeを書くとtransformtransform(value, arg1, arg2)という感じで値が渡ってきます。

というわけで内容はこんな感じになりました。

import {Pipe, PipeTransform} from '@angular/core';
import {Task} from './todo.service';

@Pipe({
  name: 'completeLength'
})
export class CompleteLengthPipe implements PipeTransform {
  transform(todoItems: Task[]): number {
    console.log(todoItems)
    return todoItems.filter(task => task.done).length;
  }
}

todo.component.htmlの方にも変更を加えます。

<section>
  <h1>Todo <small>{{todoItems | completeLength}}/{{todoItems.length}}</small></h1>
  <ul>...</ul>
</section>

できました!

ただこれ、最初の1回しか動きません😱

ググるとどうやら配列の場合は参照自体を変えないと変更があったとみなされないようです。ただ、@Pipeの中でpure:falseにしてあげると、配列の中身が変っただけでも感知されるみたいなので早速指定します。

@Pipe({
  name: 'completeLength'
  pure: false
})

とりあえず完成?

ブログを作る

今度は@angular/router@angular/http辺りを理解するためにブログを簡単に作ってみたいと思います。

プロジェクト作成

これは前回と同様ng initで作っちゃってください。

mkdir project-dir && cd $_; ng init

ルーティング設計

CRUDな感じで作ります。つまり以下のような感じです。

  • GET /posts / 投稿一覧ページ
  • GET /posts/:id / 詳細ページ
  • GET /posts/new / 新規ポストページ
  • POST /posts/new / 新規ポストリクエスト
  • GET /posts/:id/edit / 編集ページ
  • PUT /posts/:id / 編集リクエスト
  • Delete /posts/:id / ページ削除リクエスト

ここではAPIでJson-serverをこんな感じの初期データを入れたJSONファイル、db.jsonを使います。

{
  "posts": [
    {
      "id": 1,
      "title": "title",
      "contents": "contents"
    }
  ]
}

こんな感じで起動しといてください。

#  ↓ インストールがまだなら
# npm i -g json-server
json-server db.json

ルーティング設定

では、app.module.tsを編集していきます。ルーティング設定には最低でもRouterModuleRoutesが入ります。RouterModuleはルーティングに必要なComponentやらDirectiveをまとめて使えるようにするもので、Routesには配列で「このPathならこのComponentを使う」という設定をガンガン入れていくものです。

import {RouterModule, Routes} from '@angular/router';

とりあえずこんな感じで一覧ページだけ設定して、importsで読み込みます。とりあえずです。

const blogRoutes: Routes = [
  {
    path: 'posts',
    component: PostListComponent
  },
  {
    path: '',
    redirectTo: '/posts',
    pathMatch: 'full'
  },
  {
    path: '**',
    component: NotFoundComponent
  }
]

// ...

@NgModule({
  // ...
  imports: {
    // ...
    RouterModule.forRoot(blogRoutes)
  }
  // ...
})

上記の設定はこんな感じです。

  • redirectToは、適用されたときにそのURLへリダイレクトさせます。
  • componentはマッチした時にどのコンポーネントを使うかを指定します。redirectToかどちらかは必要だという認識でいいと思います。
  • pathMatch: fullpathが完全にマッチした時に適用されます。pathMatch: prefixという指定の仕方もあってこちらは前方一致になります。
  • **はどこにもマッチしなかった時に適用されます。

PostListComponentとNotFoundComponentを作る

これを作らなきゃng serveすら出来ないのでさくっと作りましょう。

ng g component post-list
ng g component not-found

じゃあここからはng serveして画面を見ながら作っていきましょう。

BlogServiceを作る

ここではAPIと通信を行ったりする表示をまとめて書いてしまいます。とりあえずやることは一覧を取得することですね。まずng g service blogでBlogServiceを作りましょう。そして、blog.service.tsを編集します。

と、その前にPostの方を定義してしまいます。post.tsを作ってこんな感じにします。

export default class Post {
  constructor(
    private id: number,
    private title: string,
    private contents: string
  )
}

そして、BlogServiceです。Httpというプロバイダーはngコマンドから作ってる人は自動で読み込まれてるはずなので、それをDIして使います。

とりあえず、起動されているJson-serverからPost一覧を取得したいと思います。Angular2は結構RxJSに依存している感じで、http.getの戻り値はrxjs.Observable.Observableを返す必要があります。なので、それに関するものをimportする必要が出てきます。ただ、ng initで作られたプロジェクトなら既にインストールされているので、必要なのはimportだけです。

というわけで、こんな感じに書けば良いです。

import {Injectable} from '@angular/core';
import {Http, Response} from '@angular/http';
import {Observable} from 'rxjs';
import 'rxjs/add/operator/map';
import Post from './post';

@Injectable()
export class BlogService {
  constructor(private http: Http) {}

  getPosts(): Observable<Post[]> {
    return this.http
      .get('http://localhost:3000/posts')
      .map((res: Response): Post[] => return res.json().data as Post[]);
  }
}

resには、status:200やら_body: <何かString>やらが入っているObjectで、res.jsonとすることで_bodyの内容をjson化(多分😅)してくれます。

さて、じゃあ一覧取得メソッドは書けたので、PostListComponentで取得して表示させてみたいと思います。がその前に、app.module.tsprovidersにBlogServiceを登録する必要がありましたね。

providers: [BlogService]

post-list/post-list.component.tsを編集します。さっそくBlogServiceをDIして、一覧を取得します。

import { Component, OnInit } from '@angular/core';
import {BlogService} from '../blog.service';
import {Response} from '@angular/http';
import Post from '../post';

@Component({
  selector: 'app-post-list',
  templateUrl: './post-list.component.html',
  styleUrls: ['./post-list.component.css']
})
export class PostListComponent implements OnInit {
  private posts: Post[];

  constructor(private blogService: BlogService) {}

  ngOnInit() {
    this.blogService.getPosts()
      .subscribe(
        (posts: Post[]) => this.posts = posts,
        (err: Response) => console.log(err)
      );
  }
}

subscribeblogService.getPostsmapした後のデータを受け取れます(Post[]ですね)。もしhttp.getで何かエラーが起きた時は、rxjs.Observable.throwResponseがそのまま渡ってくるので、subscribeの第2引数にエラー処理も書いておきましょう。

後は、PostListTemplateを編集して表示させるだけですね。一応データが無ければ「ないです」と表示するようにしてます。あと*は忘れずに!

<div *ngIf="posts.length === 0">Postデータがありません</div>

<ul *ngIf="posts.length > 0">
  <li *ngFor="let post of posts">
    <section>
      <h1>{{post.title}}</h1>
      <div>{{post.contents}}</div>
    </section>
  </li>
</ul>

表示するとこまではできました!