React.js + Webpack + ContainerJSでTODOリストを作ってみた
React.js + Webpack + ContainerJS でTODOリストを作ってみたので、手順をまとめます。
- 以前書いた、Knockout + ContainerJS でテスタブルにToDoリストを作るチュートリアル - うなの日記の改訂版。
- 最近流行ってるライブラリのお試しも兼ねて、いろいろ組み合わせて使ってみようという試みです。
ソース全体はこちらで公開しているので、あわせて参照ください。
ポイント
モジュールローダーを require.js → Webpack に
- ソースはクラス単位で分割管理して、Webpackで1ファイルにまとめてリリースします。
- WebpackだとAMDもCommonJSも両方扱えるので、npmのライブラリが使いやすくなるのは大きいかな。
- あと、Loaderも便利ですね。
Viewモジュールを Knockout.js → React.js に
- 流行ってますね。
- React.js に合わせて、アーキテクチャも Flux にしてみます。
ES6で書いてみる
- Webpack 使うなら、せっかくなので Loader も使ってみたい。
- ということで、ES6で書いてBabel LoaderでES5にトランスパイルする感じに
インスタンスの生成と依存関係の解決 && 注入は ContainerJSで
- コンポーネント間の結合を疎に。テストもしやすく。
テストはJasmine2で
- 前回同様、なるべくテストできる構成にして、テスト書きます。
ビルドツールは gulp
サマリ
- ビルドツール(gulp,Webpack ..etc..)をインストールする
- ビルドタスクを書く
- container-js, React.jsをインストールする
- Storeを書く
- Storeのテストを書く
- Dispatcher, Action を書く
- Components を書く
- main.jsなどのGlueコードを書く
- HTML,CSSを用意する
- ビルドして実行してみる
ビルドツールをインストールする
前提として、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で読み込んで使う形にしました。
'use strict'; var requireDir = require('require-dir'); requireDir('./tasks', { recurse: true });
- 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")); });
- 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() が使えるので、変更通知系の処理は削っています。
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 ); } }
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版 と同じです。)
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: <input type="text" className="title-field" value={this.state.title} onChange={this.onChange} ></input> <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コードを書く
必要なソースが一式そろったところで、部品を組み合わせていく部分のコードを書きます。
- 今回、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); };
- コンテナを作って、ルートビューを描画します。
- 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に移ったので、かなりシンプルに。
略
<!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 が入って、かなり書きやすくなりましたね!
- あとは モジュールの仕組みが仕様に入ったのも大きいかと。
- ただ、業務でがっつり使うなら、トランスパイルが足かせにならないかはちょっと心配。
Reactは、この例では何とも
- 例がシンプルすぎて、良さがいまいちわからなかった感じ。
- もう少し大規模なアプリになれば、パフォーマンスやシンプルさで優位性を感じられるのかも。
Fluxは、アプリケーションアーキテクチャとしてはしっくりこないかな