読者です 読者をやめる 読者になる 読者になる

undefined

bokuweb.me

Angular2 RC5への更新とステート管理の変更、power-assertによるテストまで試す

Angular2

概要

以前触ってみたときはRC3でRC5が出たらもう一回触るかってことで、以前作ったサンプルのRC5への更新、ステート管理の変更、ユニットテストについて試してみた。以前の記事は以下。

blog.bokuweb.me

RC5への更新

情報収集をするとNgModuleが追加されたことが大きいようで、コンポーネントごとにdirectivespipesでの指定を行う必要がなくなり、stableでこの方法は廃止になるとのこと。現状、このサンプルにおいてはRC3のコードのまま動作するしwarningもでなかった。

詳細は以下で確認すると良さそう。

ng2-info.github.io

ng2-info.github.io

NgModuleの導入

まずは@NgModuleを使用してモジュールを作ることになる。前回コンポーネントごとに記述していたディレクティブはdeclarationsに記述することになる。これによりモジュール配下のコンポーネントにおいて、ここで記述したディレクティブが使用できるようになる。サンプルでは未使用だがパイプなどもここにまとめて記述することになる。

providerにはDIプロバイダ、bootstrapにはエントリポイントとなるコンポーネントを指定する。

  • app/main.ts
import { NgModule, ApplicationRef } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
 
import { CommentList } from '../components/comment-list';
import { CommentForm } from '../components/comment-form';
import { CommentItem } from '../components/comment-item';
 
import { HTTP_PROVIDERS } from '@angular/http';
import { CommentBox } from '../components/comment-box';

@NgModule({
   declarations: [
     CommentBox,
     CommentList,
     CommentForm,
     CommentItem,
   ],
   providers: [
     HTTP_PROVIDERS,
   ],
   imports: [BrowserModule],
   bootstrap: [CommentBox]
 })
 class AppModule {
 }
 
 platformBrowserDynamic().bootstrapModule(AppModule);

あとは各コンポーネントのdirectivesを削除し、動作した。 前回のRC3からRC5への差分は以下。

github.com

ステート管理の見直し

前回のサンプルを作成したときには、containerにステートを持たせて管理する方法をとっていた。ただ、ReactReduxなどを使っていて、コンポーネントに状態を閉じた場合、後々いい結果にならないケースが多い、印象があるので模索していたところ以下の記事を見つけたので今回のサンプルに反映してみることにした。特にコンポーネント数が多く、コンポーネントのステートが様々なコンポーネントに影響するような構成の場合、アプリケーションのステートを局所化する手段はあったほうがいいと思う。

vsavkin.com

概要

基本的にはReduxライクな構成でイミュータブルなステートをアクション経由で変更するというもの。そこにRxJS(というよりObservableか)を使用することでaction発行毎にreducerが走る問題などを解決しつつ、RxJSの恩恵を受ければ状態管理シンプルにできるよ。という趣旨に見える。

Actions

アクションを用意する。ひとまずpayloadにデータを放り込んでる。

  • actions/action
import { Comment } from '../interfaces/comment';

export class AddCommentAction {
  public payload: {
    id: number;
    text: string;
    author: string;
  }

  constructor(comment: Comment) {
    this.payload = comment;
  }
}

export class FetchCommentAction {
  public payload: {
    comments: Comment[];
  }

  constructor(comments: Comment[]) {
    this.payload = { comments };
  }
}

export type Action = AddCommentAction | FetchCommentAction;

Observable

ActionをもらってアプリケーションステートのObservableを返すステート関数を用意する。 Reduxでのreducerに相当する関数。ただし、reducerと違いこの関数自体は一度しか呼ばれないことを利点として上げている。 これによりすべてのReducerが呼ばれたり、Selectorが呼ばれたりしてパフォーマンスが落ちることを避けられるというかな?

  • store/comments.ts
import { AddCommentAction, FetchCommentAction, Action } from '../actions/action';
import { Comment } from '../interfaces/comment';
import { Observable } from 'rxjs';

export function comments(initState: Comment[], actions: Observable<Action>): Observable<Comment[]> {
  return actions.scan((state: Comment[], action: Action) => {
    if (action instanceof AddCommentAction) {
      const { id, text, author } = action.payload;
      const newComment = { id, text, author };
      return [...state, newComment];
    } else if (action instanceof FetchCommentAction) {
      return action.payload.comments;
    } else {
      return state;
    }
  }, initState);
}

次にrootReducerに相当する各ステート関数をcombineするstateFnを用意する。このサンプルではcombineする必要はないけど、参照記事ではfilter機能などを実装しているため、複数のステート関数をcombineしている。このサンプルではあまり意味を成さないものになっているので詳細は参照記事を確認してください。

  • store/index.js
import { AddCommentAction, Action } from '../actions/action';
import { Comment } from '../interfaces/comment';
import { Observable, BehaviorSubject } from 'rxjs';
import { comments } from './comments';

export function stateFn(initState: Comment[], actions: Observable<Action>): Observable<Comment[]> {
  const appStateObs = comments(initState, actions);
  return wrapIntoBehavior(initState, appStateObs);
}

function wrapIntoBehavior(init, obs) {
  const res = new BehaviorSubject(init);
  obs.subscribe(s => res.next(s));
  return res;
}

一つ注意点があって、wrapIntoBehaviorを実行しないとコンポネーントがsubscribeしたときにemitされるまでデータが受け取れないことになる。その問題をBehaviorSubjectで解決しているとのこと。

このあたりRxJSの理解力が乏しくてアレなんだが、BehaviorSubjectの挙動としては以下のようなものらしい。

直前にonNextで渡された値を保持し、subscribe()した直後に保持していた値を流します。その後の動作は後述のPublishSubjectと同等です。

f:id:bokuweb:20160818101059p:plain

Rxで知っておくと便利なSubjectたち - Qiitaより

この辺りの挙動はテストを書いてみるとわかりやすく、参照記事と同様のものを書いてみた。 後述するが、テストまわりは不明点周りで現状かなり辛い印象をもったが、この辺りはAngularから切り離されているためテストも容易だった。

import assert = require('power-assert');
import Rx = require('rxjs');
import { stateFn } from '../store/index';
import { AddCommentAction, Action } from '../actions/action';

describe ('store test.', () => {
  it('should create a new comment', () => {
    const actions = new Rx.Subject<Action>();
    const states = stateFn([], actions);
    actions.next(new AddCommentAction({ id: 1, text: 'text', author: 'author' }));
    actions.next(new AddCommentAction({ id: 2, text: 'foo', author: 'bar' }));
    states.subscribe(s => {
      assert.equal(s.length, 2);
      assert.deepEqual(s[0], {
        id: 1, text: 'text', author: 'author',
      });
      assert.deepEqual(s[1], {
        id: 2, text: 'foo', author: 'bar',
      });
    });
  });
});

Application and View Boundary

上記のactionやstateをView側で扱う準備をする。 この辺り理解が怪しいんだが、Angular 2のDIは基本的に、 トークン に対して値をセットしており、OpaqueTokenはProviderのトークンとして使いやすいインスタンスを提供してくれると書いてある。詳細は以下を参照すると良さそう。

import { OpaqueToken } from '@angular/core';
import { Subject } from 'rxjs';
import { Action } from '../actions/action';
import { stateFn } from '../store';

export const initState = new OpaqueToken("initState");
export const dispatcher = new OpaqueToken("dispatcher");
export const state = new OpaqueToken("state");

export const stateAndDispatcher = [
  {
    provide: initState,
    useValue: [],
  }, {
    provide: dispatcher,
    useValue: new Subject()
  }, {
    provide: state,
    useFactory: stateFn,
    deps: [initState, dispatcher]
  }
];

そして、こいつを@NgModuleで登録してやる。

...
import { stateAndDispatcher } from '../store/state-and-dispatcher';

@NgModule({
  declarations: [
    CommentBox,
    CommentList,
    CommentForm,
    CommentItem,
  ],
  providers: [
    HTTP_PROVIDERS,
    stateAndDispatcher,
  ],
  ...

View

以下のようにInjectしてdispatchと、stateを使用している。

  • components/comment-box.ts
import { Component, OnInit, Inject } from '@angular/core';
import { CommentService } from '../service/comment';
import { Comment } from '../interfaces/comment';
import { Observable, Observer } from 'rxjs';
import { Action } from '../actions/action';
import { state, dispatcher } from '../store/state-and-dispatcher';
import { AddCommentAction, FetchCommentAction } from '../actions/action';

@Component({
  selector: 'comment-box',
  providers: [CommentService],
  template: `
    <div class="commentBox">
      <h1>Comments</h1>
      <comment-list [comments]="comments"></comment-list>
      <comment-form (onCommentSubmit)="handleCommentSubmit($event)"></comment-form>
    </div>
  `,
})
export class CommentBox implements OnInit {

  comments: Comment[];

  constructor(private commentService: CommentService,
              @Inject(state) private state: Observable<Comment[]>,
              @Inject(dispatcher) private dispatcher: Observer<Action>) {
    this.state.subscribe(comments => this.comments = comments);
  }

  ngOnInit() {
    this.commentService
      .startIntervalFetch()
      .subscribe(comments => this.dispatcher.next(new FetchCommentAction(comments)));
  }

  handleCommentSubmit(comment) {
    comment.id = this.comments.length;
    this.commentService
      .add(comment)
      .subscribe(res => this.dispatcher.next(new AddCommentAction(comment)));
  }
}

書いてて思ったけど、このコンポーネントはSmartComponentとしての責務に専念して、templateへの記述は最小限に抑えるべきな気がする。 あとこういった構成の場合複雑な非同期処理が必要となった場合(API設計が悪いとか、BFFが、とかはひとまず置いておいて)、シンプルに対応できるかというのが頭をよぎるんだが、この場合サービス側でごにょごにょやる感じになるのかな。

これはAngularではなくRxJS力の低さの問題なんだけど、非同期処理をチェーンさせる(たとえばユーザーリストをフェッチ後、ユーザidからお友達リストを取ってここなくてはならない、など)場合、どう書くのがシンプルなのかわからない。

テスト

mocha + power-assertでテストが書けるとのことなので一番シンプルなコンポーネントで試してみた。 とは言え、現状不明点が多く、「こうしたらなんか動いた」的な状態に近い。

テスト対象

  • components/comment-list.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'comment-list',
  template: `
    <div class="comment-list">
      <div *ngFor="let comment of comments">
        <comment-item [author]="comment.author" [text]="comment.text"></comment-item>
      </div>
    </div>
    `
})

export class CommentList {
  @Input() comments;
}

テスト

  • test/comment-list.test.ts
import 'es6-shim';
import 'reflect-metadata';
import 'zone.js/dist/zone';
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/async-test';
import 'rxjs';
import { TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import assert = require('power-assert');
import { CommentList } from '../components/comment-list';
import { CommentItem } from '../components/comment-item';
import { Comment } from '../interfaces/comment';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(
    BrowserDynamicTestingModule,
    platformBrowserDynamicTesting()
);

describe ('comment-list test.', () => {
  @Component({
    template: `
      <comment-list [comments]="comments"></comment-list>
    `,
  })
  class TestCmp {
    comments: Comment[] = [
      { id: 0, text: 'text1', author: 'author1' },
      { id: 1, text: 'text2', author: 'author2' }
    ];
  }

  beforeEach(() => {
    // setup
    TestBed.configureTestingModule({
      declarations: [TestCmp, CommentList, CommentItem]
    });
  });

  it('component test', (done) => {
    // compile test component
    TestBed.compileComponents().then(() => {
      // create component instance
      const fixture = TestBed.createComponent(TestCmp);
      fixture.detectChanges();
      const el = fixture.debugElement.nativeElement as HTMLElement;
      const authors = el.querySelectorAll('comment-item h2.comment-author');
      const text = el.querySelectorAll('span');
      assert.equal(el.querySelectorAll('comment-item').length, 2);
      assert.equal(authors.length, 2);
      assert.equal(authors[0].textContent.trim(), 'author1');
      assert.equal(text.length, 2);
      assert.equal(text[0].textContent.trim(), 'text1');
      assert.equal(text[1].textContent.trim(), 'text2');
      done();
    });
  });
});

以下にも記述があるが、ユニットテスト用のモジュールを管理するTestBed APIが追加されたらしく、こいつを使ってテスト用のコンポーネントを作成したりしてテストしていくことになりそう。

ng2-info.github.io

最初はTestBed.createComponent(CommentList)として、createComponentに直接テスト対象のコンポーネントを追加していたんだけど、この場合@Inputをどのように解決するのかわからず、上位コンポーネントとしてTestCmpを作成し、こいつを渡すことにした。以下のモジュールのテストを参考にしていて、これならspystubなどを仕込むのも簡単にできそう。

github.com

また、以下の記事もユニットテストやマーブル記法のテストまで触れてあって参考になった。

qiita.com

beforeEachでは TestBed.configureTestingModule({declarations: [TestCmp, CommentList, CommentItem]});としてテスト用のモジュール設定を行っている。これは@NgModuleに相当するところのようで、使用するディレクティブなどはここで設定しておく必要がある。(子や孫なども含めて)。

    TestBed.compileComponents().then(() => {
      // create component instance
      const fixture = TestBed.createComponent(TestCmp);
      fixture.detectChanges();
      const el = fixture.debugElement.nativeElement as HTMLElement;

あとは上記のようにすることでレンダリング後のHTMLElementがもらえるので、querySelectorで検索してテストしてみた。

ここまでやって思ったのはShallowRenderingまたはそれに相当するものが欲しい。あるのかどうかわからないが。 そうしないとすべてのコンポーネントが展開されてしまい、不味いことになる。

すでにShallowRenderingのようなものがあって見つけられていないのか、それとも、TestBed.configureTestingModuleのdeclarationsにモックを登録したりTestBed.overrideComponentを上手く使ってやりくりしたりするのかよく分かっていない。上記の記事にも書いてあるけど、とにかく今は情報が少なくて辛い。

また、今回は雑にquerySelectorを使っているけど、Enzyme的なView用テストユーティリティがあると良さそう。すでにあるのかもしれないけど・・。

追記

ここにテストについて結構書いてある

habrahabr.ru

モダンCSSフレームワーク『Bulma』のReactコンポーネントセット『ReBulma』を作った

React.js

概要

今年に入ってからのんびりTwitterClient(https://github.com/bokuweb/tsukiakari)を作っていて、こいつに使いたいなーと思いコンポーネントセット作った。といっても、まだかなり雑でまだまだやることがありそう。

以下のような記事もあって様々なReactコンポーネントセットがあるんだが、あまり気にいるものがなく作ってみることにした。有名ところはMateri-UIあたりになるんだろうけど、正直自分はMaterialデザインの色とか見た目があまり好きじゃないし、Material-UIの挙動が?(現在は改善されてるかも)だったり、Styleのみを閉じたシンプルなものが欲しいというのも理由のひとつになっている。

qiita.com

Bulma

BulmaFlexboxベースのモダンCSSフレームワークで現在V0.1.0RC。どんな感じなのかは以下の公式を眺めるとよい。先日もGithub Trendに入っていたので注目度は高いっぽい。

bulma.io

紹介記事もいっぱいでてくるし、以前作った(http://bokuweb.github.io/slack-list-ja/)でも試しに使っている。

coliss.com

作ったもの

作ったものは以下。

github.com

コンポーネントは以下から確認できます。サンプルコードは直接編集できるので、適当にいじってみてください。

re-bulma styleguide

一例を載せると以下のような感じ。

f:id:bokuweb:20160729112449p:plain

気をつけた点など

グローバルスコープを汚染しないこと

自分はもともとこういったCSSフレームワークが好きじゃなくて、大きな理由のひとつにグローバルスコープの汚染が上げられと思う。 これはどのCSSフレームワークも大きな差はないと思うけど、例えばBulmaの場合も以下のような記述があって、コンポーネント外にもスタイルは染みだしてしまう。

body {
  color: #69707a;
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.428571428571429;
}

a {
  color: #1fc8db;
  cursor: pointer;
  text-decoration: none;
  -webkit-transition: none 86ms ease-out;
  transition: none 86ms ease-out;
}

a:hover {
  color: #222324;
}

なので、今回はこういったグローバルなスタイルを削除し、コンポーネントに閉じつつ見た目を保っている(つもり。だけど、やはり多少の差異は避けられないような気もしている。例えばfont-size: inheritが設定されたコンポーネントなどは、グローバルにfont-sizeを指定していないのでBulmaの見本とくらべて大きくなっていると思う。)。これで、コンポーネントのスタイルがコンポーネント外のスタイルに影響を与えることはひとまず、無くなっていると思う。

擬似スコープ

csjs

擬似スコープを作成するのには、今回はcsjsを使ってみることにした。こういったことを行うツールで有名所はCSS Modulesなんだろうけど、自分が食わず嫌いの天邪鬼で(CSS Modulesを使った際に外部にコンポーネントとして提供する手順がぱっと見よくわからなかったのもあるが)今回はcsjsを使った。

github.com

csjsはテンプレートストリングを使って以下のように使用する。

const styles = csjs`
  .title {
    font-size: 15px;
  }
`;

class Foo extends Component {
  render() {
    return (
      <h1 className={styles.title}>Bar</h1>
    );
  }
}

すると実際には以下のようにclassNamesuffixが付加され擬似スコープ化されるというもの。あとはcsjsを経由して得られるcssを何らかの方法でinjectする必要がある。(後述するが今回はsubstack/insert-cssで内の放り込んでいる。)

  render() {
    return (
      <h1 className="title_3YGtO7">Bar</h1>
    );
  }
問題点

上記のようにcsjsでランダムなsuffixをつけて、スタイルを上書きしたい場合は上位からclassNameを渡して設定させようとしていたんだけど、これだと詳細度の関係で思うようにスタイルの上書きができなかったり、入れ子になっているコンポーネントまでclassNameが渡せず、細かいカスタマイズができない、という話しがあって結局現在はcsjsを介して固定(___re-bulma_)のPrefixをつけるようにした。擬似スコープを作るという意味ではこのへんでも十分かもしれない。

npm iのみで済ませたい

これは些細な内容なんだけど、自分は最高に怠惰なのでnpm iした後に、別途<head />にcssを仕込むの嫌いで、上でも少し触れたようにsubstack/insert-csshead内に放り込んでいる。それだけのためにinsert-cssに依存してruntimeで挿入するのか?お前のトレードオフは間違っている。と言われたら「ですよねー」という感情しかわかない気もするけど、みんなコンポーネントのcssを別途、<head />にしこむことについてどんな印象を持っているんだろうか。

課題

容量

現在スタイル関係のファイルで百数十KBあって、それにコンポーネント(各数KB)あるのでかなりきつい。そもそもこうやって作ってもたものの本当に使うのコンポーネントって、ほんの一部でほとんどが無駄だと思う(Progressとか出番がイメージできない)。

なので課題としてlodashのようにコンポーネントの単位でインストールできるようにすれば今後使いやすいかもしれないと思ったりもしている。npm i re-bulma.button のようにインストールするイメージ。

そもそも、じゃあこういったUIキットみたいなものをプロダクションで自分が採用するかといったら、使用するケースは少ないと思うが、<Button />、とか<Input />程度の粒度で公開されていれば、使用できるケースも増えるかもしれない。

CSSのリファクタリング

今はcsjsの制約があって、本家のcssからグローバルな定義を解決しつつ機械的に移植したためcssで書いていて、散らかっているんだけど、リファクタリングしながらstylusで書いてもいいかもと思っている。

蛇足

名前は素直にreact-bulmaとかでいいかとおもったけど、こういったものをみんな作ろうと思うのか何なのかわからないけどreact-bulmabulma-reactも抑えられていて、re-bulmaにした。先に名前だけ抑えて何も作らないのは勘弁して欲しい。その過程でyamchaというのも発見した。ロゴはsvg。

github.com

さいごに

スローペースかもしれないけど、ぼちぼち改善していくのでよければ試してみてください。さくっとモックみたいなのを作るケースには役だつかも。

github.com

golang入門するためにlifegame書いてみた

golang

タイトルとおりなんだけど、golang使ってみたいと思うケースが出てきて、入門のためまずはlifegameを書いてみた。

ルール

ライフゲーム - Wikipedia

結果

github.com

f:id:bokuweb:20160713185814g:plain

16/7/16修正しました

package main

import (
    "fmt"
    "math/rand"
    "time"
)

type game struct {
    field  [][]bool
    row    int
    column int
}

func newGame(row, column int) *game {
    rand.Seed(time.Now().UnixNano())
    p := new(game)
    p.row = row
    p.column = column
    p.field = make([][]bool, row)

    for r := 0; r < row; r++ {
        p.field[r] = make([]bool, column)
        for c := 0; c < column; c++ {
            if rand.Intn(10) == 0 {
                p.field[r][c] = true
            }
        }
    }
    return p
}

func (p *game) count(r, c int) int {
    if r < 0 || p.row <= r {
        return 0
    }
    if c < 0 || p.column <= c {
        return 0
    }
    if p.field[r][c] {
        return 1
    }
    return 0
}

func (p *game) updateCell(r, c int) bool {
    count := p.count(r-1, c-1) +
        p.count(r-1, c) +
        p.count(r-1, c+1) +
        p.count(r, c-1) +
        p.count(r, c+1) +
        p.count(r+1, c-1) +
        p.count(r+1, c) +
        p.count(r+1, c+1)

    if count == 2 {
        return p.field[r][c]
    } else if count == 3 {
        return true
    } else {
        return false
    }
}

func (p *game) render() {
    fmt.Print("\033[0;0H")
    for r := 0; r < p.row; r++ {
        for c := 0; c < p.column; c++ {
            cell := " "
            if p.field[r][c] {
                cell = "█"
            }
            fmt.Print(cell)
        }
        fmt.Println()
    }
}

func (p *game) update() {
    field := make([][]bool, p.row)
    for r := 0; r < p.row; r++ {
        field[r] = make([]bool, p.column)
        for c := 0; c < p.column; c++ {
            field[r][c] = p.updateCell(r, c)
        }
    }
    p.field = field
}

func main() {
    game := newGame(30, 90)
    game.render()
    ticker := time.Tick(time.Second)
    for {
        <-ticker
        game.update()
        game.render()
    }
}

その他

ひとまず、以下の記事を一通りやってみた。分かりやすかった。

blog.amedama.jp

普段なら3項演算子で書くところも、諦めて愚直にifで書く必要があるらしい。

qiita.com

テスト書いてないけど、プライベートな関数をテストしたかったら以下の記事のようにするんだろうか? httpパッケージなんかを見るとexport_test.goみたいなファイルを作ってexportしてるように見える。

medium.com

lodashを移植したら勉強になるのかと思ったりしたけど、そういったライブラリの需要はあるんだろうか?それともすでに同様のライブラリあるんだろうか。

追記

ご指摘いただきました。ありがとうございます。認識していなかったです。

qiita.com

Angular2でReactのチュートリアルを試してみる

Angular2

概要

一回触ってみたいと思っていたAngular2をようやく触ってみた。最近は新しいフレームワークやライブラリを触る場合はゲームを作ってみるか、Reactのチュートリアルをやるようにしていて、今回はReactのチュートリアル(コメントフォームのやつ)をAngular2でやってみることにした。

基本的には環境構築周りは以下のシェルスクリプトマガジンとAngularのSlackチームng-japanのビギナー用に紹介されていた手順を踏んでいる。

シェルスクリプトマガジン vol.37

シェルスクリプトマガジン vol.37

  • 作者: 當仲寛哲,岡田健,佐川夫美雄,大岩元,松浦智之,後藤大地,白羽玲子,水間丈博,濱口誠一,すずきひろのぶ,花川直己,しょっさん,法林浩之,熊野憲辰,桑原滝弥,USP研究所,ジーズバンク
  • 出版社/メーカー: USP研究所
  • 発売日: 2016/04/25
  • メディア: 雑誌
  • この商品を含むブログを見る

Join ng-japan on Slack!

なにか間違いやより良い方法などありましたらご指摘願います。

バージョン

package.jsonも最後にのせるが

Angular: 2.0.0-rc.3 Typescript: 1.8.10

で試している。

Repository

github.com

作業ログ

インストール

Angular2関連のパッケージは@angular/xxxxxという形式になっているよう。すこし前まではangular2/xxxxxだったのかな。 一点注意が必要なのはRx.jsのバージョンが上がって、symbol-observableが必要になった点でしょうか。その他はほぼ、slackで紹介されていた手順だと思う。

stackoverflow.com

$ npm init -y

$ npm i -S es6-shim systemjs symbol-observable zone.js reflect-metadata rxjs @angular/common @angular/compiler @angular/core @angular/platform-browser @angular/platform-browser-dynamic

$ npm i -D concurrently lite-server typescript typings

npm scriptの記述。

  • package.json
"scripts": {
  "start": "concurrently \"npm run tsc:w\" \"npm run lite\" ",
  "tsc": "tsc",
  "tsc:w": "tsc -w",
  "lite": "lite-server",
  "typings": "typings",
  "postinstall": "typings install"
},

typescriptの設定。

  • tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "system",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": false,
    "noImplicitAny": false
  },
  "exclude": [
    "node_modules",
    "typings/main",
    "typings/main.d.ts"
  ]
}

型情報のインストール

$ node_modules/.bin/typings init
node_modules/.bin/typings install -GS es6-shim jasmine --source dt
touch index.html
mkdir scripts
touch scripts/main.ts
mkdir -p components/home
touch components/home/home.ts
touch system-config.ts
touch bs-config.json
  • bs-config.json
{
  "port": 8000,
  "files": ["./**/*.{html,htm,css,js}"],
  "server": { "baseDir": "./" }
}

system.jsの設定。この辺りよく作法を理解していないので要復習。

  • system-config.js
System.config({
  map: {
    '@angular': 'node_modules/@angular',
    'rxjs': 'node_modules/rxjs',
    'scripts/main': 'app/main.js',
    'symbol-observable': 'node_modules/symbol-observable',
  },
  packages: {
    '@angular/core':  { main: 'index' },
    '@angular/common':  { main: 'index' },
    '@angular/compiler':  { main: 'index' },
    '@angular/http':  { main: 'index' },
    '@angular/router':  { main: 'index' },
    '@angular/platform-browser':  { main: 'index' },
    '@angular/platform-browser-dynamic':  { main: 'index' },
    'symbol-observable': { defaultExtension: 'js', main: 'index' },
    'rxjs':  { main: 'Rx' },
    'components':  { main: 'index' }
  }
});
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <base href="/">
        <title>ng2 comment system</title>
    </head>
    <body>
        <comment-box>Loading...</comment-box>
        <script src="node_modules/es6-shim/es6-shim.js"></script>
        <script src="node_modules/reflect-metadata/Reflect.js"></script>
        <script src="node_modules/systemjs/dist/system.src.js"></script>
        <script src="node_modules/zone.js/dist/zone.js"></script>
        <script>
         System.import('system-config.js')
               .then(function () {
                   System.import('app/main');
               })
               .catch(console.error.bind(console));
        </script>
    </body>
</html>
  • app/main.ts
import {bootstrap} from '@angular/platform-browser-dynamic'
import {AppComponent} from '../components/home/home'

bootstrap(AppComponent);
  • components/home.ts
import {Component} from '@angular/core'
@Component({
  selector: 'my-app',
  template: `
    <h1>Hello ng2</h1>
    `
})
export class AppComponent {
}

これでnpm startするとブラウザが開いて、Hello ng2 と表示されるはず。

Commentボックスの実装

app/main.tsから呼ぶのをcomponents/comment-boxに修正。

  • scripts/main.ts
import { bootstrap } from '@angular/platform-browser-dynamic';
import { CommentBox } from '../components/comment-box';

bootstrap(CommentBox);

index.htmlから呼ぶコンポーネント名を修正

<ng-app>を<comment-box>に変更

 <body>
   <comment-box>Loading...</comment-box>
          ...

components/home.tscomponents/comment-box.txに修正して、以下のように修正した。

comment-box

  • components/comment-box.ts
import { Component, OnInit } from '@angular/core';
import { CommentList } from './comment-list';
import { CommentForm } from './comment-form';
import { CommentService } from '../service/comment';
import Comment from '../interfaces/comment';

@Component({
  selector: 'comment-box',
  providers: [CommentService],
  directives: [CommentList, CommentForm],
  template: `
    <div class="commentBox">
      <h1>Comments</h1>
      <comment-list [comments]="comments"></comment-list>
      <comment-form (onCommentSubmit)="handleCommentSubmit($event)"></comment-form>
    </div>
  `,
})
export class CommentBox implements OnInit {

  comments: Comment[];

  constructor(private commentService: CommentService) {
  }

  ngOnInit() {
    this.commentService
      .startIntervalFetch()
      .subscribe(comments => this.comments = comments);
  }

  handleCommentSubmit(comment) {
    comment.id = this.comments.length;
    this.commentService
      .add(comment)
      .subscribe(res => this.comments.push(res));
  }
}

こうなるまで結構右往左往したんだけど、ひとまず結果だけを載せておく。 また、ポイントとなりそうな箇所を以下に記載しておく。

コンポーネント

Angular2ではコンポーネントはDecoratorを使用して以下のような形で記述するらしい。

@Component({
  selector: 'comment-box',
  providers: [CommentService],
  directives: [CommentList, CommentForm],
  template: `
    <div class="commentBox">
      <h1>Comments</h1>
      <comment-list [comments]="comments"></comment-list>
      <comment-form (onCommentSubmit)="handleCommentSubmit($event)"></comment-form>
    </div>
  `,
})
export class CommentBox implements OnInit {
 ...省略
selector

他のコンポーネントからこのコンポーネントを呼ぶ場合はこの名前を使って呼ぶ。上記の場合であれば<comment-box></comment-box>とする。(<comment-box />とは書けないよう。)また、カスタムディレクティブの場合は使用するディレクティブを次のdirectivesで指定する必要があるっぽい。

directives

使用するカスタムディレクティブをここで指定する。この場合はCommentList, CommentFormというディレクティブを使用する。

providers

Injectするサービスを指定する。Viewに関心のない処理などはサービスとして切り出してInjectするらしい。このコメントシステムであればサーバとAjax通信する処理をサービス(CommentService)として切り出して、Injectしている。Injectされる側(CommentService)は後述する。

templete

テンプレートストリングを使用して、以下のように書く。

  template: `
    <div>Hello</div>
  `

また、templateUrlを使って次のように別ファイルのhtmlを使用するこもできるっぽい。

templateUrl: 'foo/bar.html'

どっちが主流なのだろうか。デザイナーとの協業であれば後者のほうがやりやすいだろうし、チームの体制に左右されるだろうか。テンプレートストリングだとエディタのシンタックスハイライトとかインデントが死んでてみんなどうしているのって感じ。

class

デコレートされる側のclassは以下のようになっている。

export class CommentBox implements OnInit {

  comments: Comment[];

  constructor(private commentService: CommentService) {
  }

  ngOnInit() {
    this.commentService
      .startIntervalFetch()
      .subscribe(comments => this.comments = comments);
  }

  handleCommentSubmit(comment) {
    comment.id = this.comments.length;
    this.commentService
      .add(comment)
      .subscribe(res => this.comments.push(res));
  }
}

OnInitってのを継承していて、これはLifecycle Hooksを使うためのもの。今回はReactcomponentWillMount相当する(?あってる?)タイミングでサーバへリクエストを投げたかったので、OnInitを使用している。以下によくまとまっていた。

blog.yuhiisk.com

constructorでは後述するcommentService(主にサーバとのAjax通信するサービス)を受け取って、ngOnInitでポーリングする処理を呼んでいる。handleCommentSubmitでは子コンポーネントのcomment-formからのデータを受け取りcommentServiceに渡すことで、コメントを投稿している。startIntervalFetch()add()ともにobservableが返ってくるのでsubscribeしてcommentsを書き換えたり、追加したりしている。

comment-list

基本的にはcomment-boxでの知識があればほぼほぼ書けた。

  • components/comment-list.ts
import { Component, Input } from '@angular/core';
import { CommentItem } from './comment-item';

@Component({
  selector: 'comment-list',
  directives: [CommentItem],
  template: `
    <div class="comment-list">
      <div *ngFor="let comment of comments">
        <comment-item [author]="comment.author" [text]="comment.text"></comment-item>
      </div>
    </div>
    `
})

export class CommentList {
  @Input() comments;
}

新しいポイントは以下。

@Input()

親コンポーネントからディレクティブに対する入力は@input()で定義する必要がある。

*ngFor

みたまんま繰り返しを行う記述でstructural directivesと呼ぶらしい。他には、*ngIf="condition"等が用意されている。ちょっと前まではletではなく#と書いていたようで、#を使うと現在では動作はするが、#" inside of expressions is deprecated. Use "let" instead!という警告がでる。

ここではcommentsの要素数分繰り返し<comment-item></comment-item>に渡している。

詳細は以下を見ると良さそう。

angular.io

comment-form

フォーム部分については以下のようになった。

  • components/comment-form.ts
import { Component, Output, EventEmitter } from '@angular/core';
import { CommentList } from './comment-list';
import { CommentService } from '../service/comment';
import Comment from '../interfaces/comment';

@Component({
  selector: 'comment-form',
  template: `
    <div class="comment-form">
      <input type="text" value={{author}} (keyup)="onAuthorChange($event)" placeholder="Your name" />
      <input type="text" value={{text}} (keyup)="onTextChange($event)" placeholder="Say something..." />
      <input type="submit" value="Post" (click)="handleSubmit()" />
    </div>
    `,
  styles: [`
    .comment-form {
      margin-top: 50px;
    }
  `]
})
export class CommentForm {
  @Output() onCommentSubmit: EventEmitter<any> = new EventEmitter();

  public author: string
  public text: string

  onAuthorChange(e: KeyboardEvent): void {
    this.author = (<HTMLInputElement>event.target).value;
  }

  onTextChange(e: KeyboardEvent): void {
    this.text = (<HTMLInputElement>event.target).value;
  }

  handleSubmit(): void {
    const author = this.author.trim();
    const text = this.text.trim();
    if (!text || !author) return;
    this.onCommentSubmit.emit({ author, text });
    this.text = '';
    this.author = '';
  }
}

新しい要素は以下。

styles

コンポーネントのスタイルはstyleで指定できる。

  styles: [`
    .comment-form {
      margin-top: 50px;
      }
  `]

Angular2Scoped CSSを実現しているという話しをちらっと聞いていたんだけどどのように実現しているか見たら以下のようになっていた。

スコープはどのように実現しているか確認すると以下のようになっていた。attributeを使用して擬似的にスコープを実現しているように見える。

  • html
<comment-form _nghost-dkv-3="">
    <div _ngcontent-dkv-3="" class="comment-form">
      <input _ngcontent-dkv-3="" placeholder="Your name" type="text" ng-reflect-value="">
      <input _ngcontent-dkv-3="" placeholder="Say something..." type="text" ng-reflect-value="">
      <input _ngcontent-dkv-3="" type="submit" value="Post">
    </div>
</comment-form>
  • css
.comment-form[_ngcontent-dkv-3] {
    margin-top: 50px;
}

またstyleUrlsによりcssファイルを使用することもできるっぽい。

styleUrls: ['app/style.css'],

バインディング

<input type="text" value={{author}} (keyup)="onAuthorChange($event)" placeholder="Your name" />のようにしてkeyupイベントで値をセットし、反映している。この辺りは以下のように書くことで1way2wayか選択できるっぽい。

  • 2way binding
<input type="text" [(ngModel)]=”author” placeholder=”Your name” />
  • 1way binding
<input type="text" value={{author}} placeholder="Your name" />

また、changeだとハンドラが呼ばれるのはblurのタイミングっぽくて、ReactでいうonChangeのタイミングで呼びたかったらkeyupイベントを使うのが良さそうに見える。

angular.io

@output

@input()があったように出力は@output()を使用するっぽい。ここではフォームの内容をEventEmitterを使って<comment-box></comment-box>まで引き上げている。多分、<comment-form></comment-form>に直接CommentServiceInjectすることもできるんだろうけど、Dumb ComponentSmat Componentを意識するとこういうことになるんじゃないかと思う。

comment-item

特筆すべき箇所はないが、この時点でmarkedを追加した。 あと、タグを使用する場合は[innerHTML]を使用するっぽい。

  • components/comment-item.ts
import { Component, Input } from '@angular/core';
import { parse } from 'marked';

@Component({
  selector: 'comment-item',
  template: `
    <div class="comment">
      <h2 class="comment-author">
        {{author}}
      </h2>
      <span [innerHTML]="rawMarkup()"></span>
    </div>
  `
})

export class CommentItem {
  @Input() author: string
  @Input() text: string

  rawMarkup():string {
    return parse(this.text, { sanitize: true });
  }

markedの追加

以下の手順で行った。

npm i -S marked
node_modules/.bin/typings -GS install marked --source dt

system-config.jsに追加

System.config({
  map: {
    ...省略
    'marked': 'node_modules/marked',
  },
  packages: {
    ...省略
    'marked': { main: 'index' },
  }

comment-service

CommentServiceに使用するHttpモジュールについては以下を参考にした。

qiita.com

angular.io

  • service/comment.ts
import { Injectable } from '@angular/core';
import Comment from '../interfaces/comment';
import { Http, Request, Response } from '@angular/http';
import { Observable } from 'rxjs';
import 'rxjs/add/operator/map';

@Injectable()
export class CommentService {

  constructor(private http: Http) {
  }

  startIntervalFetch(): Observable<Comment[]> {
    return Observable.interval(1000)
      .flatMap(() => this.http.get("http://localhost:3001/comments"))
      .map(res => res.json() as Comment[]);
  }

  add(comment: Comment): Observable<Comment> {
    return this.http
      .post("http://localhost:3001/comments", comment)
      .map(res => res.json() as Comment);
  }
}

startIntervalFetch()では1秒ごとにサーバにコメントを取りに行って、add()でコメントを追加している。 この時点でjson-serverをインストールしてやり取りはそっちと行っている。

@Injectable()

@Injectable()DIを可能にする。

使う側(components/comment-box.ts)は以下のようになる。

@Component({
  providers: [CommentService],
})
export class CommentForm {
  constructor(private commentService: CommentService) {
  }

package.json

package.jsonは最終的に以下。

{
  "name": "ng2-comment-tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "concurrently \"npm run tsc:w\" \"npm run lite\" \"npm run json\"",
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "lite": "lite-server",
    "typings": "typings",
    "json": "json-server --watch db.json --port 3001",
    "postinstall": "typings install"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@angular/common": "^2.0.0-rc.3",
    "@angular/compiler": "^2.0.0-rc.3",
    "@angular/core": "^2.0.0-rc.3",
    "@angular/http": "^2.0.0-rc.3",
    "@angular/platform-browser": "^2.0.0-rc.3",
    "@angular/platform-browser-dynamic": "^2.0.0-rc.3",
    "es6-shim": "^0.35.1",
    "marked": "^0.3.5",
    "reflect-metadata": "^0.1.3",
    "rxjs": "^5.0.0-beta.9",
    "symbol-observable": "^1.0.1",
    "systemjs": "^0.19.31",
    "zone.js": "^0.6.12"
  },
  "devDependencies": {
    "concurrently": "^2.1.0",
    "json-server": "^0.8.14",
    "lite-server": "^2.2.0",
    "typescript": "^1.8.10",
    "typings": "^1.3.0"
  }
}

 まとめ

  • 1.xのときよりシンプルになったような印象を受ける
  • 二の足を踏んでいたけど、導入障壁も高くないように感じた
  • ただ、逐一追っているわけではないがまだ破壊的な変更が入っているような話しを耳にする
    • 次に触るのはもう少し安定してからでいいかな、と感じた
    • その際はなにか独立したコンポーネント、例えばreact-resizable-and-movableあたりを移植してみたい
  • Singleton store脳な自分には状態を一元管理するような方法があるとありがたい
    • ng-reduxとか?あまりいい評判は聞かない。。。
  • テスト周りがわからん。rc.4からjasmineに限らずmochaも使用できるようになった?

flowtypeを試してみる

flowtype javascript React.js

最初に

この記事はflowtype導入の手順紹介というより、自分の作業ログに近いものです。flowtypeって何?ってところも含めて以下に紹介する記事を見たほうがわかりやすいと思いますので、参照してください。

今回試すにあたって、参考にした記事。

qiita.com

qiita.com

joe-re.hatenablog.com

qiita.com

動機

自分の観測範囲内で「flowtypeいいよ!」って話しをよく聴くようになり、試してみることにした。 自分の場合はだが、主な動機としてはReactのpropTypes頑張って書く割に得られる恩恵少ないというのがあってflowtypeであれば、それを改善しつつ、部分的に適用することができる。

新規プロジェクトで あればTypescriptを採用するなどの選択肢を取ることが可能であるが、既存プロジェクトの場合はそうはいかない。だけど、flowtypeであれば既存のプロジェクトにも型の恩恵を付加することができる。

また上でも紹介している以下の記事内にFLOW SOUNDNESS, NO RUNTIME EXCEPTIONS AS GOALとあって、これは導入しない手はないんじゃないか?と思い今に至る。実行時例外のない世界に住んでみたい。

joe-re.hatenablog.com

作業ログ

導入

以下で入る。

npm i -D flow-bin babel-plugin-transform-flow-strip-types

現状のバージョンがflow-bin@0.27.0babel-plugin-transform-flow-strip-types@6.8.0flow-binが本体で、babel-plugin-transform-flow-strip-typesがトランスパイル時にflow type annotationを除去してくれるもの。

package.jsonにflowを追加する。

  • package.json
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "flow": "flow",
  "watch": "watchify --extension=js -o public/dist/bundle.js public/src/index.js",
  "start": "node server.js & npm run watch"
},

.babelrcにtransform pluginを追加する。

  • .babelrc
{
  "presets": ["react", "es2015", "stage-1"],
  "plugins": ["babel-plugin-transform-flow-strip-types"]
}

.flowconfigを用意する

今後必要な設定は.flowconfigに追記していく。 ひとまずは以下のような感じ。node_modules以下対象外としておく。

  • .flowconfig
[ignore]
.*/node_modules/.*

[include]

[libs]

[options]

ひとまずの導入はこんなところ。早速動作させてみる。

動作テスト

公式の例となっている以下のようなファイルを用意。

  • test.js
// @flow
function foo(x) {
  return x * 10;
}
foo('Hello, world!');

チェックをかけてみる

$ npm run flow

test.js:5
  5: foo('Hello, world!');
     ^^^^^^^^^^^^^^^^^^^^ function call
  3:   return x * 10;
              ^ string. This type is incompatible with
  3:   return x * 10;
              ^^^^^^ number

Found 1 error

良さそう。

サンプルへの適用

以前Reactのチュートリアル(コメントボックス)にReduxを使ったサンプルを作ってみたことがあるんだけど、そのプロジェクトに対し部分的にflowtypeの適用を試みてみる。その成果物となるものは以下。

github.com

コメント送信部への適用

まずはコメント送信部、public/src/components/comment-form.jsへの適用を試みてみる。

Comment型を定義する。この定義は他のコンポーネントなどでも使用するので別ファイルに定義し、importするものとする。

  • public/src/types.js
/* @flow */

export type Comment = {
  author: string;
  text: string;
};

以下のように使用する。

  • public/src/components/comment-form.js
/* @flow */

import React, { Component, PropTypes } from 'react';
import type { Comment } from '../types';

... 省略 ...
  handleSubmit(e) {
    e.preventDefault();
    const author = this.author.value.trim();
    const text = this.text.value.trim();
    if (!text || !author) return;
    const comment: Comment = { author, text };
    this.props.onCommentSubmit(comment);
    this.author.value = '';
    this.text.value = '';
  }

この時点でチェックをかけると(当然)めちゃめちゃに怒られる。足りない情報を埋めていき、ひとまず以下のような形になった。その際以下のものをよく参照した。(その他まとまっているものなどあれば教えていただけるとうれしいです。)

Flow type cheat sheet - SaltyCrane Blog

github.com

/* @flow */

import React, { Component, PropTypes } from 'react';
import type { Comment } from '../types';

export default class CommentForm extends Component {
  author: HTMLInputElement;
  text: HTMLInputElement;

  handleSubmit(e: Event) {
    e.preventDefault();
    const author = this.author.value.trim();
    const text = this.text.value.trim();
    if (!text || !author) return;
    const comment: Comment = { author, text };
    this.props.onCommentSubmit(comment);
    this.author.value = '';
    this.text.value = '';
  }

  render() {
    return (
      <form className="commentForm" onSubmit={this.handleSubmit.bind(this)}>
        <input type="text" placeholder="Your name" ref={c => {this.author = c;}} />
        <input type="text" placeholder="Say something..." ref={c => {this.text = c;}} />
        <input type="submit" value="Post" />
      </form>
    );
  }
}

例えば以下のように変更すると怒ってくれる。

const comment: Comment = { author, 10 };
public/src/components/comment-form.js:15
 15:     const comment: Comment = { author, 10 };
                        ^^^^^^^ property `text`. Property not found in
 15:     const comment: Comment = { author, 10 };
                                  ^^^^^^^^^^^^^^ object literal

public/src/components/comment-form.js:15
 15:     const comment: Comment = { author, 10 };
                                            ^^ non-string literal property keys not supported


Found 2 errors

良さそう。

propTypesへの適用

メインの動機でもあるpropTypesのチェックを行ってみる。 この辺りから導入してやると、費用対効果が高そうな印象を受ける。

propTypesの定義をstatic propTypes = { ... }のように記述していると、early stage feature proposalということでオプションの設定を促される。当たり前だが、これらはbabelではなくflowtype側で解決する必要がある。具体的には、.flowconfigの[options]に以下を追記してやる。

[options]
esproposal.class_static_fields=enable

comment-formは以下のように変更した。具体的にはpropsで渡されるonCommentSubmitが 関数であることを教えている。

  • public/src/components/comment-form.js
type Props = {
  onCommentSubmit: Function,
}

export default class CommentForm extends Component {
  static propTypes = {
    onCommentSubmit: PropTypes.func.isRequired,
  };

  props: Props;
  author: HTMLInputElement;
  text: HTMLInputElement;
  ...

comment-formを使う側のコンポーネントも/* @flow */を追記しチェックの対象とする。 試しにFunctionではなくNumberを渡してみる。

  • public/src/comment-box.js
  render() {
    const { comments, saveComment } = this.props;
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList comments={comments} />
        <CommentForm onCommentSubmit={1} />

チェックをかける。

public/src/components/comment-box.js:20
 20:         <CommentForm onCommentSubmit={1} />
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ React element `CommentForm`
 20:         <CommentForm onCommentSubmit={1} />
                                           ^ number. This type is incompatible with
  7:   onCommentSubmit: Function,
                        ^^^^^^^^ function type. See: public/src/components/comment-form.js:7


Found 1 error

良さそう。ここまで書くと逆にpropTypesを書くメリットはなくなるのだろうか?propTypesを拾ってくれるstyleguideのために書くくらいだろうか?

Reduxへの適用

次にRedux側にも適用してみる。外部ライブラリの型定義はflow-typedで管理するっぽい。

qiita.com

flow-typedのインストール

自分は基本的にはローカルに入れたいんだけど、今回ローカルにいれると面倒そうなのでグローバルにいれることにした。

npm install -g flow-typed
Reduxの型定義の検索、入手

flow-typed search PACAGE_NAMEで検索をできるよう。 flow-typed search Reduxとすると以下のような結果がえられる。

$ flow-typed search redux
 * flow-typed cache not found, fetching from GitHub...done.

Found definitions:
╔═══════════════╤═════════════════╤══════════════╗
║ Name          │ Package Version │ Flow Version ║
╟───────────────┼─────────────────┼──────────────╢
║ redux-actions │ v0.9.x          │ >=v0.23.x    ║
╟───────────────┼─────────────────┼──────────────╢
║ redux         │ v3.x.x          │ >=v0.23.x    ║
╟───────────────┼─────────────────┼──────────────╢
║ redux-form    │ v5.x.x          │ >=v0.22.1    ║
╚═══════════════╧═════════════════╧══════════════╝

す、すくない。redux-xxxxというパッケージの型定義ファイルが2つしか登録されていないことになる。インストールは以下のようにする。

$ flow-typed install -f v0.27.0 redux@3.0.0

これでproject配下のflow-typed/npm/に型情報が格納されると思う。

Storeの型を定義する

インストールした定義は以下のように使用できる。以下はconfigureStoreの返り値の型がStoreであることを定義している。 当然、このままチェックをかけると怒られる。具体的にはredux-thunkredux-loggerが何なのだ?という話しになる。

/* @flow */

import type { Store, Middleware } from 'redux';
import { createStore, applyMiddleware } from 'redux';
import comment from '../reducers/comment';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';

export default function configureStore(): Store {
  const logger = createLogger();
  const createStoreWithMiddleware = applyMiddleware(
    thunk, logger
  )(createStore);
  const store: Store = createStoreWithMiddleware(comment);
  return store;
}

そのような場合は .flowconfig[libs] でmodule を定義することで import 時に解決するオブジェクトの型定義を解決してくれるらしい。 具体的には以下のように記述している。

  • .flowconfig
[ignore]
.*/node_modules/.*

[include]

[libs]
decls

[options]
esproposal.class_static_fields=enable

[libs]にdeclsディレクトリを指定しておき、decls/modules.jsに以下のようにredux-thunkredux-loggerの型を定義した。

  • decls/modules.js
declare module 'redux-thunk' {
  declare var exports: Function;
}

declare module 'redux-logger' {
  declare var exports: (options?: Object) => Function;
}

この辺りも正直合っているのかわからないところもあるのだけど、declare var exports: string;にしたら怒ってくれるので機能しているようには見える。

Action / Reducerへの適用

あとはこつこつ情報を埋めていくことになる。 actionとreducerに適用してみたものは以下のようになる。 Typescriptflowもこれまで触ったことがなく、間違っている可能性もあるのでその場合はご指摘ください。

  • public/src/actions/comment.js
/* @flow */

import * as api from '../api/api';
import type { Comment } from '../types';
import type { Dispatch } from 'redux';

export const SUBMIT_COMMENT = 'SUBMIT_COMMENT';
export const RECIEVE_COMMENTS = 'RECIEVE_COMMENTS';

export type CommentActionType =
  { type: 'SUBMIT_COMMENT', comment: Comment } |
  { type: 'RECIEVE_COMMENTS', comments: Array<Comment> };


export function submitComment(comment: Comment): CommentActionType {
  return {
    type: SUBMIT_COMMENT,
    comment,
  };
}

export function recieveComments(comments: Array<Comment>): CommentActionType {
  return {
    type: RECIEVE_COMMENTS,
    comments,
  };
}

export function fetchComments() {
  return (dispatch: Dispatch) => {
    api.fetchComments('/api/comments')
      .then((comments) => {
        dispatch(recieveComments(comments));
      }).catch(error => {
        console.error(error);
      });
  };
}

export function saveComment(comment: Comment) {
  return (dispatch: Dispatch) => {
    dispatch(submitComment(comment));
    api.saveComment('/api/comments', comment)
      .then(() => {
        console.log('save comment');
      }).catch(error => {
        console.error(error);
      });
  };
}
  • public/src/reducers/comments.js
/* @flow */

import * as commentActions from '../actions/comment';
import type { CommentActionType } from '../actions/comment';
import type { Comment } from '../types';

export type CommentsState = {
  comments: Array<Comment>,
}

export default function comment(state: CommentsState = { comments: [] }, action: CommentActionType) {
  switch (action.type) {
    case commentActions.SUBMIT_COMMENT:
      return { comments: state.comments.concat([action.comment]) };
    case commentActions.RECIEVE_COMMENTS:
      return { comments: action.comments };
    default:
      return state;
  }
}

まとめ

  • 導入の障壁などなく、既存のプロジェクトにも部分的に適用することが可能なので試しやすい
    • 基本的には入れて損することはないとい印象
  • 基本的には自分が携わっている範囲では導入に対するデメリットは無いように感じる
    • 参照記事にもあるようにデメリットではなく欠点/問題点という表現が良さそうなのだが、flow-typedに対応ライブラリが少ないというのがあげられると思う。
      • ただ、面倒であればひとまず握りつぶしてしまうことも可能だし、これからどんどん増えていく可能性もあるので導入の障壁とは感じていない

ちなみに、検索エンジンの検索数で「このライブラリが人気」みたいなのは自分は好きじゃないんだけど、flowtypeをgoogle trendで調べたところ注目度は上がっているんだとは思う。

f:id:bokuweb:20160619224133p:plain

ElixirですごいE本 11章

Erlang Elixir

すごいErlangゆかいに学ぼう!

すごいErlangゆかいに学ぼう!

引き続き。

11.1 状態を述べろ

defmodule Kitchen do
  def fridge1 do
    receive do
      {{:store, food}, from} ->
        send from, {self(), :ok}
        fridge1
      {{:take, food}, from} ->
        send from, {self(), :not_found}
      :terminate -> :ok
    end
  end
end

実行する

ex(2)> c("ch11.ex")
ch11.ex:1: warning: redefining module Kitchen
ch11.ex:4: warning: variable food is unused
ch11.ex:7: warning: variable food is unused
ch11.ex:15: warning: variable food is unused
ch11.ex:18: warning: variable food is unused
[Kitchen]
iex(3)> fridge1 = spawn(Kitchen, :fridge1, [])
#PID<0.66.0>
iex(4)> send fridge1, {{:store, :apple}, self()}
{{:store, :apple}, #PID<0.57.0>}
iex(5)> flush
{#PID<0.66.0>, :ok}
:ok
iex(6)> send fridge1, {{:take, :apple}, self()}
{{:take, :apple}, #PID<0.57.0>}
iex(7)> flush
{#PID<0.66.0>, :not_found}
:ok
  • 食べ物を保存する場所がない
  • 状態を追加する必要がある
defmodule Kitchen do
  def fridge2(foodList) do
    receive do
      {{:store, food}, from} ->
        send from, {self(), :ok}
        fridge2 [food|foodList]
      {{:take, food}, from} ->
        case Enum.member? foodList, food do
          true ->
            send from, {self(), {:ok, food}}
            fridge2 List.delete foodList, food
          false ->
            send from, {self(), :not_found}
            fridge2 foodList
        end
      :terminate -> :ok
    end
  end
end
  • 状態を再帰で関数のパラメータに保持
iex(10)> c("ch11.ex")
ch11.ex:1: warning: redefining module Kitchen
ch11.ex:4: warning: variable food is unused
ch11.ex:7: warning: variable food is unused
[Kitchen]
iex(11)> pid = spawn(Kitchen, :fridge2, [[:baking_soda]])
#PID<0.80.0>
iex(12)> send pid, {{:store, :milk}, self()}
{{:store, :milk}, #PID<0.57.0>}
iex(13)> flush
{#PID<0.80.0>, :ok}
:ok
iex(14)> send pid, {{:store, :bacon}, self()}
{{:store, :bacon}, #PID<0.57.0>}
iex(15)> send pid, {{:take, :bacon}, self()}
{{:take, :bacon}, #PID<0.57.0>}
iex(16)> send pid, {{:take, :turkey}, self()}
{{:take, :turkey}, #PID<0.57.0>}
iex(17)> flush
{#PID<0.80.0>, :ok}
{#PID<0.80.0>, {:ok, :bacon}}
{#PID<0.80.0>, :not_found}
:ok

11.2 メッセージ大好きだけど秘密にしておいて

defmodule Kitchen do
  def store(pid, food) do
    send pid, {{:store, food}, self()}
    receive do
      {pid, msg} -> msg
    end
  end

  def take(pid, food) do
    send pid, {{:take, food}, self()}
    receive do
      {pid, msg} -> msg
    end
  end

  def fridge2(foodList) do
    receive do
      {{:store, food}, from} ->
        send from, {self(), :ok}
        fridge2 [food|foodList]
      {{:take, food}, from} ->
        case Enum.member? foodList, food do
          true ->
            send from, {self(), {:ok, food}}
            fridge2 List.delete foodList, food
          false ->
            send from, {self(), :not_found}
            fridge2 foodList
        end
      :terminate -> :ok
    end
  end
end
  • メッセージの抽象化を行う
iex(18)> c("ch11.ex")
ch11.ex:1: warning: redefining module Kitchen
ch11.ex:5: warning: variable pid is unused
ch11.ex:12: warning: variable pid is unused
ch11.ex:18: warning: variable food is unused
ch11.ex:21: warning: variable food is unused
[Kitchen]
iex(19)> pid = spawn(Kitchen, :fridge2, [[:baking_soda]])
#PID<0.94.0>
iex(20)> Kitchen.store pid, :water
:ok
iex(21)> Kitchen.take pid, :water
{:ok, :water}
iex(22)> Kitchen.take pid, :juice
:not_found
  • startを追加
defmodule Kitchen do
  def start(foodList) do
    IO.inspect __MODULE__ 
    spawn(__MODULE__ , :fridge2, [foodList])
  end
  
  ...
  
iex(1)> c("ch11.ex")
ch11.ex:1: warning: redefining module Kitchen
ch11.ex:10: warning: variable pid is unused
ch11.ex:17: warning: variable pid is unused
ch11.ex:23: warning: variable food is unused
ch11.ex:26: warning: variable food is unused
[Kitchen]
iex(2)> pid = Kitchen.start [:rhubarb, :dog, :hotdog]
Kitchen
#PID<0.65.0>
iex(3)> Kitchen.take pid, :dog
{:ok, :dog}
  • Elixirではmodule名は__MODULE__で取れる

11.3 タイムアウト

前回やってので省略。 afterでタイムアウト指定できる。

11.4 選択的受信

defmodule MultiProc do
  def important do
    receive do
      {priority, msg} when priority > 10 ->
        [msg | important()]
    after 0 ->
        normal()
    end
  end

  def normal do
    receive do
      {_, msg} ->
        [msg | normal()]
    after 0 ->
        []
    end
  end
end
iex(1)> c("ch11.ex")
ch11.ex:1: warning: redefining module Kitchen
ch11.ex:10: warning: variable pid is unused
ch11.ex:17: warning: variable pid is unused
ch11.ex:23: warning: variable food is unused
ch11.ex:26: warning: variable food is unused
[MultiProc, Kitchen]
iex(2)> send self(), {15, :high}
{15, :high}
iex(3)> MultiProc.important
[:high]
iex(4)> send self(), {15, :high}
{15, :high}
iex(5)> send self(), {17, :high}
{17, :high}
iex(6)> send self(), {9, :low}
{9, :low}
iex(7)> send self(), {5, :low}
{5, :low}
iex(8)> MultiProc.important
[:high, :high, :low, :low]

選択的受信の落とし穴

  • 無視したメッセージが多くなると読み込み時間が長くなる
    • プロセスサイズも大きくなる

HerokuへDockerを使ってPhoenixアプリをデプロイする

docker heroku Phoenix Elixir

ここ数日全然うまくいかなかったけど、一応動作したので記録として残しておく

前提

herokuへのDeploy方法は公式にもアナウンスされていたり、各所で記事が上がっているんだけどいずれも自分の場合うまく行かなかった。カットアンドトライで今の方法に行き着いたので、とんちんかんなことをしている可能性がある。その場合ご指摘いただけると幸いです。

www.phoenixframework.org

条件

  • Elixir / Phoenix インストール済
  • Heroku登録、Toolbeltインストール済
  • Docker Toolboxインストール済

手順

基本的には以下に沿う。brunchを使用するなら以下に沿えば一応動く。

github.com

プロジェクトの作成

$ mix phoenix.new phoenix_heroku_sample

dockerプラグインのインストール

$ heroku plugins:install heroku-docker
$ cd phoenix_heroku_sample

heroku-dockerについては以下を見ると良さそう。

devcenter.heroku.com

app.jsonProcfileの準備

https://github.com/selvan/docker-heroku-phoenix-app からapp.jsonとProcfileをアプリケーションのルートにもってきて、適宜名前など編集する

  • app.json
{
  "name": "phoenix_heroku_sample",
  "description": "A sample phoenix app",
  "image": "joshwlewis/docker-heroku-phoenix:latest",
  "addons": [
    "heroku-postgresql"
  ]
}
  • Procfile
web: MIX_ENV=prod NODE_ENV=production BRUNCH_ENV=production mix phoenix.server

初期化

Dockerfile

Dockerfileが生成される

heroku docker:init

Dockerfileを以下のように変更。 npm run build でビルドするようにしている。

FROM joshwlewis/docker-heroku-phoenix:latest

ENV MIX_ENV prod

# This causes brunch to build minified and hashed assets
ENV BRUNCH_ENV production

# We add manifests first, to cache deps on successive rebuilds
COPY ["mix.exs", "mix.lock", "/app/user/"]
RUN mix deps.get

# Again, we're caching node_modules if you don't change package.json
ADD package.json /app/user/
RUN npm install

# Add the rest of your app, and compile for production
ADD . /app/user/
RUN mix compile \
    && npm run build \
    && mix phoenix.digest

pacakge.json

{
  "repository": {},
  "scripts": {
    "build-assets": "cp -r web/static/assets/* priv/static",
    "watch-assets": "watch-run -p 'web/static/assets/*' npm run build-assets",
    "build-js": "browserify -t babelify web/static/js/app.js | uglifyjs -mc > priv/static/js/app.js",
    "watch-js": "watchify -t babelify web/static/js/app.js -o priv/static/js/app.js -dv",
    "build-css": "cat web/static/css/*.css > priv/static/css/app.css",
    "watch-css": "catw web/static/css/*.css -o priv/static/css/app.css -v",
    "build": "npm run build-assets && npm run build-js && npm run build-css",
    "watch": "npm run watch-assets & npm run watch-js & npm run watch-css",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "phoenix": "file:deps/phoenix",
    "phoenix_html": "file:deps/phoenix_html"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.6.0",
    "babel-preset-react": "^6.5.0",
    "babel-preset-stage-0": "^6.5.0",
    "babelify": "^7.2.0",
    "browserify": "^13.0.0",
    "catw": "^1.0.1",
    "uglify-js": "^2.6.2",
    "watch-run": "^1.2.4",
    "watchify": "^3.7.0"
  }
}

.babelrc

プラグイン、プリセットはおこのみで。

{
  "presets": [
    "es2015",
    "react",
    "stage-0"
  ]
}

app.js

web/static/js/app.jsを編集する

  • web/static/js/app.js
import "../../../deps/phoenix_html/web/static/js/phoenix_html";

assets用ディレクトリ

priv/static/jspriv/static/cssを作成しておく

アプリの作成

heroku create phoenix-heroku-sample

prod.secretの編集

  • config/prod.secret.ex
use Mix.Config

# In this file, we keep production configuration that
# you likely want to automate and keep it away from
# your version control system.
config :phoenix_heroku_sample, PhoenixHerokuSample.Endpoint,
  secret_key_base: System.get_env("SECRET_KEY_BASE")

# Configure your database
config :phoenix_heroku_sample, PhoenixHerokuSample.Repo,
  adapter: Ecto.Adapters.Postgres,
  url: {:system, "DATABASE_URL"},
  pool_size: 20

secret key baseを環境変数に移しておく。 以下のようにconfig/prod.secret.exに記載してあった、secret key baseをセットする

heroku config:set SECRET_KEY_BASE="xxxxxxxxxxxxxxxxxxxxxxxxx" --app phoenix-heroku-sample

prod.secretをgitで管理する。 Heroku · Phoenixではprod.secretを使用せずprodを使用する方法が書かれている。

  • .gitignore
# /config/prod.secret.exs

デプロイ

phoenix_heroku_sample heroku docker:release --app phoenix-heroku-sample

問題

  • ローカルで動か無い気がする
    • docker-compose upじゃだめ?
  • フロント以外の各手順の意味が明確に理解できていない

Phoenix + ReactでChannelを使用した簡易チャットを作る(1)

React.js Phoenix Elixir

勉強用の題材としてブログを作りはじめた。

blog.bokuweb.me

ブログの更新やコメントにも使用できるので、次はchannelを使用して簡単なチャットを作ってみる。Channelとは簡単にwebsocket通信ができる機能で、Node.jsにおけるSocket.io的なものと理解している。

動作環境

  • Erlang 17.5
  • Elixir 1.2.0
  • Phoenix 1.1.4
  • node.js 4.2.1

環境構築

前回同様。

blog.bokuweb.me

プロジェクト名はphoenix_channel_sandboxとする。

mix phoenix.new phoenix_channel_sandbox

また今回、package.jsonのdependenciesを削除する際以下の2つは削除しない。

  "dependencies": {
   "phoenix": "file:deps/phoenix",
   "phoenix_html": "file:deps/phoenix_html"
  },

Channelへのjoinまでを実装

バックエンド

lib/phoenix_channel_sandbox/endpoint.exsocket用のエンドポイントが用意されている。

  • lib/chat_phoenix/endpoint.ex
defmodule PhoenixChannelSandbox.Endpoint do
  use Phoenix.Endpoint, otp_app: :phoenix_channel_sandbox

  socket "/socket", PhoenixChannelSandbox.UserSocket

これにより、エンドポイント/socketにアクセスするとUserSocketモジュール に接続されることになる。 UserSocketweb/channels以下に雛形が生成されており、以下の行のコメントアウトを解除する。

  • web/channels/user_socket.ex
  channel "rooms:*", PhoenixChannelSandbox.RoomChannel

メッセージはPhoenix.Socket.Messageの形式でやりとりされる。上記の"rooms:*"はtopic名で、topic名はtopic:subtopic、またはtopicの形式で指定される。*はワイルドカードなのでトピックroomsに来たすべてのサブトピックをRoomChannelモジュールに送ることになる。

Phoenix.Socket.Message – Phoenix v1.1.4

メッセージの受け先となるモジュールroom_channelweb/channelsに作成する。

  • web/channels/room_channel.ex
defmodule PhoenixChannelSandbox.RoomChannel do
  use PhoenixChannelSandbox.Web, :channel

  def join("rooms:join", message, socket), do: {:ok, socket}
end

join/3channelへの参加を承認/拒否する関数で、{:ok, socket}{:ok, reply, socket}を返すことで、承認されchannelに参加できる。 拒否するには、{:error, reply} を返す。今回はtopic名rooms、subtopic名joinにアクセス接続が合った場合無条件に参加を承認している。

バックエンドはこれでOKで、JS側を実装する。

フロント

  • web/static/js/app.js
import "../../../deps/phoenix_html/web/static/js/phoenix_html";
import Socket from "./socket";
  • web/static/js/socket.js
import {Socket} from "phoenix"

let socket = new Socket("/socket", {params: {token: window.userToken}})
socket.connect()

const channel = socket.channel("rooms:join", {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

余談だけど、サンプルのJSには;がついていない。;付けない一派なんだろうか。

joinの確認

$ mix phoenix.server
$ open http://localhost:4000/ 

問題なければ下記のようにjoin成功のメッセージが出力されているはず。

f:id:bokuweb:20160322223048p:plain

Reactを使った簡易チャットの実装

バックエンド

room_channel.exに以下の関数を追加する。

  • web/channels/room_channel.ex
  def handle_in("new:message", message, socket) do
    broadcast! socket, "new:reply", %{msg: message["msg"]}
    {:noreply, socket}
  end

handle_in/3は受信メッセージをハンドルする関数で、今回はnew:messageというイベントを受け取った場合、メッセージ内容をブロードキャスト配信している。

https://hexdocs.pm/phoenix/Phoenix.Channel.html#c:handle_in/3

次にReactでマウントできるようweb/template/layout/app.tml.eexを掃除しておく。

  • web/template/layout/app.tml.eex
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">

    <title>Hello PhoenixChannelSandbox!</title>
    <link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
  </head>

  <body>
      <div id="root"></div>
      <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>
</html>

フロント

  • Reactのインストール
$ npm i  -S react react-dom

送受信が行えるよう、socket.jsを変更しておく。

  • web/static/js/socket.js
import { Socket } from "phoenix";

export default class SocketTest {
  constructor(endpoint, token) {
    this.socket = new Socket(endpoint, { params: { token }});
    this.socket.connect();
  }

  connect(topic, msg = {}) {
    this.channel = this.socket.channel(topic, msg)
    this.channel.join()
      .receive("ok", resp => console.log("Joined successfully", resp))
      .receive("error", resp =>  console.log("Unable to join", resp));
  }

  send(msg) {
    this.channel.push("new:message", { msg }); // TODO: 
  }

  addListener(key, fn) {
    this.channel.on(key, fn);
  }
}

app.jsではフォームの描画とメッセージ受信時の更新、ボタン押下時のメッセージ送信を行う。 今回はnew:replyというイベントをListenして、コールバックでメッセージ内容をstateに詰めてる。

  • web/static/js/app.js
import "../../../deps/phoenix_html/web/static/js/phoenix_html";
import Socket from "./socket";
import React, { Component } from 'react';
import { render } from 'react-dom';

class Chat extends Component {
  constructor(props) {
    super(props);
    this.state = { messages: [] };
    this.socket = new Socket('/socket');
    this.socket.connect('rooms:join', {});
    this.socket.addListener("new:reply", messages => {
      this.setState({ messages: this.state.messages.concat(messages) });
    })
  }

  handleSubmit(e) {
    e.preventDefault();
    const message = this.refs.message.value.trim();
    if (!message) return;
    this.socket.send(message);
  }

  renderMessages() {
    return this.state.messages.map(message => <p>{message.msg}</p>);
  }

  render() {
    return (
      <div>
        <h1>Chat sample!!</h1>
        <form className="commentForm" onSubmit={this.handleSubmit.bind(this)}>
          <input type="text" ref="message" placeholder="message" />
          <input type="submit" value="Post" />
        </form>
        {this.renderMessages()}
      </div>

    );
  }
}

render(<Chat />, document.querySelector('#root'));

ひとまずこれで最低限のチャットは可能となる。

f:id:bokuweb:20160322210259g:plain

今回の成果物

github.com

TODO

  • データの永続化
  • 認証
  • 見た目
  • スクロールとか 検索とか
  • Herokuへのデブロイ
    • かなり検索したがまだ失敗している

参考記事

2015/07/09/Phoenix FrameworkのChannelを使ってTwtter Streamingをbroadcastする - ヽ(´・肉・`)ノログ

ruby-rails.hatenadiary.com

所感

少しElixir書いた