読者です 読者をやめる 読者になる 読者になる
無料で使えるシステムトレードフレームワーク「Jiji」 をリリースしました!

・OANDA Trade APIを利用した、オープンソースのシステムトレードフレームワークです。
・自分だけの取引アルゴリズムで、誰でも、いますぐ、かんたんに、自動取引を開始できます。

React.js + Webpack + ContainerJSでTODOリストを作ってみた

React.js + Webpack + ContainerJS でTODOリストを作ってみたので、手順をまとめます。

ソース全体はこちらで公開しているので、あわせて参照ください。

ポイント

  • モジュールローダーを require.js → Webpack に

    • ソースはクラス単位で分割管理して、Webpackで1ファイルにまとめてリリースします。
    • WebpackだとAMDもCommonJSも両方扱えるので、npmのライブラリが使いやすくなるのは大きいかな。
    • あと、Loaderも便利ですね。
  • Viewモジュールを Knockout.js → React.js に

    • 流行ってますね。
    • React.js に合わせて、アーキテクチャも Flux にしてみます。 http://facebook.github.io/flux/img/flux-simple-f8-diagram-with-client-action-1300w.png
  • ES6で書いてみる

    • Webpack 使うなら、せっかくなので Loader も使ってみたい。
    • ということで、ES6で書いてBabel LoaderでES5にトランスパイルする感じに
  • インスタンスの生成と依存関係の解決 && 注入は ContainerJSで

  • テストはJasmine2で

    • 前回同様、なるべくテストできる構成にして、テスト書きます。
  • ビルドツールは gulp

サマリ

  1. ビルドツール(gulp,Webpack ..etc..)をインストールする
  2. ビルドタスクを書く
  3. container-js, React.jsをインストールする
  4. Storeを書く
  5. Storeのテストを書く
  6. Dispatcher, Action を書く
  7. Components を書く
  8. main.jsなどのGlueコードを書く
  9. HTML,CSSを用意する
  10. ビルドして実行してみる

ビルドツールをインストールする

前提として、node, npmがインストールされているものとします。

まずは、gulpやgulp-webpackなど必要なビルドツールをインストールします。

  • プロジェクトのルートディレクトリに package.json を作成して、必要なライブラリを記載します。
  • インストールするライブラリは以下。
    • gulp .. gulp 本体
    • ES6トランスパイラ関連
      • babel-core .. ES6のソースをES5に変換するトランスパイラ
      • babel-loader .. babel を Webpack から使うための Loader
    • Webpack関連
      • webpack .. Webpack 本体。
      • gulp-webpack ..gulpから Webpack を呼び出すときに使うプラグイン
    • テスト関連
      • jasmine-core .. jasmine 本体。
      • jasmine .. node 上で jasmine を使えるようにするラッパー
      • gulp-jasmine ..gulpから jasmine のテストを実行するときに使うプラグイン
    • その他
      • rimraf .. ディレクトリを一括削除するときに使うgulpプラグイン
  • モジュール名等は適当に。
{
  "name": "todo-list",
  "version": "1.0.0",
  "description": "container-js todo-list example with React.js and Webpack",
  "main": "",
  "dependencies": {
  },
  "devDependencies": {
    "babel-core": "^4.7.12",
    "babel-loader": "^4.1.0",
    "gulp": "^3.8.11",
    "gulp-jasmine": "^2.0.0",
    "gulp-webpack": "^1.3.0",
    "jasmine": "^2.2.1",
    "jasmine-core": "^2.2.0",
    "rimraf": "^2.3.1",
    "webpack": "^1.7.3"
  },
  "engines": {
    "node": ">=0.10.0"
  },
  .. 略
}

package.jsonを書いたら、npmでまとめてインストールします。

$ npm install
$ ls ./node_modules
babel-core  babel-loader  gulp  gulp-jasmine  gulp-webpack  jasmine  jasmine-core rimraf  webpack

ビルドタスクを書く

以下のタスクを用意します。

名前 機能
build ES6、JSXコードをトランスパイルしてWebpackで統合したjsファイルを生成します。
build-test テストをWebpackでビルドします。
test テストを実行します
clean ビルド成果物を削除します

 

gulpfile.jsが肥大化しないよう、タスクの実装は tasks/ 以下に配置して、gulpfile.jsで読み込んで使う形にしました。

gulpfile.js:

'use strict';

var requireDir = require('require-dir');
requireDir('./tasks', { recurse: true });

tasks/build.js:

  • config/webpack.js にWebpackの設定ファイルを用意して、Loaderの設定はそこから読み込んでいます。
'use strict';

var gulp    = require('gulp');
var uglify  = require('gulp-uglify');
var webpack = require('gulp-webpack');
var config  = require('../config/webpack.js').src;

gulp.task('build', ['copy-resources'], function () {
    return gulp.src(config.entry)
        .pipe(webpack(config))
        .pipe(gulp.dest("./build/app/js"));
});

config/webpack.js:

  • module.loaders で /node_modules 以外の jsファイルをbabel-loaderにかけるように設定。
    • babel-loaderはJSXのトランスパイルにも対応しているので、それもやらせます。
  • ContainerJSで動的なモジュール読み込みを使うので、 exprContext と unknownContext の設定をしています。
    • この設定を行うことで、src/js以下のモジュールは、明示的に import していなくても、リリースアーカイブに同梱され利用できるようになります。
"use strict";

var base = {
    module: {
        loaders: [{
            test:     /\.js$/, 
            exclude:  /node_modules/,
            loader:  'babel-loader'
        }],
        
        // container.jsで呼び出しているLoaderから、exprContextを生成する。
        // コンテキストが生成されると、application.jsのあるsrc/js以下のモジュール
        // が再帰的に探索して登録され、require('module'); でロードできるようになる。
        // entry で指定したmain.jsが2重登録されないよう、正規表現で除外。
        exprContextRecursive : true, 
        exprContextRegExp: /^\.\/(?!main)([a-zA-Z0-9\-\_\/]*)$/,
        exprContextCritical: false,
        exprContextRequest: '../../../src/js',
        
        // もう一つunknownContextもできるが、不要なので、無視する。
        unknownContextRegExp: /$^/, 
        unknownContextCritical: false
    }
};

module.exports = {
    src : {
        entry: './src/js/main.js',
        output: {
            filename: 'main.js'
        },
        resolve: {
            root: __dirname + '/src/js', 
        },
        module: base.module
    },
    
    test : {
        entry: './test/all-specs.js',
        output: {
            filename: 'all-specs.js'
        },
        resolve: {
            root: __dirname + '/test'
        },
        module: base.module
    }
}

残りのタスクは、コードを参照。

container-js, React.jsをインストールする

npm でインストールします。

$ npm install container-js react --save

Storeを書く

環境が整ったので、コードを書いていきます。 ファイル構成はこんな感じ。Fluxのチュートリアルと合わせています。

$ tree src/js
src/js
├── actions
│   └── todo-list-actions.js
├── components
│   ├── todo-input-form.js
│   ├── todo-list-view.js
│   └── todo-view.js
├── composing
│   └── modules.js
├── dispatchers
│   └── todo-list-dispatcher.js
├── main.js
└── stores
    ├── todo-list.js
    └── todo.js
  • MVVM版と同じく、Todo と TodoListを用意します。
  • ES6なら Object.observe() が使えるので、変更通知系の処理は削っています。

src/js/stores/todo-list.js:

import ContainerJS from 'container-js';
import Todo        from './todo';

export default class TodoList {
    
    constructor() {
        this.items = [];
    }
    
    get( id ) { 
        for ( let item of this.items ){
            if (item.id === id) return item;
        }
    }
    add( title ) { 
        const todo = new Todo(title);
        todo.attachTo(this);
        this.items.push(todo);
        return todo;
    }
    removeCompleted() {
        this.items = this.items.filter( item => !item.completed );
    }
    removeById(id) {
        this.items = this.items.filter( item => item.id !== id );
    }
}

src/js/stores/todo.js:

var sequence = 1;

export default class Todo {
    
    constructor(title, completed=false, now=new Date()) {
    
        this.validateTitle(title);
    
        this.id           = sequence++;
        this.title        = title;
        this.completed    = completed;
        this.createdAt    = now;
        this.lastModified = now;
        this.todoList     = null;
    }
    
    setTitle( value ) {
        this.validateTitle(value);

        this.title        = value;
        this.lastModified = new Date();
    }
    
    complete() {
        this.completed    = true;
        this.lastModified = new Date();
    }
    activate() {
        this.completed    = false;
        this.lastModified = new Date();
    }
    
    remove() {
        if (!this.todoList) throw new Error("illegal state. todoList is not set.");
        this.todoList.removeById( this.id );
    }
    
    attachTo( todoList ) {
        this.todoList = todoList;
    }
    
    validateTitle(title) {
        if (!title) throw new Error("title is not set.");
        if (title.length > 100) throw new Error("title is too long.");
    }
}

Storeのテストを書く

Storeのテストも書きます。

  • 更新通知系の処理がなくなったので、テストもだいぶ減りました。

test/specs/stores/todo-list-spec.js:

import TodoList from '../../../src/js/stores/todo-list';

describe('TodoList', () => {
    
    let todoList;
    let events = [];

    beforeEach( () => {
        todoList = new TodoList();
    });
    
    afterEach(() => {
        events = [];
    });
    
    describe('get', () => {
        it( "can retrieve a todo by id.", () => {
            const todo = todoList.add("test1");

            expect( todoList.get(todo.id) ).toBe( todo );
        });
    });
    
    describe('add', () => {
        it( "creates and add a new todo.", () => {
            todoList.add("test1");
            
            expect( todoList.items.length ).toEqual( 1 );
            expect( todoList.items[0].title ).toEqual( "test1" );
            
            todoList.add("test2");
            
            expect( todoList.items.length ).toEqual( 2 );
            expect( todoList.items[0].title ).toEqual( "test1" );
            expect( todoList.items[1].title ).toEqual( "test2" );
        });
    });
    
    ... 略
    
});

test/specs/stores/todo-spec.js:

import Todo    from '../../../src/js/stores/todo';

describe('Todo', () => {
    
    describe('new', () => {
        it( "can creates a new instance.", () => {
            
            jasmine.clock().mockDate(new Date(2013,0,1));
            
            let todo = new Todo("test");
            
            expect( todo.title ).toEqual( "test" );
            expect( todo.completed ).toEqual( false );
            expect( todo.createdAt ).toEqual( new Date(2013,0,1) );
            expect( todo.lastModified ).toEqual( new Date(2013,0,1) );
        });
        
        it( "can creates a new instance from arguments.", () => {
            
            jasmine.clock().mockDate(new Date(2013,0,1));
            
            let todo = new Todo("test", true, new Date(2014, 1, 1));
            
            expect( todo.title ).toEqual( "test" );
            expect( todo.completed ).toEqual( true );
            expect( todo.createdAt ).toEqual( new Date(2014, 1, 1) );
            expect( todo.lastModified ).toEqual( new Date(2014, 1, 1) );
        });

        it( "generate a new id.", () => {
            
            let todo1 = new Todo("test");
            let todo2 = new Todo("test");
            
            expect( todo1.id ).not.toEqual( todo2.id );
        });
    });

    ... 略
    
});

テストは以下のコマンドで実行できます。

$ gulp test                                                                                                       
[19:09:58] Using gulpfile todo-list-using-webpack/gulpfile.js
[19:09:58] Starting 'copy-test-resources'...
[19:09:58] Finished 'copy-test-resources' after 11 ms
[19:09:58] Starting 'build-test'...
[19:10:09] Version: webpack 1.7.3
       Asset    Size  Chunks             Chunk Names
all-specs.js  772 kB       0  [emitted]  main
[19:10:09] Finished 'build-test' after 12 s
[19:10:09] Starting 'test'...
...........

11 specs, 0 failures
Finished in 0 seconds
[00:00:00] Finished 'test' after 244 ms

node上で実行するほか、./build/test/test.html をブラウザに読み込ませることでも実行できるようにしてみました。

Dispatcher, Action を書く

Storeの操作をViewから呼び出す時の口となる、 Dispatcher と Action を用意します。

src/js/dispatchers/todo-list-dispatcher.js:

import ContainerJS from 'container-js';

export default class TodoListDispatcher {
    
    constructor() {
        this.callbacks = {};
        
        this.todoList = ContainerJS.Inject;
    }
    
    initialize() {
        this.registerCallbacks();
    }
    
    dispatch(payload) {
        const callback = this.callbacks[payload.actionType];
        if (callback) callback(payload);
    }
    
    registerCallbacks() {
        this.callbacks["add"] = 
            (payload) => this.todoList.add(payload.title);
        this.callbacks["removeCompleted"] = 
            (payload) => this.todoList.removeCompleted();
        
        this.callbacks["complete"] = (payload) => {
            let todo = this.todoList.get(payload.id);
            if (todo) todo.complete();
        };
        this.callbacks["activate"] =  (payload) => {
            let todo = this.todoList.get(payload.id);
            if (todo) todo.activate();
        };
        this.callbacks["remove"] =  (payload) => {
            let todo = this.todoList.get(payload.id);
            if (todo) todo.remove();
        };
    }
}

src/js/actions/todo-list-actions.js:

import ContainerJS from 'container-js';

export default class TodoListActions {
    
    constructor() {
        this.callbacks = {};
        
        this.dispatcher = ContainerJS.Inject;
    }
    
    add(title) {
        this.dispatcher.dispatch({
            actionType: "add",
            title:       title
        });
    }
    
    removeCompleted() {
        this.dispatcher.dispatch({
            actionType: "removeCompleted"
        });
    }
    .. 略
} 

Components を書く

次はViewですね。

  • 「TodoListView」「TodoView」「TodoInputForm」を用意します。
  • それぞれ、以下に対応します。( MVVM版 と同じです。)

f:id:unageanu:20130923144917p:plain

src/js/components/todo-list-view.js:

  • Todoリストアプリの root のビュー。
  • 新しいTodoの入力フォーム、各Todoの描画は、「TodoView」「TodoInputForm」に移譲しています。
  • stateでTodoの一覧を持ちます。
    • Object.observe() で TodoList の変更を監視し、追加や削除があった場合、自身のstateを更新します。
import React         from 'react';
import ContainerJS   from 'container-js';
import TodoView      from './todo-view';
import TodoInputForm from './todo-input-form';

export default React.createClass({
    
    propTypes: {
        todoList: React.PropTypes.object.isRequired,
        actions:  React.PropTypes.object.isRequired
    },
    
    getInitialState(){
        return this.retrieveState();
    },
    
    componentDidMount() {
        Object.observe(this.props.todoList, this.onChange, ['update']);
        Array.observe(this.props.todoList.items, this.onChange);
    },
    componentWillUnmount() {
        Object.unobserve(this.props.todoList, this.onChange);
        Array.unobserve(this.props.todoList.items, this.onChange);
    },
    
    render() {
        let enableToRemoveCompleted = false;
        const todos = [];
        
        this.state.todos.forEach((item) => {
           if ( item.completed ) enableToRemoveCompleted = true;
           todos.push(<TodoView todo={item} actions={this.props.actions} />)
        });

        return (
            <div>
              <TodoInputForm actions={this.props.actions} />
              <div className="todo-list">
                {todos}
              </div>
              <div className="buttons">
                <button 
                   onClick={this.removeCompleted} 
                   disabled={!enableToRemoveCompleted}>
                   remove completed
                </button>
              </div>
            </div>
        );
    },
    
    onChange() {
        this.props.todoList.items.forEach((todo)=> {
            Object.unobserve(todo, this.onTodoChange);
            Object.observe(todo, this.onTodoChange, ['update']);
        });
        
        Array.unobserve(this.props.todoList.items, this.onChange);
        Array.observe(this.props.todoList.items, this.onChange);
        
        this.setState(this.retrieveState());
    },
    onTodoChange() { 
        this.setState(this.retrieveState()); 
    },
    
    removeCompleted() {
         this.props.actions.removeCompleted();
    },
    retrieveState() {
        return { todos : this.props.todoList ? this.props.todoList.items : [] };
    }
});

src/js/components/todo-view.js:

  • 各Todoの描画を担当するビュー。
  • props の情報に基づいて、Todoを描画します。
import React from 'react';

export default React.createClass({
    
    propTypes: {
        todo:   React.PropTypes.object.isRequired,
        actions: React.PropTypes.object.isRequired
    },
    
    render() {
        const todo = this.props.todo;
        const icon = todo.completed
            ? <img src="../images/normal.png" alt="" />
            : <img src="../images/check.png" alt="checked" />;
        const command = todo.completed
            ? <a href="javascript:void(0)" onClick={this.activate}>
                activate
              </a>
            : <a href="javascript:void(0)" onClick={this.complete}>
                complete
              </a>;
              
        return (
          <div className="todo">
            {icon}
            <span className="title">{todo.title}</span>
            <span className="last-modified">{this.formatDate(todo.lastModified)}</span>
            <span className="commands">
              {command}
              <a href="javascript:void(0)" onClick={this.remove}>remove</a>
            </span>
          </div>
        );
    },
  
    complete() { this.props.actions.complete(this.props.todo.id) },
    activate() { this.props.actions.activate(this.props.todo.id) },
    remove()   { this.props.actions.remove(this.props.todo.id) }, 
  
    formatDate(d) {
        if (!d) return "";
        return d.getFullYear() 
                + "-" + (d.getMonth() + 1) 
                + "-" + d.getDate()
                + " " + d.getHours()
                + ":" + d.getMinutes()
                + ":" + d.getSeconds();
    }
});

src/js/components/todo-input-form.js:

  • 入力フォーム担当。
  • 独自のstateとして、エラー情報を持ちます。
import React       from 'react';
import ContainerJS from 'container-js';

export default React.createClass({
    
    propTypes: {
        actions:  React.PropTypes.object.isRequired
    },
    
    getInitialState(){
        return { error: "", title:"" };
    },
    
    render() {
        return (
            <div className="todo-input-form">
              title:
              &nbsp;
              <input 
                type="text" 
                className="title-field" 
                value={this.state.title}
                onChange={this.onChange}
              ></input>
              &nbsp;&nbsp;
              <button onClick={this.newTodo} className="add-button">
                add
              </button>
              <div className="error">{this.state.error}</div>
            </div >
        );
    },
    
    onChange(event) {
        this.setState({error:"", title: event.target.value});
    },
    
    newTodo(event) {
        try {
            this.props.actions.add( this.state.title );
            this.setState({error:"", title:""});
        } catch ( exception ) {
            this.setState({error:exception.message, title:""});
        }
    }
});

main.jsなどのGlueコードを書く

必要なソースが一式そろったところで、部品を組み合わせていく部分のコードを書きます。

  • インスタンスの生成と依存関係の解決 && 注入は DIコンテナでやるので、そのモジュール定義ファイル(modules.js)と、
  • 起動処理を行う、main.jsを用意します。

src/js/composing/modules.js:

  • 今回、Viewはコンテナ管理外なので、それ以外の部品を登録します。
"use strict";

const stores = ( binder ) => {
    binder.bind("todoList").to("stores.TodoList");
};
const actions = ( binder ) => {
    binder.bind("actions").to("actions.TodoListActions");
};
const dispatchers = ( binder ) => {
    binder.bind("dispatcher")
        .to("dispatchers.TodoListDispatcher")
        .onInitialize("initialize");
};

export default ( binder ) => {
    stores(binder);
    actions(binder);
    dispatchers(binder);
};

src/js/main.js:

  • コンテナを作って、ルートビューを描画します。
  • document.addEventListener ってIEで動かないんだっけ・・・・。まぁいいか。
import "babel-core/polyfill";

import React        from 'react';
import ContainerJS  from 'container-js';
import modules      from './composing/modules';
import TodoListView from './components/todo-list-view';

document.addEventListener( "DOMContentLoaded", ()=> {
    const container = new ContainerJS.Container( 
        modules, 
        ContainerJS.PackagingPolicy.COMMON_JS_MODULE_PER_CLASS,
        ContainerJS.Loaders.COMMON_JS
    );
    
    ContainerJS.utils.Deferred.when([
        container.get("todoList"),
        container.get("actions")
    ]).then(
        (components) => {
            try { 
                React.render( 
                    <TodoListView 
                        todoList={components[0]} 
                        actions={components[1]} />, 
                    document.body );
            } catch (e) { 
                console.log(e);
            }
        },
        (error) => console.log(error)
    );
});

HTML,CSSを用意する

最後に、CSSとHTMLを用意します。

  • CSSは前回と同じ
  • HTMLは、大部分がViewに移ったので、かなりシンプルに。

src/css/todo-list.css:

src/html/index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Todo List</title>
    <script type="text/javascript" src="../js/main.js">
    </script>
    <link rel="stylesheet" type="text/css" href="../css/todo-list.css">
  </head>
  <body id="todoapp">
  </body>
</html>

ビルドして実行してみる

ビルドすると、./build/app にリリース物が生成されます。

$ gulp build
[20:02:03] Using gulpfile todo-list-using-webpack/gulpfile.js
[20:02:03] Starting 'copy-resources'...
[20:02:03] Finished 'copy-resources' after 14 ms
[20:02:03] Starting 'build'...
[20:02:14] Version: webpack 1.7.3
  Asset    Size  Chunks             Chunk Names
main.js  766 kB       0  [emitted]  main
[20:02:14] Finished 'build' after 12 s

動作確認はこちらから。

感想

  • ES6良い

    • Arrow Function や Classes が入って、かなり書きやすくなりましたね!
    • あとは モジュールの仕組みが仕様に入ったのも大きいかと。
    • ただ、業務でがっつり使うなら、トランスパイルが足かせにならないかはちょっと心配。
      • それなりに時間がかかるので、書く→動かすのサイクルがどうしても1テンポ遅れる。最初雑に書いて、単体テスト書いて動かしながら洗練させてく派の人には致命傷になりかねない。
      • 差分ビルドや watch して自動ビルドする仕組みをしっかり作る必要がありそう。
      • あとは、なんかあった時のデバッグですね。Babel → Webpack で2重に処理しているので、なんかあった時は両方の出力をイメージしつつデバッグする覚悟が必要。なんかあった時だけですけど。
  • Reactは、この例では何とも

    • 例がシンプルすぎて、良さがいまいちわからなかった感じ。
    • もう少し大規模なアプリになれば、パフォーマンスやシンプルさで優位性を感じられるのかも。
  • Fluxは、アプリケーションアーキテクチャとしてはしっくりこないかな

    • Webアプリを整理する切り口としては、ドメインモデル(M)と抽象化したView(VM)で構成するMVVMの方がしっくりきます。(あくまで、個人的にはですが)。
    • ただ、「データフローを単方向にする」考え方は参考にできるかと。
      • MVVMでも気を付けて書かないと、データバインディングがスパゲッティになることはありますね。
      • フローが単方向になるように意識して書くのは重要。でも、そのために Dispatcher まではいらない感。もしくは、いてもいいけど、第一階層に置くのはちょっと、、、というところかな。