Mainly Devel Notes

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

ElectronでAngular2を動かすついでにasync/awaitも試してみた。

Electron, Angular2, TypeScript, async/await

【注】この記事ではAngular2 alpha.47を前提としています。それ以降のバージョンだと色々細かいところで違いがあるので注意してください。

Electronいいですね。
何がいいって、一番手間のかかるUI部分のプログラミングにHTMLとCSSJavaScriptが使えるんですよ。 これ一番手間のかからないやつじゃないですか。
僕はどちらかというとサーバーサイド側の担当(それもデスクトップアプリ)なので、UI構築とかほんとうにめんどくさい。とりあえず動けばいいってときはコンソールアプリで作って納めるぐらい。だから知見の積み上がったWebの世界を デスクトップのUI開発に持ち込めるというのはすごく幸せなことです。

そこで最近よく使ってるAngular2を使ってついでにTypeScriptで書いたasync/awaitも動かしてみよう、と思い立って いろいろやってたら出来たので、ここにメモを残します。

TypeScriptが吐き出すJSファイルはそのままでは動かなかったので実行時にBabelでさらにコンパイルしています。 それについては後述するindex.htmlファイルの項を参照してください。 これが現状では最も手間を掛けずにTypeScriptのasync/awaitを動かす方法じゃないかなと思います。

あとElectronでjqueryjqueryプラグインを使うときのやり方が特殊でハマりやすそうだったので簡単に触れておきました。
それについてもindex.htmlファイルの項を参照してください。


Electron 公式チュートリアル
Get started with Electron

Angular2 公式チュートリアル
5 MIN QUICKSTART
THE HERO EDITOR

過去記事
Angular2でDI(依存性注入)してテスト(Jasmine)を書いてみた。


時間があれば上記の記事を一通り眺めてみてください。
では始めます。

まずはElectron公式チュートリアルの手順通りに

git clone https://github.com/atom/electron-quick-start
cd electron-quick-start
npm install && npm start

npm startでアプリが起動するのでとりあえず動作確認します。

Angular2、他 いろいろインストール

npm install angular2@2.0.0-alpha.47 --save --save-exact 
npm install systemjs lodash jquery hammerjs materialize-css --save
npm install babel@6.1.18 --save-dev --save-exact
npm install typescript babel-polyfill --save-dev
tsc --init
tsd install node lodash --save

babel-coreを使うのが現在の主流らしいのですがよくわからなかったので babelbabel-polyfillをインストールします。
またオマケの説明のためにjquery, hammerjs, materialize-cssをインストールします。詳しくはindex.htmlファイルの項を参照してください。

(2015年12月5日追記、babelは最新版でbrowser.jsが削除されています。babel-coreに移行されたらしいのですがよくわからないので旧バージョンを指定してインストールします)

package.jsonの依存関係はこんな感じです

"devDependencies": {
  "babel": "6.1.18",
  "babel-polyfill": "^6.2.0",
  "electron-prebuilt": "^0.35.0",
  "typescript": "^1.6.2"
},
"dependencies": {
  "angular2": "2.0.0-alpha.47",
  "hammerjs": "^2.0.4",
  "jquery": "^2.1.4",
  "lodash": "^3.10.1",
  "materialize-css": "^0.97.3",
  "systemjs": "^0.19.6"
}

(2015年11月26日現在)

フォルダを作ります

mkdir src
cd src
mkdir app

Angular2公式チュートリアルに倣います。

tsconfig.json を編集します

// TypeScript 1.6.x
{
  "compilerOptions": {
    "target": "ES6",
    "noImplicitAny": false,
    "sourceMap": false,
    "experimentalAsyncFunctions": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node"
  },
  "exclude": [
    "node_modules"
  ]
}

// TypeScript 1.7.x
{
  "compilerOptions": {
    "target": "es6",
    "noImplicitAny": false,
    "sourceMap": false,    
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node"
  },
  "exclude": [
    "node_modules"
  ]
}

"target": "ES6","experimentalAsyncFunctions": true,"experimentalDecorators": true, "emitDecoratorMetadata": true,"moduleResolution": "node" は必要です。あとはお好みで。
async/awaitを使うにはES6で出力する必要があります。
(TypeScript1.7ではexperimentalAsyncFunctionsの指定は不要になりました。逆に書いてあるとコンパイル時にエラーになります)

(root)/main.js を編集します

//mainWindow.loadUrl('file://' + __dirname + '/index.html');
mainWindow.loadUrl('file://' + __dirname + '/src/index.html');

呼び出すHTMLファイルをsrcフォルダ下のindex.htmlに変更します。

(root)/src/system.config.js を追加します

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

transpiler: 'babel'が必要。babelOptionsは書かなくても動きました。(それで正しいのかどうかはよくわかりませんが)
metaプロパティの設定により、(root)/src/app/フォルダの.jsファイルが呼ばれるときにはbabel-polyfillも一緒に読み込まれます。これはasync/awaitを動かすのに必要なポリフィルです。

(root)/src/index.html を追加します

<!DOCTYPE html>
<html>
  <head>    
    <title>Hello World!</title>
    <script src="../node_modules/babel/dist/browser.js"></script>
    <script src="../node_modules/systemjs/dist/system.src.js"></script>
    <script src="../node_modules/angular2/bundles/angular2.dev.js"></script>
    <script src="system.config.js"></script>
    <script>      
      System.import('app/app');
    </script>
  </head>
  <body>    
    <h1>Hello World!</h1>
    We are using node <script>document.write(process.versions.node)</script>,
    Chrome <script>document.write(process.versions.chrome)</script>,
    and Electron <script>document.write(process.versions.electron)</script>.
    <my-app>loading</my-app>
    <script src="../node_modules/lodash/index.js"></script>    
    <!-- materialize-cssを使うために必要な記述 ここから -->
    <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を使うために必要な記述 ここまで -->
  </body>
</html>

(root)/index.htmlをコピーして、ちょこちょこ書き加えたものが上記です。 Angular2公式チュートリアルのエッセンスも入っています。
Babelに関する記述は、

  • Babelのbrowser.jsを読み込む。

の1点だけですが、system.config.jsファイルの設定により実行時にapp.jsと一緒にbabel-polyfillを読み込むようになっています。

TypeScriptで書く → ES6で事前コンパイル(tsc) → 実行時にBabelでさらにコンパイル(babel-polyfillも必要)
という流れです。
ここまでしてようやくTypeScriptで書いたasync/awaitを動かせます。

また今回は使いませんが、jqueryをElectronで使う場合はscriptタグ内のonload()window.jQuery = window.$ = module.exports;のように書く必要があります。Electronではこれでいいのですが、ブラウザ環境だとmodule is not definedみたいなエラーになるのでtry catchで囲んで両方の環境に対応するようにします。
例としてmaterialize-cssjqueryプラグインを使うためにどうすればいいかをbodyタグ下部に書いておきました。
(参考文献: SystemJS and 'default' import with 'export =' proposal #5285)

(root)/src/app/my.d.ts を追加します

declare interface Hero {
  id: number,
  name: string
}

declare interface IHeroService {
  getPromiseHeroes: () => Promise<Hero[]>
}

IHeroServicegetPromiseHeroes()Heroオブジェクトの配列をPromiseで返します。

(root)/src/app/hero-service.ts を追加します

export class HeroService implements IHeroService {
  heroes: Hero[];
  constructor() {
    this.heroes = HEROES;
  }
  getPromiseHeroes() {
    return new Promise<Hero[]>(resolve => {
      console.log('heroes are returned as Promise, they will be shown after 2 seconds');
      setTimeout(() => { resolve(this.heroes) }, 2000);
    });
  }
}

var HEROES: Hero[] = [
  { "id": 11, "name": "Mr. Nice" },
  { "id": 12, "name": "Narco" },
  { "id": 13, "name": "Bombasto" },
  { "id": 14, "name": "Celeritas" },
  { "id": 15, "name": "Magneta" },
  { "id": 16, "name": "RubberMan" },
  { "id": 17, "name": "Dynama" },
  { "id": 18, "name": "Dr IQ" },
  { "id": 19, "name": "Magma" },
  { "id": 20, "name": "Tornado" }
];

Angular2でDIするときの基本パターンですね。骨子は公式チュートリアルからのパクりです。
heroesPromiseで返しているのがポイントです。

(root)/src/app/app.ts を追加します【今回のエントリーの要です】

import {bootstrap, Component} from 'angular2/angular2'
import {HeroService} from './hero-service'
//import * as _ from 'lodash' // 実行時エラーの原因になるので不採用

@Component({
  selector: 'my-app',
  template: `
    <h2 *ng-if="count > 0">{{count}}</h2>
    <ul>
      <li *ng-for="#hero of heroes">{{hero.name | uppercase}}</li>
    </ul>
  `
})
export class AppComponent {
  heroes: Hero[];
  count: number;
  constructor(heroService: HeroService) {
    (async() => { // ↓ここからawaitが有効      
      console.log('app.ts async start');
      let range = _.range(0, 4).reverse(); // [3, 2, 1, 0]
      for (let i of range) {
        this.count = i;
        console.log(this.count);
        if (i > 0)
          await new Promise(resolve => { // awaitはPromiseがresolveするまで先に進まない
            setTimeout(() => { resolve() }, 1000);
          });
      }
      this.heroes = await heroService.getPromiseHeroes(); // ここでもawait
      console.log('app.ts async end');
    })(); // ↑ここまでawaitが有効    
  }
}
bootstrap(AppComponent, [HeroService]);

これも骨子はAngular2公式チュートリアルからのパクりです。
async functionのスコープ内でawaitが有効になります。 「awaitPromiseの戻り値を待つ」と考えると理解しやすいですね。 .then~~.then~~.then~~を書かなくていいので見ていて気持ちの良いコードになるかと思います。
ちなみに上記は3秒カウントダウンしたらheroService.getPromiseHeroes()が呼ばれるという内容です。
getPromiseHeroes()Promiseを返すのでawaitすることができます。ただ実際にはこのような場面ではawaitせずに次の処理に進ませることの方が多いと思います。
ちなみにgetPromiseHeroes()awaitしない場合は、template*ng-forのところにasyncパイプを付け加えないとエラーになります。
(*ng-for="#hero of heroes | async"とする)

package.jsonの"scripts"を編集します。

"scripts": {    
  "tsc": "./node_modules/.bin/tsc -p . -w",
  "start": "electron main.js"
},

動作確認しましょう

npm run tsc
npm start

カウントダウン後に少し遅れて10人のヒーローが表示されたらOKです。


以上です、ありがとうございました。