Mainly Devel Notes

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

初心者が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 別の方の予定だったのですが急遽変更になりました。ではまた明日。