Angular2で簡易ブログを作ってみる

このブログを運用してたクラウドサービスが吸収合併?かなんかした影響ですべての記事がクラウド上のMySQLに永眠することになってしまい、 あちらこちらでキャッシュをひろっては、作りかけだった静的サイトジェネレータでホスト先を移行したりとせわしなくしていた今日このごろです。 そんなわけで「Angular2やるぞ!」と1ヶ月ほど前に書いたAngular2のコードも確実に忘れそうだったのと明日の勉強会に備え、備忘録も兼ねて記事にしとこうと思います。

簡易ブログを作ってみる

公式のチュートリアル、結構分かりやすいんですがDecorators構文だとかいわゆるESnextな記述がでてくるんで、単純に写経してもすぐに忘れる自信があったので若干公式サンプルをいじって、ブログアプリ的なものを作ってみました。 Angular2のバージョンは(これを書いた当時最新の)beta.13です。

angular2_blog

公式のデモだとデータがモックだったりするんですが、ここではHttpコンポーネントを使って記事データを読み込んだり、データの内容がマークアップなのでエスケープせず展開したりと微妙にプラスαな記述になってます。が、基本的にやってることは公式のデモとそんなに変わりません。

次のような構成になってます。

root
  │ index.html
  │ style.css
  │ package.json
  │ tsconfig.json
  │ typings.json
  │ 
  ├─app
  │   // アプリの起動
  │   main.ts
  │ 
  │   // 画面系
  │   app.component.ts
  │   blog.component.ts
  │   article.component.ts
  │ 
  │   // 画面パーツ
  │   articlelist.component.ts
  │ 
  │   // データ取得サービス
  │   article.service.ts
  │ 
  │   // データ管理クラス
  │   article.ts
  │   article.info.ts
  │ 
  └─data
      └─articles
          // 記事リストを管理
          index.json

          // 記事
          introduction.html
          architecture.html

ソースはGitHubにありますのでpackage.json等の設定ファイルは以下から見れます。npm install と npm start でローカルで動かすことができます。

以降、簡単な注釈とともにコードをぺたぺた貼っていきます。

ベースHTML|index.html

ベースとなるHTMLです。SPAなので使用するHTMLはこれ1つです。

<!doctype html>
<html>
  <head>
    <base href="/"/>
    <title>Angular2 BLOG</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="styles.css">

    <!-- 1. Load libraries -->
    <!-- IE required polyfills, in this exact order -->
    <script src="node_modules/es6-shim/es6-shim.min.js"></script>
    <script src="node_modules/systemjs/dist/system-polyfills.js"></script>
    <script src="node_modules/angular2/es6/dev/src/testing/shims_for_IE.js"></script>

    <script src="node_modules/angular2/bundles/angular2-polyfills.js"></script>
    <script src="node_modules/systemjs/dist/system.src.js"></script>
    <script src="node_modules/rxjs/bundles/Rx.js"></script>
    <script src="node_modules/angular2/bundles/angular2.dev.js"></script>

    <!-- router, http を追加 -->
    <script src="node_modules/angular2/bundles/router.dev.js"></script>
    <script src="node_modules/angular2/bundles/http.dev.js"></script>

    <!-- 2. Configure SystemJS -->
    <script>
      System.config({
        packages: {
          app: {
            format: 'register',
            defaultExtension: 'js'
          }
        }
      });
      System.import('app/main').then(null, console.error.bind(console));
    </script>
  </head>
  <body>
    <my-app>Loading...</my-app>
  </body>
</html>

まず、SystemJSによる遅延ローディング(必要なコンポーネントのみを都度読み込む)を使って、mainモジュールを読み込みます。 公式ではSystemJSを使ってますが、動的ローディングが必要なければ、使い慣れたBrowserifyやWebpackで事前に結合してもOKです。ググると参考になりそうなコードが見つかります。

ちなみにSystemJSだけでも開発中だけはビルドなしの動的ローディングを適用し、リリース時には全ファイルまとめてビルドするっといったこともできるそうです。

bodyタグ内に記述してるmy-appタグがAngularJSでいうところのディレクティブみたいなもので、Angular2ではコンポーネントと呼び画面のテンプレートやパーツはコンポーネント単位で管理します。

アプリケーションの実行|main.ts

先ほどSystemJSで読み込んでたmainモジュールです。

import {bootstrap}    from 'angular2/platform/browser';
import {AppComponent} from './app.component';
import {HTTP_PROVIDERS} from 'angular2/http';

bootstrap(AppComponent, [HTTP_PROVIDERS]);

ES.nextなimport文で必要なモジュールを読み込んでいます。 bootstrap関数の第一引数には、このアプリのエントリーポイントとなるコンポーネントを指定します。第二引数ではこのアプリ全体で使用するプロバイダを指定しておくと、呼び出し先の子、孫モジュールで、そのプロバイダの利用宣言(@Componentよるprovidersの定義)を省略できます。

アプリケーションの構成管理|app.component.ts

bootstrapの第一引数に指定されたAppComponentの実装です。

import { Component, OnInit } from 'angular2/core';
import { RouteConfig, ROUTER_DIRECTIVES, ROUTER_PROVIDERS } from 'angular2/router';
import { HomeComponent } from './home.component';
import { BlogComponent } from './blog.component';
import { ArticleComponent } from './article.component';
import { ArticleService } from './article.service';
import { Articlelist } from './articlelist.component';

@Component({
  selector: 'my-app',
  template: `
    <nav class="globalNav">
      <!--
        ここに@RouteConfigで設定したnameを指定する
        routerLinkを書くには directives:[ROUTER_DIRECTIVES] の定義が必要
      -->
      <a [routerLink]="['Home']">HOME</a>
      <a [routerLink]="['Blog']">ARTICLES</a>
    </nav>
    <!-- ここに@RouteConfigで設定したテンプレートが表示される -->
    <div class="main">
      <router-outlet></router-outlet>
    </div>
    <div class="footer">
      <h3>ARTICLES</h3>
      <my-articlelist></my-articlelist>
    </div>
  `,
  styles:[`
  `],
  directives: [
    ROUTER_DIRECTIVES,
    Articlelist
  ],
  providers: [
    ROUTER_PROVIDERS,
    ArticleService
  ]
})

@RouteConfig([
  {
    path: '/',
    name: 'Home',
    component: HomeComponent,
    useAsDefault: true
  },
  {
    path: '/blog',
    name: 'Blog',
    component: BlogComponent,
  },
  {
    path: '/blog/:id',
    name: 'Article',
    component: ArticleComponent,
  },
])

export class AppComponent  implements OnInit {

  ngOnInit() {
  }

}

ここでは主に、画面全体のレイアウトとパーツの埋め込み、ルータ(画面遷移)の定義をしています。

ざっと見ると@から始まるDecorators構文(Javaで言うところのアノテーション)と、class定義の二部構成になってるのが分かります。Angular2では属性設定をDecoratorsに、実装はclassにと分けて定義することで視認性の高いコードを書くことができます。

@Componentデコレータ

@Componentのselector属性では、index.htmlで記述してたmy-appカスタムタグ名を宣言しています。つまりこのapp.componentモジュールがmy-appの実装ということになります。selector属性の値は必ずしもカスタムタグである必要はなく、例えばbodyと記述すればbody要素内にテンプレートが展開されることになります。

directives属性には、このコンポーネントにて利用するカスタムタグを管理するコンポーネントを指定します。具体的には、template属性にて記述されてるrouterLinkやrouter-outletタグを使用するためにROUTER_PROVIDERSコンポーネントを、my-articlelistタグを使用するためにArticlelistコンポーネントをdirectives属性に指定しています。 ちなみにmy-articlelistタグではフッター部分の記事一覧を表示してます。

providersではこのコンポーネント自身や子孫コンポーネントで利用するプロバイダを指定します。利用しようとしてるProviderが自身のコンポーネントで宣言されてなければ親を探しに、親になければそのまた親を探しにいくインジェクトチェーンという仕組みで動作します。 ここではROUTER_PROVIDERSとArticleServiceを指定することで各画面コンポーネント内でもこれらのサービスを利用できるようにしています。

@RouteConfigデコレータ

@RouteConfigではルーティングの設定をしています。この辺はAngularJSのui-routerと似たような感じですね。

Angular2.0.0-RCのRouter周りの変更点

で、ここまで書いておいてあれなのですが、Angular2.0.0-RCになってRouter周りの仕様が結構変わってしまったらしいです ^^: (公式のデモは2016/05/16時点では上記のような古いコードのままのようです)

Qiitaのこちらの記事「Angular 2.0.0-RC の Router 周りの変更点」に分かりやすくまとめられてます。

ホーム画面|home.component.ts

import { Component, OnInit } from 'angular2/core';

@Component({
  template: `
      <h1>{{title}}</h1>
      <img class="logo" src="https://5a2f21e2ab6046465d69ab5ffd597737265b3816-www.googledrive.com/host/0B0b09VuqaAG8dGQ5T095ZHRKakU"/>
  `,
  styles:[`
    .logo{
      margin: auto;
      display: block;
      max-width: 300px;
    }
  `],
})
export class HomeComponent implements OnInit {

  title: string;

  ngOnInit() {
    this.title = 'ANGULAR2 BLOG';
  }
}

ホーム画面の実装です。AngularJS同様、{{title}}の記述で単方向バインディングしてます。Angular2では原則、単方向バインディングを使用することになってますが、次のように記述することで双方向バインディングさせることもできます。

<input type="text" [(ngModel)]="title">

内部的には、次のように単方向バインディング+イベントハンドラが生成されてるそうです。

<input [ngModel]="title" (ngModelChange)="titleChanged($event)">

記事一覧ページ|blog.component.ts

グローバルメニューのBLOGリンクをクリックした時に表示される記事一覧画面です。

import { Component, OnInit } from 'angular2/core';
import { Articlelist } from './articlelist.component';

@Component({
  template: `
    <h1>{{ title }}</h1>
    <my-articlelist [mode]="'large'" ></my-articlelist>
  `,
  directives: [Articlelist],
})
export class BlogComponent implements OnInit {

  title: string;

  ngOnInit() {
    this.title = 'ARTICLES';
  }

}

先ほど、フッターにて記事一覧を表示してたmy-articlelistタグを、テンプレート内でmodeパラメータにlargeを指定して利用しています。 つまり同じコンポーネントを使いまわし、パラメータ指定したモードの値により表示形式が変わるような実装になってます。

記事一覧コンポーネント|articlelist.component.ts

そのmy-articlelistタグの実装です。

import {Component, OnInit, Input} from 'angular2/core';
import { ROUTER_DIRECTIVES, Router } from 'angular2/router';
import Articleinfo from './articleinfo';
import { ArticleService } from './article.service';

@Component({
  selector: 'my-articlelist',
  template: `
    <div *ngIf="mode==='large'">
      <div [routerLink]="['Article', { id: article.id }]" class="panel" *ngFor="#article of articles">
        <h2>{{article.title}}</h2>
      </div>
    </div>
    <ul *ngIf="mode!=='large'">
      <li *ngFor="#article of articles">
        <a [routerLink]="['Article', { id: article.id }]">{{article.title}}</a>
      </li>
    </ul>
  `,
  styles:[`
    .panel{
      margin: 1%;
      padding: 40px;
      display: inline-block;
      width: 23%;
      min-height: 200px;
      border: solid 1px #eee;
      box-sizing: border-box;
      background-color: #fafafa;
      vertical-align: top;
      text-align: center;
    }
    .panel:hover{
      border-color: #00aaff;
      color: #00aaff;
      cursor: pointer;
    }
    ul{
      margin: 20px 0;
      padding:0;
    }
    li{
      margin:0;
      padding:0;
      list-style: none;
    }
  `],
  directives: [ROUTER_DIRECTIVES],
})
export class Articlelist implements OnInit {

  @Input() mode: string;

  articles: Articleinfo[];

  constructor(
    private _router: Router,
    private _articleService: ArticleService
  ){
  }

  ngOnInit() {
    this.getArticles();
  }

  getArticles() {
    this._articleService.getArticleInfos().subscribe((articles: Articleinfo[]) => this.articles = articles);
  }

  gotoArticle( id ) {
    let link = ['Article', { id: id }];
    this._router.navigate(link);
  }

}

テンプレート記法

テンプレート内に記述された#や*といった独特な記法についてはそいうものとして、リファレンスみたりググりながら慣れてくしかなさそうですね。

styles属性

このstyles属性はテンプレートに定義されたタグに対して定義するわけですが、このモジュールで定義したテンプレートにのみしか適用されません。 Angular2が独自にShadowDomをエミュレートした実装になっています。 ブラウザに実装されたShadowDomを利用したい場合は次のように記述すればよいらしいです。

@Component({
    ...
    encapsulation: ViewEncapsulation.Native
})

@Input()

ここではじめてclassに実装らしきものがでてきました。

まず目につく @Input() mode: string ですが、@Inputはコンポーネントに属性を定義するAPIになります。つまりこれで親コンポーネントからパラメータを受け取れるようになります。@Input()はangular2/coreモジュールよりimportします。 また、以下のように書くと内部的に扱う変数名を変えることができます。

@Input('displayMode') mode: string;

ちなみに : string というのは TypeScriptによる型定義で、Visual Studio Codeを使うとコード補完が効いて気持よく開発できます。

constructorとngOnInit

初期化時に実行されるであろうと想像しやすい constructorとngOnInitですが、これは constructor、ngOnInit の順で実行されます。 constructor で各種コンポーネントをDIで受け取り、ngOnInitにて初期化処理を行っています。

記事リストの取得

getArticles()メソッドでは、先程から登場してるArticleServiceをDIし、getArticleInfos()メソッドで記事の一覧データを取得しテンプレートから参照可能なメンバー変数 articles にセットしています。

ページの移動

gotoArticle()メソッドでは、指定した ID の記事に遷移させるコードですがここでは使用してませんね ^^; リンク以外で他ページに遷移させたい時に使用します。

記事ページ|article.component.ts

個別の記事ページに相当するコンポーネントです。

import { Component, OnInit } from 'angular2/core';
import { Router, RouteParams } from 'angular2/router';

import Article from './article';
import { ArticleService } from './article.service';

@Component({
  template: `
    <h1 *ngIf="article">{{article.title}}</h1>
    <div *ngIf="article" [innerHtml]="article.content"></div>
  `,
})
export class ArticleComponent implements OnInit {

  private article: Article;

  constructor(
    private _routeParams: RouteParams,
    private _articleService: ArticleService
  ){
  }

  ngOnInit() {
    let id = this._routeParams.get('id');
    this._articleService.getArticle(id).then((article: Article) => this.article = article);
  }
}

エスケープせず記事を差し込む

[innerHtml]="article.content" とすることで記事のマークアップをエスケープせずそのままテンプレートに差し込んでます。

記事データの取得

記事データは、RouteParamsモジュールを利用しurlに含まれる記事IDを取得し、ArticleServiceのgetArticleInfos()メソッドでID指定し記事データを取得しています。

RouteParamsモジュールは、親コンポーネントのapp.component.tsの@ComponentのprovidersにてROUTER_PROVIDERSを指定してるため利用できています

記事情報クラス|ArticleInfo.ts

つづいて記事データの取得処理ですが、まず、記事データを格納するArticleInfoクラスを次のように定義します。記事一覧取得サービスではこれを配列に格納して返します。

export default class ArticleInfo {
  id: string;
  title: string;
}

記事内容クラス|Article.ts

個々の記事の管理は、Articleinfoクラスを継承し、本文を管理するcontentをメンバに追加したArticleクラスを次のように定義します。

import Articleinfo from './articleinfo';

export default class Article extends Articleinfo{
  content: string;
}

記事データ取得サービス|article.service.ts

前述のデータ管理用のクラスを使用し、次のように記事データ取得サービスを定義します。

import {Injectable} from 'angular2/core';
import 'rxjs/Rx';
import { Observable } from 'rxjs/Observable';
import {Http, Response} from 'angular2/http';
import Article from './article';
import Articleinfo from './articleinfo';

@Injectable()
export class ArticleService {

  constructor(private http: Http) {
  }

  getArticle(id: string){
    return  new Promise((resolve, reject) => {

      const url = "data/articles/" + id + ".html";
      const article = new Article;
      article.id = id;

      const extractData = (res: Response) => {
        if (res.status < 200 || res.status >= 300) {
          throw new Error('Bad response status: ' + res.status);
        }
        this.getArticleInfos().subscribe((articleInfos: Articleinfo[]) =>{
          let info = articleInfos.filter(article => article.id === id)[0];
          article.title = info.title;
          article.content = res.text();
          resolve(article);
        });
      }

      const handleNotFound = () => {
        article.title = '404 NOT FOUND';
        article.content = '';
        resolve(article);
      };

      this.http.get(url).subscribe(extractData, handleNotFound);
    });
  }

  getArticleInfos (): Observable<Articleinfo[]> {

    const extractData = (res: Response) => {
      if (res.status < 200 || res.status >= 300) {
        throw new Error('Bad response status: ' + res.status);
      }
      return res.json() || { };
    }

    const handleError = (error: any) => {
      let errMsg = error.message || 'Server error';
      console.error(errMsg); // log to console instead
      return Observable.throw(errMsg);
    }

    return this.http.get('data/articles/index.json')
      .map(extractData)
      .catch(handleError);
  }

}

rxjs、Observable

まず冒頭でrxjs、Observable等のモジュールを読み込んでます。これはPromiseに変わる非同期処理によるデータ取得処理として、Observableオブジェクトやsubscribe()メソッドを利用するために読み込んでいます。

@Injectable()デコレータ

@Injectable()デコレータは多段的なDIをしたい場合に指定が必要になります。このarticle.serviceは他のコンポーネントから呼ばれるサービスとして位置づけられますが、そのサービスの中で他のサービスをDIしたい場合(ここではHttpがそれに該当します)に、@Injectable()が必要になります。

さわってみた感想

生JSで大規模SPAばかりやってると、いろんな意味で「クライアントでJavaが動けばいいのに...」って気持ちになる瞬間があるわけですが、Angular2+TypeScriptはすごくJavaっぽくて良いなと感じました。 過去に、JavaScriptの直さわりを不要とすることを売りにした、GWTやWicketなどが流通したエンタープライズなJava界隈でも、これなら受け入れてもらえる予感が...

あとはつまらない環境構築などを不要とするEclipseみたいIDEが登場すれば(Visual Studio がそうなるのかな?)、フルスタックを売りとした魅力的なエンタープライズ向けプロダクトになる可能性を秘めてるのではないでしょうか。