Mainly Devel Notes

Twitter, GitHub, StackOverflow: @ovrmrw (short hand of "overmorrow" that means the day after tomorrow)

Angular2の実践的なビューの作り方(Abstract Classを使う)

Angular2, TypeScript, Abstract Class, RxJS

【更新】Angular2 rc.0に対応しました。記事の内容とは異なる部分がありますのでご留意下さい。

Angular 2 Advent Calendar 2015の10日目です。

前提環境などは昨日と同じなので、先に軽く目を通しておいていただければと思います。当然TypeScriptが大前提です。
昨日→初心者がAngular2で嵌まったり解決したりサンプルコード書いたりしてみた。

今日は何のために、どういうメリットのためにクラスを継承(extends)するのかに焦点を当てます。
想定する対象読者は↓

  • 何のためにクラスの継承をするのかよくわからない。
  • DogやCatがAnimalを継承したら何がウマいのかわからない。
  • Abstract Classを継承することの意義がわからない。
  • Abstract Classを知らない。

目次

Abstract Classとは

JavaC#のようなオブジェクト指向の言語を使ってる人にとっては当たり前の機能ですが、TypeScriptには最近のバージョンでようやく追加されました。(interfaceは最初からあった)
もちろんJavaScriptにはこのような概念はありません。
Abstractの言葉の意味は「抽象的な、理論上の」といった感じですが、まあそれはいいとして要するにinterfaceを内包した継承専用のclassだと思えばいいんじゃないでしょうか。 僕はそう理解していますよ。間違っていたらごめんなさい。
最大の効能は、親クラスを継承した子クラスに実装を強制できるということに尽きます。そう言うと「interfaceでもいいじゃん」となりがちですが、 interfaceはimplementsし忘れたら強制力を発揮できません。別に関数名が合ってればimplementsしてなくても動くし。

クラスを継承しただけで強制力を持たせられるというのは、後程の説明でも出てきますが、強制しているが故に親クラスから子クラスの関数を呼び出すこともできるということにつながります。
子クラスで共通のコードを親クラスに追いやろうとするとき、これはとても大事なことなので覚えておいてください。

自分が書いたコードを数か月後にメンテナンスしている場面を想像できますか? 趣味ではありませんよ、ビジネスとしてです。 そのとき全てを忘れているあなたが子クラスに何か破壊的な変更をしようとする度に、コンパイラは何がどうあるべきかを思い出させてくれるでしょう。

コメントを適切に残すのも大事ですが、コードを適切に強制するのもメンテナンスする上では大事なことです。

Abstract Classの意義

実装のないAbstract Functionを持つため、Abstract Classはそれ自身をインスタンス化することができません。つまりnew ParentClass()はできません。
継承専用となるため、子クラスで共通のコードをabstractな親クラスに追いやるようにしましょう。

Web開発ではビューを作るときに、そうですね10画面ぐらいのビューを作るとしましょう。 全てのビューで共通のコードってあると思います。僕が思いつくところでは、

今回はこの2つを取り上げますが、イベントハンドラはせっかくなのでAngular2の普通のやり方ではなく、 RxJSのSubscription(Observableイベントハンドラ)を使って例を示します。

後に出てくる以下の2つの関数に注意を払ってください。
これらがAbstract Function(子クラスで実装を強制される関数)として登場します。

  • initializableJQueryPlugins()
  • initializableEventObservables()

親クラスでは宣言だけ、子クラスで実装します。その結果として親クラスの中で呼び出せるようになります。そして今回の例では

  1. 子クラスのビューを用意しようとするとき、
  2. 親クラスのngOnInit()を通じてinitPluginsAndObservables()実行。
  3. (子クラスで実装されているはずの)initializable関数を親クラスから呼び出し。

という流れで処理されます。
もう何度も言っていることですが、Abstract Classを使うと共通するコードを親クラスに追いやってすっきりさせることが簡単にできます。
順を追って理解していけばそう難しいことはないはずですので、さあ、はじめましょう。

(僕は今回の記事のようなパターンを勝手にAbstract Classデザインパターンと読んでいます)

Step1 abstract親クラスを子クラスで継承する

// app-parent.ts

export abstract class AppParent {
}
// app-page1.ts

import {Component} from 'angular2/core'

const componentSelector = 'app-page1';
@Component({
  selector: componentSelector,
  template: `  
  `
})
export class AppPage1 extends AppParent {
}

AppPage1子クラスは、abstractなAppParent親クラスを継承します。abstractな関数を宣言する予定なので、クラスもabstractを付けなければいけません。
Step1は簡単ですね。


Step2 jqueryプラグインの登録に関するコードを書く

// app-parent.ts

export abstract class AppParent {
  
  // 追加ここから▼▼▼
  protected abstract initializableJQueryPlugins(): void;
  // 追加ここまで▲▲▲
}
// app-page1.ts

import {Component} from 'angular2/core'

const componentSelector = 'app-page1';
@Component({
  selector: componentSelector,
  template: `  
    <div id="datepicker"></div>
    <div id="dialog"></div>
  `
})
export class AppPage1 extends AppParent {
  
  // 追加ここから▼▼▼
  initializableJQueryPlugins(): void {
    $(`${componentSelector} #datepicker`).datepicker();
    $(`${componentSelector} #dialog`).dialog();
  }
  // 追加ここまで▲▲▲
}

AppParent親クラス

  • abstractなinitializableJQueryPlugins()を追加。子クラスで実装することを強制します。

AppPage1子クラス

  • initializableJQueryPlugins()を追加。親クラスでabstractとなっているので実装する必要があります。jqueryプラグインの登録を行ないます。

beta.0まではルーティングを使うときにはjqueryプラグインが確実に一度だけロードされるように制御する必要がありましたが、
beta.1からはAngular2側の制御が変わったみたいで逆にページ遷移で入る度に毎回ロードする必要があります。

この記事はその影響を多大に受けて多くのコードを削除することになりましたが、それはそれで書き方が楽になるので良いBreaking Changeだと思います。


Step3 イベントハンドラ(Subscription)の登録に関するコードを書く

// app-parent.ts

import {Subscription} from 'rxjs/Subscription'

export abstract class AppParent {
  
  protected abstract initializableJQueryPlugins(): void;
  
  // 追加ここから▼▼▼
  private _disposableSubscriptions: Subscription<any>[] = [];
  private get disposableSubscriptions() {
    return this._disposableSubscriptions;
  }
  protected set disposableSubscription(subscription: Subscription<any>) {
    this._disposableSubscriptions.push(subscription);
  }
  
  protected abstract initializableEventObservables(): void;
  // 追加ここまで▲▲▲
}
// app-page1.ts

import {Component} from 'angular2/core'
import {Observable} from 'rxjs/Observable'
import _ from 'lodash'

const componentSelector = 'app-page1';
@Component({
  selector: componentSelector,
  template: `  
    <div id="datepicker"></div>
    <div id="dialog"></div>
    <div><input id="searchWord" type="text" [(ngModel)]="searchWord"></div>
    <div>{{now | date:'yyyy-MM-dd HH:mm:ss'}}</div>
  `
})
export class AppPage1 extends AppParent { 
  
  initializableJQueryPlugins(): void {
    $(`${componentSelector} #datepicker`).datepicker();
    $(`${componentSelector} #dialog`).dialog();
  }
  
  // 追加ここから▼▼▼
  static _searchWord: string = '';
  get searchWord() {
    return AppPage1._searchWord;
  }
  set searchWord(word: string) {
    AppPage1._searchWord = word;
  }
  now: number;
  
  initializableEventObservables(): void {
    this.disposableSubscription = Observable.fromEvent<KeyboardEvent>(document.getElementById('searchWord'), 'keyup') // (1)
      .map(event => event.target.value)
      .debounce(() => Observable.timer(1000))
      .subscribe(value => {
        this.loadCards(value); // 最後に説明します。
      }); // Subscription型が返る。

    this.disposableSubscription = Observable.timer(1, 1000) // (2)
      .subscribe(() => {
        this.now = _.now();
      }); // Subscription型が返る。
      
    this.disposableSubscription = Observable.fromEvent<MouseEvent>(document.getElementsByTagName(componentSelector), 'click') // (3)
      .map(event => event.target.textContent)
      .filter(text => _.trim(text).length > 0)
      .subscribe(text => {
        Materialize.toast(`You clicked "${text}"`, 2000); // Materialize-cssの通知 
      }); // Subscription型が返る。
  }
  // 追加ここまで▲▲▲
}

AppParent親クラス

  • _disposableSubscriptions配列、及びそのgetter/setterを追加。Subscriptionを格納する配列を用意しておき、後でまとめてdisposeするときに使います。
  • abstractなinitializableEventObservables()を追加。子クラスで実装することを強制します。

AppPage1子クラス

  • initializableEventObservables()を追加。親クラスでabstractとなっているので実装する必要があります。ObservableからSubscriptionの生成を行ない、disposableSubscriptionに代入します。後でまとめてdisposeするときに使います。

子クラスでstaticな変数を持つ理由は、状態(値)を保存しておくためです。
今回の例ではsearchWordはルーティングでページを行ったり来たりしても失われずに残り続けます。

最初に述べたように、イベントハンドラは全て(といっても3つだけですが)ObservableからSubscriptionを生成しています。

  1. input要素(searchWord)に文字を入力して、1秒間キーボード入力が止まるとloadCards()がコールされる。
  2. 1000ミリ秒毎にnowを更新する。つまり時計の表示を更新する。
  3. 色々なHTMLエレメントをクリックすると、イベントから取り出したtextContentをMaterialize-cssのトーストで通知表示する。

これらのSubscriptionなイベントハンドラはページ遷移する度にdisposeと生成を繰り返さないと動作がおかしくなりますので、そのためのコードを次のStep4で示します。

お気づきかと思いますが、親クラスは子クラスに公開する必要のない変数や関数はprivateにします。 必要のないものは見せない。これも適切にプログラムを強制するということの一環かと思います。こういうのよくカプセル化とかいいますね。

Step4 イベントハンドラ(Subscription)をdisposeするコードを書く

// app-parent.ts

import {Subscription} from 'rxjs/Subscription'
import {OnDestroy} from 'angular2/core'

export abstract class AppParent implements OnDestroy { // interfaceをimplementsする
  
  protected abstract initializableJQueryPlugins(): void;
  
  private _disposableSubscriptions: Subscription<any>[] = [];
  private get disposableSubscriptions() {
    return this._disposableSubscriptions;
  }
  protected set disposableSubscription(subscription: Subscription<any>) {
    this._disposableSubscriptions.push(subscription);
  }
  
  protected abstract initializableEventObservables(): void;
  
  // 追加ここから▼▼▼
  private disposeSubscriptions(): void {
    this.disposableSubscriptions.forEach(subscription => {
      if (!subscription.isUnsubscribed) {
        subscription.unsubscribe();
      }
    });
    this._disposableSubscriptions = void 0;
  }
  
  ngOnDestroy() {
    this.disposeSubscriptions();
  }
  // 追加ここまで▲▲▲
}

AppParent親クラス

  • disposeSubscriptions()を追加。配列に格納された全てのSubscriptionをunsubscribeします。つまりdisposeします。ページ遷移で出る際に必須です。
  • OnDestroyインターフェースのngOnDestroy()を追加。ページ遷移で出る度にイベント発火します。

AppPage1子クラス

  • 追加変更ありません。もし子クラスでもngOnDestroy()を実装する場合は、その中でsuper.ngOnDestroy()を書く必要があります。(そうしないと親クラスのngOnDestroy()が呼ばれないため)

さあ、子クラスには何も追加していません。
ページ遷移で出る際、OnDestroyインターフェースのngOnDestroy()が呼ばれますが、子クラスには定義していないので自動的に親クラスのngOnDestroy()が呼ばれ、disposeSubscriptions()が実行されます。

親クラスで何をやっているかなんて全く気にしなくていいですね。なんてすっきりなんでしょう。
しかしAbstract Classは次のStep5で本領を発揮します。


Step5 jqueryプラグインとObservableイベントハンドラを登録するコードを書く(子クラスで実装した関数を親クラスから呼び出す)

// app-parent.ts

import {Subscription} from 'rxjs/Subscription'
import {OnDestroy, OnInit} from 'angular2/core'

export abstract class AppParent implements OnDestroy, OnInit {
  
  protected abstract initializableJQueryPlugins(): void;
  
  private _disposableSubscriptions: Subscription<any>[] = [];
  private get disposableSubscriptions() {
    return this._disposableSubscriptions;
  }
  protected set disposableSubscription(subscription: Subscription<any>) {
    this._disposableSubscriptions.push(subscription);
  }
  
  protected abstract initializableEventObservables(): void;
  
  private disposeSubscriptions(): void {
    this.disposableSubscriptions.forEach(subscription => {
      if (!subscription.isUnsubscribed) {
        subscription.unsubscribe();
      }
    });
    this._disposableSubscriptions = void 0;
  }
  
  ngOnDestroy() {
    this.disposeSubscriptions();
  }
  
  // 追加ここから▼▼▼
  private initPluginsAndObservables(): void {
    this.initializableJQueryPlugins();
    this.initializableEventObservables();
  }
  
  ngOnInit() {
    this.initPluginsAndObservables();
  }
  // 追加ここまで▲▲▲
}

AppParent親クラス

  • initPluginsAndObservables()を追加。子クラスで実装されたinitializableJQueryPlugins()initializableEventObservables()親クラスから実行してUIを準備します。ただし同じセレクターに対してjqueryプラグインを二重に登録しないように制御します。
  • OnInitインターフェースのngOnInit()を追加。ページ遷移で入る度にイベント発火します。

AppPage1子クラス

  • 追加変更ありません。もし子クラスでもngOnInit()を実装する場合は、その中でsuper.ngOnInit()を書く必要があります。(そうしないと親クラスのngOnInit()が呼ばれないため)

さあ、わかっていただけたでしょうか。
子クラスで実装された2つのinitializable関数は、子クラスの中では実行されません。
(子クラスでngOnInit()を書くときだけ注意する必要があります)
そして子クラスは親クラスが何をしているかを知る必要はありません。ただ単に実装を強制された関数を適切に実装しているだけです。
これがAbstract Classの威力です。使えば使うほどその力はあなたの役に立つはずです。

例えこの親クラスを継承するビューが10個あろうが100個あろうが、仕様変更時に子クラスが受ける影響は軽微であることが伝わるかと思います。
共通となりそうなコードはバンバン追いやってしまいましょう。Angular2のビューを作るときのポイントをもう一度整理しますよ。

  • Observableイベントハンドラはページ遷移で出る度に全てdisposeして、入る度に全て登録し直すこと。そうしないと動作がおかしくなる。
  • 共通のコードはなるべくまとめて親クラスに追いやること。親クラスから子クラスで実装したコードを呼び出せる性質を利用すること。
  • 仕様変更時にいかに自分が楽できるかを考えながらコーディングすること。ビジネスの現場では仕様変更はしょっちゅうある。

これができるのはTypeScriptによる恩恵が大きいです。静的な型の力ですね。

さて、親クラスはこれで完成ですが、最後に説明を保留していたloadCards()を子クラス追加して終わりたいと思います。。


Step6 最後まで説明を保留していたloadCards()を書く

これはもうオマケみたいなものなので、読み飛ばしてGitHubにアップロードしたサンプルコードを動かしてもらったが早いと思います。

// app-page1.ts

import {Component} from 'angular2/core'
import {Observable} from 'rxjs/Observable'
import _ from 'lodash'
import {Http, Response, HTTP_PROVIDERS} from 'angular2/http'

const componentSelector = 'app-page1';
@Component({
  selector: componentSelector,
  template: `  
    <div id="datepicker"></div>
    <div id="dialog"></div>
    <div><input id="searchWord" type="text" [(ngModel)]="searchWord"></div>
    <div>{{now | date:'yyyy-MM-dd HH:mm:ss'}}</div>
    <div>
      <ul>
        <li *ngFor="#card of cards">{{card.title}} - {{card.body}}</li>
      </ul>
    </div>
  `,
  providers: [HTTP_PROVIDERS]
})
export class AppPage1 extends AppParent {
   
  initializableJQueryPlugins(): void {
    $(`${componentSelector} #datepicker`).datepicker();
    $(`${componentSelector} #dialog`).dialog();
  }
  
  static _searchWord: string = '';
  get searchWord() {
    return AppPage1._searchWord;
  }
  set searchWord(word: string) {
    AppPage1._searchWord = word;
  }
  now: number;
  
  initializableEventObservables(): void {
    this.disposableSubscription = Observable.fromEvent<KeyboardEvent>(document.getElementById('searchWord'), 'keyup')
      .map(event => event.target.value)
      .debounce(() => Observable.timer(1000))
      .subscribe(value => {
        this.loadCards(value);
      });

    this.disposableSubscription = Observable.timer(1, 1000)
      .subscribe(() => {
        this.now = _.now();
      });
      
    this.disposableSubscription = Observable.fromEvent<MouseEvent>(document, 'click')
      .map(event => event.target.textContent)
      .filter(text => _.trim(text).length > 0)
      .subscribe(text => {
        Materialize.toast(`You clicked "${text}"`, 2000);  
      });    
  }
  
  // 追加ここから▼▼▼
  constructor(public http: Http) {
  }
  cards: Card[] = [];
  
  loadCards(searchWord: string = ''): void {
    (async() => {
      let cards: Card[] = await this.http.get('/cards.json')
        .map((res: Response) => res.json() as Card[])
        .toPromise(Promise); // (1)
      if (searchWord) {
        const words: string[] = _.chain(searchWord.replace(/[ ]/g, ' ').split(' ')) // (2)
          .map(word => _.trim(word))
          .filter(word => word.length > 0)
          .value();
        words.forEach(word => {
          cards = _.filter(cards, card => {
            return _.some([card.title, card.body], value => value.indexOf(word) > -1);
          });
        });
      }
      this.cards = cards;
    })();
  }
  // 追加ここまで▲▲▲
}

declare interface Card { // 追加
  title: string;
  body: string;
}

loadCards()の定義を追加しました。
これに関しては内容が大分かぶるので過去記事 初心者がAngular2で嵌まったり解決したりサンプルコード書いたりしてみた。 - Httpモジュールを使ってみよう(async/await登場)を参照してください。
補足程度に簡単に説明すると、

  • async関数スコープ内は非同期処理を同期風に書ける。
  • awaitPromiseが解決するまで先に進まない。
  • (1)でObservable型をPromise型に変換している。awaitで待ち受けするため。
  • (2)でsearchWordをスペースで区切って配列に変換している。
  • 入力したワードがCardのtitleかbodyに一致したものだけ絞り込んで画面に表示する。

こんなようなことをやっています。
Httpモジュールを使うので、

  • import {Http, HTTP_PROVIDERS} from 'angular2/http'
  • @Component({ providers: [HTTP_PROVIDERS] })
  • constructor(public http: Http) { }

上記3点はセットで揃えましょう。

これで子クラスも完成しました。お疲れ様でした。
お気づきかと思いますがAbstract ClassデザインパターンがAngular2に依存するコードはイベント発火時の関数名(ngOnInit(),ngOnDestroy())だけなので、 どのフレームワークを使ったとしても他の部分のコードは流用できるかと思います。


GitHubにアップロードしてるので動かしてみてください

今回の記事に関する実例をGitHubにアップロードしました。
ovrmrw/angular2sample1
Card Listのページともう一つのページしかありませんが、一応は記事の内容が動作するサンプルとなっています。時間のあるときにでも見てみてください。

サンプルコードを順を追って説明するためにかなりの長文になってしまったことをお詫びします。

明日は @jimbo さんです。

ありがとうございました。

初心者がAngular2で嵌まったり解決したりサンプルコード書いたりしてみた。

Angular2, TypeScript, VS Code, System.js, async/await, Electron

【更新】GithubリポジトリはAngular2 rc.0に対応しました。記事の内容とは大幅に異なるのでご留意下さい。

Angular 2 Advent Calendar 2015の9日目です。

目次

はじめに

みなさんAngular2使ってますか? 使ってませんよね、だってまだalphaバージョンだもん。(先日も破壊的な更新があったし…)
でも僕は最近妙にハマってます。公式チュートリアルがすごくわかりやすくて直観的だったから。まあalpha故に別の意味でも度々ハマってますがw

ちなみに僕はWeb開発の経験はほとんどないAngular1もよくわかってない、なんでここに参加してるのかもよくわからない出自の者なのですが、 普段は基幹業務系のSIerです。どちらかというとフロントエンドよりもサーバーサイド寄りです。Angular2以外に触れたことのあるフレームワークと言えばKnockoutAureliaくらいです。
なんでWeb開発に手を出してるの?と聞かれたら今のところは「修行の一環として」としか答えられないです。はいすみません。

さて来年には正式リリースされる予定のAngular2ですが、一番の注目点はTypeScriptベースで開発されているということです。
ちょっと前にKnockoutでWeb開発に触れたとき、最初に痛感したのは「型の無いJavaScriptキモイ」ということでした。 仕事でC#をちょこちょこ使うMicrosoft派な僕にとって型が無いから実行時まで構文上のエラーさえ知らされないというのは「そんなん無理じゃん」というぐらい生理的に受け付けないものでした。(Lintとか知らない)
そしたらしばらくしてTypeScriptというものが世に出てきまして、そのとき1万だか2万行ぐらい書いてたJavaScriptのコードを夢中でTypeScriptに書き直したことを覚えています。 まあそのときの成果物は今となっては稼働していないので懐かしい思い出話なんですけどね。

前置きが長くなりましたが、そういうわけで僕はIntelliSenseに飼い慣らされたこともあって型の無い世界は嫌いです。だからNot JavaScript But TypeScriptです。
そしてせっかくなら新しいもの使いたいじゃないですか。ES6。それにC#出身なんでasync/awaitも使いたいですよね。

ではいきましょう。今回の記事の前提環境です。

  • OSはWindows (自宅にも職場にもあるから)
  • Visual Studio Code (TypeScriptと相性が良さそうだから)
  • モジュールローダーはSystem.js (Angular2の公式チュートリアルがそうだから)
  • JavaScriptは余程のことがない限り全てTypeScriptで書く (型が無いと生きられないから)
  • TypeScriptのtargetはES6 (新しいしasync/await使えるから)
  • TypeScript→ES6→Babelで事前コンパイルする (async/awaitを動かすため)
  • 5 MIN QUICKSTARTとかTUTORIAL: TOUR OF HEROESとか一通りわかる (基礎知識として必要だから)

WindowsでのNode.js環境の作り方は過去記事 Windowsでnpm installの赤いエラーに悩まされているアナタへで詳しく触れています。

そして下記のnpm installがされていることを想定します。

npm install es6-promise@^3.0.2 es6-shim@^0.33.3 -save
npm install reflect-metadata@0.1.2 rxjs@5.0.0-beta.0 zone.js@0.5.10 --save --save-exact
npm install angular2@2.0.0-beta.1 --save --save-exact 
npm install systemjs lodash jquery hammerjs materialize-css --save
npm install typescript babel-preset-es2015 babel-polyfill gulp gulp-typescript gulp-babel gulp-ignore electron-prebuilt --save-dev
tsd install lodash jquery --save

僕が普段使っているtsconfig.jsonファイルの内容です。今回はこの設定を前提とします。

// tsconfig.json (TypeScript1.7用)

{
  "compilerOptions": {
    "target": "ES6",
    "noImplicitAny": false,
    "sourceMap": false,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node"
  },
  "exclude": [
    "node_modules"
  ]
}

Part1 tsdでインストールされるd.tsファイルがES6対応じゃないなら自力で対応しよう

よくみんなtsdでd.tsファイルをインストールしますよね、こうやって。

tsd install lodash --save

でもこれ、将来的にはどうか知りませんが現状はES6対応していません。
例えばapp.tsというファイルの中で…

// app.ts

import _ from 'lodash'

って書くと'lodash'のとこに赤い波線が出てきます。なにやらエラーのようです。
そこでtypings/lodash/lodash.d.tsファイルを見てみると、最後の方に…

// lodash.d.ts

declare module "lodash" {
    export = _;
}

と書いてあります。これが原因ですね。import _ from 'lodash'と書きたい場合、これを…

// export = _;
export default _;

としなくてはいけません。TypeScriptの仕様なのかES6の仕様なのか知りませんが、そういうものだからです。
でもなるべくtypingsフォルダの中はいじりたくないですよね。だからmy.d.tsファイルみたいに別ファイルを用意して…

// my.d.ts

declare module "lodash" {
  export default _;
}

と書きます。そうするとオーバーライド(?)みたいな扱いになるのか、import _ from 'lodash'はエラーと見なされなくなります。 ちなみにimport * as _ from 'lodash'と書けばそもそもエラーにはなりません。ネットで調べるとよくこの書き方を指南されるんじゃないでしょうか。
じゃあなんで

  • import * as _ from 'lodash'ではダメで
  • import _ from 'lodash'にこだわるのか

というと、前者はwindowオブジェクトに枝を生やさない(window._が生成されない)のに対し、後者は枝を生やすからです。
つまりHTMLファイルで<script src="...lodash.js">みたいなことを書かなくていいんですね。これは特に下記のようなコードに影響します。

// app.ts

onClick(event) {
  if (_.isEmpty(event.target.value)) .....  
}

うろ覚えで適当にコード書いてますが、要するにボタンクリック時とかに発火するような関数の中でlodashを使おうとしていると、window._が存在しない場合にエラーになります。 先ほどの前者はダメで後者なら良いというのはこういうことです。後者はRequireJSと同じようなものだというとわかりやすいかもしれません。

TypeScriptと付き合っていくときの心得としては、

  • 動くなら多少のエラーは無視する。
  • 与えられたコードは必要に応じてオーバーライド(?)したり直接上書きしたりする。

というのも必要かなって思います。あと公式ドキュメントはちゃんと読みましょう。
僕はTypeScriptのエラーと格闘して何時間か嵌りました。

Part2 System.jsを使うならconfigは専用ファイルを用意しよう

Angular2の公式チュートリアルで採用されているものが正義です。少なくとも僕の中では。というか他のツールでAngular2を使う方法を知りません。
それ以前にAureliaもjspm(System.jsを内包しているライブラリ)を推していますから、僕はその流れに乗るしかありません。

ところでチュートリアルだとHTMLファイルの中にさらっとSystem.config()が書いてありますが、結構がっつりやるとこれがどんどんボリューム増になります。
jspmとか使うとこの辺はわりと自動でやってくれるんですけど、せっかくだからnode_modulesフォルダの中身をブラウザ環境で使いまわしたいし勉強も兼ねて自分で書いた方がいいですね。

これは僕が普段使っているもので、index.htmlファイルと同じ場所に配置します。

// system.config.js

System.config({
  baseURL: '.',
  transpiler: false,
  paths: {
    'node:*': '../node_modules/*',    
  },
  map: {
    'babel-polyfill': 'node:babel-polyfill/dist/polyfill.min.js',
    'numeral': 'node:numeral/min/numeral.min.js',
    'moment': 'node:moment/min/moment.min.js',
    'lodash': 'node:lodash/index.js',
  },
  packages: {
    'app': { defaultExtension: 'js' },
  },
  meta: {
    'app/*.js': { deps: ['babel-polyfill'] }
  }
});

System.jsはブラウザ上でのimportやrequireをフックする(本来の機能を上書きする)ものなので、 System.config()がちゃんと設定されていないとtsファイルでimport _ from 'lodash'とか書いても無駄ですので注意しましょう。
metaプロパティでやっていることは、/src/app/*.jsファイルが読み込まれたら一緒にbabel-polyfillを読み込む、という指示です。 現状はこれがないとasync/awaitが動きませんので注意してください。 僕は当初System.jsがrequireやimportにどう影響を及ぼしているか理解していなかったので何時間か嵌りました。

(関連過去記事 TypeScript + System.jsの構成におけるSystem.config()の基本パターン。)

Part3 TypeScript→ES6→Babelで事前コンパイルしよう(async/await対応)

正直言ってgulpfileの書き方よくわかってません。が、こんな感じで書くとgulp tscとかgulp watchしたときにちゃんとコンパイルしてくれます。

// gulpfile.js

'use strict';
const gulp = require('gulp');
const ts = require('gulp-typescript');
const babel = require('gulp-babel');
const ignore = require('gulp-ignore');

gulp.task('tsc', () => {
  const tsProject = ts.createProject('tsconfig.json', { noExternalResolve: true });
  tsProject.src()
    .pipe(ignore.exclude(['**/*.d.ts', 'node_modules/**/*.*', 'typings/**/*.*']))
    .pipe(ignore.include(['*.ts', 'src/**/*.ts']))
    .pipe(ts(tsProject)) // (1)
    .pipe(babel({ // (2)
      presets: ['es2015']
    }))
    .pipe(gulp.dest('.'));
});

gulp.task('watch', () => {
  gulp.watch(['*.ts', 'src/**/*.ts'], ['tsc']);
});

tsconfig.jsonファイルの中でtarget: ES6と指定しているので、(1)の段階でTypeScriptからES6のJavaScriptに変換されます。 でもこれだけだと現状のブラウザでは動かないんですね。もう一度変換する必要があります。
次の(2)の段階でBabelに通してようやくブラウザで動くES5のJavaScriptに変換されます。
これでC#erが泣いて喜ぶasync/awaitが動くES5のJavaScriptファイルの完成ですよ。(細かいことを言えばさらにbabel-polyfillが必要です)

処理速度を気にしなければブラウザ上で実行時にBabelで変換するというやり方もあったのですが、 最新版のBabelでは非推奨になっているのと公式サイトからもやり方が消えてしまったので今後はブラウザ環境で実行時コンパイルをやるな、ということなのだと思います。
僕はgulpfileの書き方で何時間か嵌りました。

Part4 ルーティングを書いてみよう

公式チュートリアルにはルーティングの書き方の説明がないんですよね。
最もシンプルに説明するにはどうしたらいいかなって思って、下記のコードに辿り着きました。後はこれにゴテゴテ色々付け足していくことになると思います。

import {Component, provide} from 'angular2/core'
import {bootstrap} from 'angular2/platform/browser'
import {Router, Route, RouteConfig, ROUTER_DIRECTIVES, ROUTER_PROVIDERS, Location, LocationStrategy, HashLocationStrategy} from 'angular2/router'
import {Page1} from './page1/page1'
import {Page2} from './page2/page2'

@Component({
  selector: 'my-app',
  template: `
    <ul>
      <li><a [routerLink]="['/Page1']">PAGE1</a></li>
      <li><a [routerLink]="['/Page2']">PAGE2</a></li>
    </ul>
    <router-outlet></router-outlet>
  `,
  directives: [Page1, Page2, ROUTER_DIRECTIVES]
})
@RouteConfig([
  new Route({ path: '/p1', component: Page1, name: 'Page1', useAsDefault: true }),
  new Route({ path: '/p2', component: Page2, name: 'Page2' }),
])
export class App {
  constructor(public location: Location, public router: Router) {
  }
}
bootstrap(App, [ROUTER_PROVIDERS, provide(LocationStrategy, { useClass: HashLocationStrategy })]);

new Route()の中でcomponentnameで同じこと書くならどっちかいらなくね?って思ったんですが、両方書いてないとエラーになるみたいです。
(↑この辺は理解が曖昧なので下のリンク先を参照してください)
constructor()でDIしているのはAngular2のお約束みたいなものですね。

ちなみにルーティングを使うときはHTMLファイルで…

<script src="../node_modules/angular2/bundles/router.dev.js"></script>

をお忘れなく。僕はこれで何時間か嵌りました。

ルーティングに関してはAngular2のRouterを触ってみるが詳しいです。

Part5 Httpモジュールを使ってみよう(async/await登場)

公式チュートリアルにはHttpモジュールの使い方も説明されていません。
最もシンプルに説明するにはどうしたらいいかな、でもasync/awaitも書きたいしって思ってたらこうなりました。

import {Component} from 'angular2/core'
import {Http, Response, HTTP_PROVIDERS} from 'angular2/http'
import _ from 'lodash'

@Component({
  selector: 'my-page1',
  template: `
    <input type="text" (keyup)="onChangeWord($event)">
    <ul>
      <li *ngFor="#card of cards">{{card.title}} - {{card.body}}</li>
    </ul>
  `,
  providers: [HTTP_PROVIDERS]
})
export class Page1 {
  cards: Card[] = [];
  
  // .....色々省略
  
  constructor(public http: Http) {
  }
  onChangeWord(event: KeyboardEvent): void {
    const value = event.target.value;
    this.loadCards(value);
  }
  loadCards(searchWord: string = ''): void {
    console.log(1);
    (async() => {
      console.log(2);
      let cards = await this.http.get('/cards.json')
        .map((res: Response) => res.json() as Card[])
        .toPromise(Promise);
      console.log(4);
      if (searchWord) {
        const words = _.words(searchWord);
        words.forEach(word => {
          cards = _.filter(cards, card => {
            return card.title.indexOf(word) > -1 || card.body.indexOf(word) > -1;
          });
        });
      }
      this.cards = cards;
    })();
    console.log(3);
  }  
}

すみません、シンプルではなくなってしまいました。
元々のコードを大分端折ったので伝わるかどうかわからないのですが、検索ワードを入力するinput要素があって、 何か入力するとloadCards()が呼ばれてCardの中の文字列にヒットするものだけを絞り込んで画面に表示する、という内容です。
さてこのloadCards()の特徴は…

  • async関数スコープ内は非同期処理を同期っぽく書ける。
  • consoleには1, 2, 3, 4の順でログが残る。
  • awaitPromiseが解決するのを待ってから先に進む。
  • Angular2のHttpモジュールはObservable型を返すが、そのままだとawaitできないのでtoPromise()でPromise型に変換している。
  • ifより先はcardsが値を持っていることを前提として書けるのでthen()の出番はない。
  • async/awaitは要するにfunction*yieldの組み合わせと同じ。(だと思う)

Angular2のHttpモジュールはネットで調べるとわかるように、敢えてPromiseではなくrxjsのObservableを採用しています。
そしてその場合、通例ではmap()の次にsubscribe()で受けてそこでその後の処理を書くわけなんですが、それだとthen()と大して変わらないし、 せっかくasync/awaitやってるんだからawaitできるようにtoPromise()でPromise型に変換して返したいよねって思ってたらこういう結果になりました。

ちなみにHttpモジュールを使うときはHTMLファイルで…

<script src="../node_modules/angular2/bundles/http.dev.js"></script>

をお忘れなく。僕はこれで何時間か嵌りました。

Httpモジュールに関してはAngular2のHttpモジュールを眺めてベストプラクティスを考えるが詳しいです。

Part6 ElectronとSystem.jsで初心者が嵌まりそうなこと(require('remote')編)

Electronのレンダラプロセス(ブラウザ)からメインプロセス(サーバーサイド)のモジュールを使いたいとき、普通にやったらレンダラプロセスではrequireできないんですね。
そういうときどうしたら良いか公式ドキュメント:remoteには以下のように説明されています。

const remote = require('remote');
const BrowserWindow = remote.BrowserWindow;

var win = new BrowserWindow({ width: 800, height: 600 });
win.loadURL('https://github.com');

レンダラプロセス内でrequire('remote')して、remote経由でメインプロセスのモジュールを操作しろ、と。

さてここで一つ問題が生じます。System.jsがrequireをフックしているという点です。
System.jsをモジュールローダーとして使う場合、上記のコードは動作しません。代わりにこういう風に書く必要があります。

const remote = System._nodeRequire('remote');

詳しくはSystemJS APIを参照してください。
あとはElectron公式ドキュメントの通りで大丈夫です。
僕はこれで何時間も嵌りました。

Part7 Electronで初心者が嵌りそうなこと(jqueryプラグイン編)

僕がよく使うライブラリの中で、lodash, moment, numeralあたりはPart1の方法でHTMLから<script src=...を追放できます。
が、jqueryとそのプラグインだけは例外であり、Expressのようなブラウザ環境とElectron環境で両立する書き方は工夫が必要です。

ネットで色々調べた結果、最もシンプルな解決策はこれだろうという結論に達したのがこれです。HTMLファイルの中に書きます。

<script src="../node_modules/jquery/dist/jquery.min.js" onload="try{ window.jQuery = window.$ = module.exports; }catch(e){ }"></script>
<script src="../node_modules/hammerjs/hammer.min.js" onload="try{ window.Hammer = module.exports; }catch(e){ }"></script>
<script src="../node_modules/materialize-css/dist/js/materialize.min.js"></script>

上記は個人的に気に入っているMaterialize-cssというCSSフレームワークを使う例です。
おそらくブラウザ環境だけならhammerjsの指定は必要ないと思いますが、Electron環境ではこう書かないと動きません。 最もよく使われているBootstrapでも同じような書き方で通用するだろうと思いますので試してみて下さい。

それとSPA開発なら当然jqueryプラグインを一度だけロードする方法も知っておく必要があります。これも嵌まりポイントです。
【注意】beta.1からはAngular2側の制御が変わり、jqueryプラグインは毎回ロードする必要があります。下記のサンプルコードはbeta.0以前の場合に有効です。

import {Component, AfterViewInit} from 'angular2/core'
declare var $: JQueryStatic;

const componentSelector = 'my-page2';

@Component({
  selector: componentSelector,
  template: `
    <!-- 省略 -->
  `
})
export class Page2 implements AfterViewInit {
  static _isJQueryPluginsInitialized: boolean = false;
  get isJQueryPluginsInitialized() {
    return Page2._isJQueryPluginsInitialized;
  }
  set isJQueryPluginsInitialized(flag: boolean) {
    Page2._isJQueryPluginsInitialized = flag;
  }

  ngAfterViewInit() {
    if(!this.isJQueryPluginsInitialized) {
      this.initJQueryPlugins();
      this.isJQueryPluginsInitialized = true;
    }
  }
  initJQueryPlugins(): void {
    $(`${componentSelector} .modal-trigger`).leanModal();
  }
}

上記はMaterialize-cssのModalsを使えるようにするコード例です。
ngAfterViewInit()は僕の知る限りコンポーネント生成の一番最後に実行される関数なのでここに書きます。 classのstatic変数で既にロードされたかどうかのフラグを持つのがコツですね。

これに関してはAngular2の実践的なビューの作り方(Abstract Classを使う)でより詳細に触れています。

Part8 interfaceを実装してBreaking Changesに備えよう

alpha.46からalpha.47に変わったときonInit()メソッドngOnInit()に変わりました。 名前が他で使われそうというのが理由らしいのですが、これは事の経緯を知らないといきなり動かなくなって嵌まる要因になります。僕は嵌まりました。

そこでオススメしたいのは多少面倒でもinterfaceを実装しておくことです。

import {Component, OnInit, AfterContentInit, AfterViewInit} from 'angular2/angular2'

const componentSelector = 'my-page2';

@Component({
  selector: componentSelector,
  template: `
    <!-- 省略 -->
  `
})
export class Page2 implements OnInit, AfterContentInit, AfterViewInit {
  constructor() {
    console.log(`${componentSelector} constructor`);
  }
  ngOnInit(){
    console.log(`${componentSelector} onInit`);    
  }
  ngAfterContentInit() {
    console.log(`${componentSelector} afterContentInit`);    
  }
  ngAfterViewInit() {
    console.log(`${componentSelector} afterViewInit`);    
  }
}

一行目でinterfaceをimportして、classのimplementsに加えていますね。
これにより、

  • interface名が変更される。
  • interfaceの仕様が変更される。

どちらの仕様変更があった場合でもTypeScriptがエラーを通知してくれるようになります。型を持つ者の強みですね。

ちなみに上記のコードを実行するとわかるのですが、constructor() ngOnInit() ngAfterContentInit() ngAfterViewInit()の順で実行されます。


最後に

嵌ってばかりでしつこいと思われるかもしれませんが僕にしてみればWeb開発は嵌ってることの方が多いです。
何か一行書くためだけに何時間かネットで調べて、やってみてダメだからまた調べに行って、トライ&エラーの繰り返し。よくみんなこんなことやってられますねw
そもそもWeb開発の最前線にいるわけでもない僕が書いたものなので、ところどころ筋違いなことを書いていたり理解できない部分があったかもしれません。 各章に参考文献へのリンクも記載しようかなとも思ったのですが、思い出せないものもたくさんあるしほとんど英語なので思い切ってばっさりなくしてしまいました。

KnockoutのチュートリアルJavaScriptを覚えて、AureliaのチュートリアルでモダンWeb開発に触れて、TypeScriptでようやくWebにも秩序が生まれるかなと思ったところに TypeScriptネイティブのAngular2の登場ですよ。Angular1を知らない僕でもこれならなんとかなるかなと思って手を出して以来、少し僕の中にも知見が積み上がってきた気がしたので こうして思い切ってAdvent Calendarに投稿してみた次第です。
どれか一つでもこれからAngular2を触る人の助けになれば幸いです。

ここまで読んでいただいてありがとうございました。

明日は…… また僕ですw 別の方の予定だったのですが急遽変更になりました。ではまた明日。