ReactとCordovaで、Web/モバイルのハイブリットアプリを作った話
ReactとCordovaを使って、ブラウザ向けのWebUI + Androidで動くスマホアプリ を提供するサービスを、一人で作ってみた話です。
サマリー
- 作ったもの
- 最大の課題:作業量
- 一人で作りきるために意識したこと
- 取り組み1: Cordovaを使って、Web UI/スマホアプリのコードを共通化する
- 取り組み2: レイヤードアーキテクチャを採用し、共有できるコードを最大化する
- 取り組み3: ユニットテストを書く
- まとめ
作ったもの
自分だけの取引アルゴリズムで、誰でも、いますぐ、かんたんにFX自動取引を開始できる、システムトレードフレームワークです。
- アルゴリズムの作成、バックテスト、リアル口座での自動取引まで、これ一つで可能。
- 取引アルゴリズムはRubyで記述。メール送信や取引タイミングのPush通知も、APIを呼び出すだけで実現できます。
- 取引の状況は、スマホアプリ/Web UIでいつでもどこでも確認が可能。アルゴリズムの管理もできるので、相場が急に動いても安心です。
- クラウドプラットフォームのHerokuに対応。運用も低コスト。
- オープンソース。ソースコードはGitHubで公開しています。
スクリーンショットをいくつか。
Web UI:
スマホアプリ:
コード規模
UI側のコード規模は以下の通り。テストケースを含めた合計で、31000行くらいです。
WebUI | アプリ | 合計 | |
---|---|---|---|
ソースコード | 15222 | 4454 | 19676 |
テストケース | 9676 | 1574 | 11250 |
また、これとは別に、サーバーのコード(ruby)が、ソースコード/テストケースあわせて、27000行くらいあります。
構成
- UIは、いわゆるシングルページアプリケーションで、ECMAScript2015 で書いています。
- Babelでコンパイルして、Webpackで統合する形。
- マテリアルデザインを採用していて、Reactで動くUIライブラリの Material UI を利用しています。
前置きは以上。
最大の課題:作業量
開発にあたっての最大の課題は、なんといっても作業量でした。
初期の検討段階で、「Webアプリ or モバイルアプリのどちらかに注力できないか?」と考えましたが、
Push通知やモバイルでのシステム管理に対応した、スマホ時代のFXシステムトレードフレームワークにしたかった。
- 同様の機能を提供するソフトはすでにあるのですが、モバイルでの取引状況の確認やアルゴリズムの管理に難があったのが、開発のきっかけにもなっています。
ということで、両方必要という結論に至りました。そうと決まれば、後は、如何にして作りきるかです。
一人で作りきるために意識したこと
以下の2点を、特に意識しました。
- コードの共通化/再利用
- Web UI/スマホアプリのコードを可能な限り共通化して、開発コストを削減する。
- ユニットテスト
- ユニットテストで個々のモジュールの動作を保障。
- 機能追加や変更を、低コストで素早く行えるようにする。
そして、↑のための具体的な取り組みとして、以下を行いました。
取り組み1: Cordovaを使って、Web UI/スマホアプリのコードを共通化する
スマホアプリをCordovaを使ったハイブリットアプリにして、Web UI/スマホアプリのコードを共通化しました。
- Cordovaを使うことで、JavaScript + HTML + CSSでスマホアプリを作ることができるので、同様の構成で作成するWeb UIのコードを再利用できるようになります。
- React Native という選択肢もありましたが、UI側の開発に着手した時点ではAndroidには対応していなかったため、見送りました。
- Cordovaは、Webでの情報が豊富で、実績も多くあった点がプラスでした。
- また、Push通知や課金決済関連のプラグインがあったことも大きいです。
- 採用に当たって懸念だった、UIのパフォーマンスについては、かんたんなプロトタイプを作って検証しました。
- やはり、ネイティブアプリよりはもっさりしていますが、ゲームのようなパフォーマンスがアプリの価値に直結する性質のソフトウェアではないので、問題ないレベルと判断。
- それよりも、リリースまでの開発工数を抑えること、また、リリース後の機能強化や改善をスピード感を持って行えること、を重視しました。 unageanu.hatenablog.com
「UIの反応速度とか使い勝手とか、実際どんなもんなの?」と気になる方は、ダウンロードしてお試し頂ければと思います。(まいど、ありがとうございます! / 30日の無料トライアル期間内に、定期利用を解除すれば請求は発生しないので大丈夫です)
なお、Crosswalkの効果は絶大でした。体感速度が劇的に向上したうえ、怪しい動きをしていた箇所も治ったり。 apkのサイズ/起動時間は増えますが、採用する価値はあります。
取り組み2: レイヤードアーキテクチャを採用し、共有できるコードを最大化する
MVVMのレイヤードアーキテクチャを採用して、Modelレイヤのコードを共通化。
ViewやViewModelも、変更の度合いに応じて最適なレイヤーでカスタマイズすることで、共有できるコードを最大化しました。
- Model/View/ViewModel + 通信などのInfrastructureで構成。
- Model(UIに依存しない、アプリ共通のコアドメイン)は、Web UI/スマホアプリでそのまま共有。
- スタイルの変更だけで済む場合は、CSSのレベルでカスタマイズ。
- DOM構造を変える必要がある場合は、View(Reactコンポーネント)のコードを差し替えて対応。
- Viewで管理するデータや機能がそもそも異なる場合は、ViewModel(Modelをラップして、Viewに依存するデータや操作を提供するクラス群)のレベルで、カスタマイズして再利用。
このほか、Infrastructureの通信部分やGoogle Analyticsでの利用状況解析も、WebUI/スマホアプリでカスタマイズして利用しています。
- 通信部分は接続先REST APIを差し替える必要があるため(Web UIは自ホストに、スマホアプリはUIで設定したサーバーに接続)、URLResolverとして機能を切り出しカスタマイズしています。
- 利用状況解析は、スマホアプリではJava APIを使うようカスタマイズしたクラスを用意して差し替え。
以下は、利用状況解析のコードの抜粋です。 インターフェイスを統一しつつ、実装を差し替える形で、別のクラスを用意しました。
WebUI版:
export default class GoogleAnalytics { // 略 // Google Analytics のJavaScript版APIを呼び出す sendEvent( action, label="", value={} ) { this.run((ga) => ga('send', 'event', this.category, action, label, value)); } sendTiming( category, timingVar, time, label ) { this.run((ga) => { ga('timing', category, timingVar, time, label); }); } // 略 }
スマホアプリ版:
export default class GoogleAnalytics { // 略 // Cordova google-analytics-plugin のAPIを呼び出す // https://github.com/danwilson/google-analytics-plugin sendEvent( action, label="", value={} ) { this.run((ga) => { ga.trackEvent(this.category, action, label, value, () => {}, (e) => console.log(e)); }); } sendTiming( category, timingVar, time, label ) { this.run((ga) => { ga.trackTiming(category, time, timingVar, label, () => {}, (e) => console.log(e.message)); }); } // 略 }
これら、コンポーネントの差し替えは、DI(Dependency Injection)コンテナを利用して、行うようにしました。 DIコンテナを使うことで、コンポーネントの外側で、環境ごとにどのクラスを使うのか宣言的に指定できるようになります。
WebUI版のコンポーネント定義:
binder.bind("googleAnalytics").to("utils.GoogleAnalytics") .withProperties({ category: "web-ui", version: "1.0" }).onInitialize("initialize");
// スマホアプリ用のクラスを使うように変更 binder.bind("googleAnalytics").to("app.utils.GoogleAnalytics") .withProperties({ category: "app", // category, versionもカスタマイズ。 version: "1.0.6" }).onInitialize("initialize");
利用側のコードは以下のような感じ。Inject
としてマークしておくと、コンポーネント定義で宣言されたコンポーネントが注入されるので、利用側はAPIを呼び出すだけです。
export default class RmtService extends AbstractService { constructor() { this.urlResolver = ContainerJS.Inject; this.xhrManager = ContainerJS.Inject; this.googleAnalytics = ContainerJS.Inject; // コンポーネント定義で宣言されたコンポーネントが注入される } // 略 putAgentSetting(settings) { this.googleAnalytics.sendEvent( "put rmt agent setting" ); // GAにイベントを送信 return this.xhrManager.xhr(this.serviceUrl("agents"), "PUT", settings); } }
取り組み3: ユニットテストを書く
MVVMアーキテクチャを採用したことで、View以外の部分はDOMに依存しなくなるため、容易にユニットテストができるようになります。 ユニットテストを意識的に用意したことで、一人でも何とか作りきることができたかな、と感じます。
- 一人で開発したとこもあり、サーバー側の開発で1か月くらいrubyのコードばかり書いていると、自分が書いたコードでも忘れてしまって、変なバグを埋め込んだりします。テストコードがあることでデグレードが検知され、バグに気付くことが多くありました。
- また、コードがどう動くか、についても、テストケースをみると思い出せるということもありました。
ユニットテストで確認された"動く"コードを少しずつ積み上げていく、という作り方は、心理的にも良かったと思います。 一人なので、開発期間はどうしても長くなりますし、やってもやってもタスクがなくならない、終わりが見えない感じになりますが、"動く"コードが積み上がっていくことで、毎日少しずつでもコードを書いていれば、いつか完成すると思えるようになります。
なお、初版リリースの段階ではViewのテストは省略しました。
- 初版リリースでは、UIの精緻化などでDOMの構造を変更する可能性が高く、テストを作成しても、効果が変更コストに見合わないと考えました。
- また、Viewの機能は「ViewModelの状態に応じて仮想DOMを生成する」だけで、テストすべきロジックが少ない && ViewModelのテストで、アプリケーションがこういう状態の時はこうなる、〇〇操作を実行するとこういう状態になる、といった機能は確認できている、というのも理由としてあります。
- 同じ理由で、E2Eテストも行っていません。このあたりは、今後、サービスが利用されるようになって、メンテナンスの比重が大きくなってからの課題と考えています。
まとめ
- Cordovaを使ってちゃんと作れば、一人でもサービスを作れる。
- プロトタイプや最初のMVPは、Cordovaで素早くコストをかけずに作る、そして、手ごたえがあったらネイティブで作り直す、という選択肢は割とアリなのではと思います。
- もちろん、パフォーマンスが重要なアプリの場合は、不相応な場合もありますが。
- ユニットテスト重要。テスタビリティは最初から考慮して作ろう。
- Crosswalk速い。使おう。
Webアプリ向け高機能コードエディタ「Ace」をReactに組み込んで使ってみる
Ace とは?
Ace は、Webアプリケーション向けのリッチなコードエディタです。 シンタックスハイライトや正規表現検索に対応した高機能なコードエディタを、Webアプリにさくっと組み込めます。
機能一覧: (Aceのサイトより)
- Java,Ruby等を含む、110言語のシンタックスハイライトに対応
- 自動インデント
- マルチカーソル
- 正規表現での検索/置換
- 関数やクラスの折り畳み機能(Code folding)
- 「Ctrl + /」での一括コメント
- ..etc..
おお、なんかすごい高機能だ。
Reactに組み込んで使う
React-Ace を使います。
$ npm install react-ace
jsのソースは以下。
import React from 'react' import AceEditor from 'react-ace' // webpackで統合するので、必要なテーマや拡張モジュールは // 明示的にimportしておく必要があります。 import 'brace/mode/ruby' import 'brace/theme/github' import 'brace/ext/searchbox' class RubyEditor extends React.Component { constructor(props) { super(props); this.state = {}; } render() { return ( <AceEditor mode="ruby" theme="github" value={this.props.source} onChange={this.onChange.bind(this)} name="editor" /> ); } onChange(newValue) { console.log(newValue); // とりあえず } } RubyEditor.propTypes = { source: React.PropTypes.string.isRequired }; RubyEditor.defaultProps = { source: "# test. \nclass Foo\nend" }; React.render( <RubyEditor />, document.body );
unageanu/sandbox/ace - GitHub にPushしているので、package.jsonなど他のソースはこちらを参照ください。
JavaScript Dateのタイムゾーン指定できない問題とその対策
JavaScriptのDate型では、タイムゾーンを外部から指定できない
- JavaScriptのDate型は、タイムゾーンの情報を持ってはいますが、APIで外から変更することはできません。
- getTimezoneOffset() はあるけど、setTimezoneOffset(timezone) はありません。
- getTimezoneOffset() の値は実行環境のタイムゾーンになります。
- getHours()やgetMinutes()はローカルのタイムゾーンにおける時刻表現を返すので、実行環境によって結果が異なり、問題になる場合があります。
const date = new Date( '2015-05-10T12:00:00.000Z' ); expect( date.getHours() ).toEqual(21); // ローカルタイムゾーンがJSTの場合は動作するが、他の環境では違う結果になる。
対策
以下のようにしてみました。
- Dateの代わりに、 date-with-offset を使う。
- DateWithOffsetをnewするときに、static変数からタイムゾーンを読み込んで使う。
具体的にはこんなユーティリティを作成。
import DateWithOffset from "date-with-offset" const defaultTimezoneOffset = new Date().getTimezoneOffset()*-1; export default class Dates { static date(iso8601String) { return new DateWithOffset( iso8601String, this.getTimezoneOffset() ); } static getTimezoneOffset() { return this.timezoneOffset != null ? this.timezoneOffset : defaultTimezoneOffset; } static setTimezoneOffset(timezoneOffset) { this.timezoneOffset = timezoneOffset; } static resetTimezoneOffset() { this.timezoneOffset = null; } }
ローカルタイムゾーンに依存するテストが動作するところは、以下のようにすることで、どの環境でも同じ結果が返るようになります。
beforeEach( ()=> Dates.setTimezoneOffset(540) ); afterEach( ()=> Dates.resetTimezoneOffset() ); it("ローカルタイムゾーンに依存するテスト", () => { // このテスト内では、ローカルタイムゾーンがJSTになる。 expect( Dates.date("2015-05-10T12:00:00.000Z").getHours() ).toEqual(21); });
Cordova + React.js + Material UI で、 Material Design な Android アプリのプロトタイプを作ってみた
Cordova のお試しということで、 Cordova + React.js + Material UI で Material Design な Android アプリのサンプルを作ってみました。
- HTML+CSS+JavaScript でネイティブアプリが作れるCordova ですが、やはり遅いという話をよく耳にするので、実際どうなのか確認してみるのが目的。
- 使えそうなら、フロントはJavaScriptで書いて、せめてモデル層だけでも共通化したい。
- UIは Material Designにしたいので、 Material UI フレームワークを使ってみました。
- Material UI は、Material Design を実装したフレームワークです。
- Navigation Drawer や Action Bar といったUIパーツを、React コンポーネント + LESS で提供しています。
- React Nativeも気になるところですが、Android版はまだらしいのでとりあえずスルー。Macも持ってないし。
プロトタイプの機能
- メイン画面には、Action Bar と Card を表示。
- Cardは以下の3種類を用意。
- テキストを流し込んだもの
- 画像を張り付けたもの
- 簡単なグラフを描画したキャンバスを張り付けたもの
- 少しスクロールするよう、複数個貼り付けています。
- Cardは以下の3種類を用意。
- Action Bar の左上のボタンをタップすると、Navigation Drawerが表示される。
- メニュー選択時の処理は実装していないので、表示だけです。
- パネルをタップすると、パネルが回転するアニメーションが走る
- CSS transitionのパフォーマンスも見てみたいので。
コードと実行手順
コードは こちら で公開しています。 Node.js とAndroidのビルドツール一式(JDK, Android SDK, Ant) をインストールして、以下のコマンドを実行すれば動かせるはずです。
$ git clone https://github.com/unageanu/sandbox.git $ cd sandbox/cordova/sample-app $ npm install $ npm run-script init $ npm run-script build $ npm run-script run-browser # ブラウザで実行 $ npm run-script run-android # Android(実機)で実行
所感
ややもっさり感はあるものの、シンプルなアプリであれば十分使えそう。
- 画面のスクロールや、LeftNavi表示時のアニメーションは少しもたつく。
- transition を使ったアニメーションは速い。
- レンダリングも、特に汚いといったことはなさそう。
- 起動は5~6秒といったところ。スプラッシュスクリーンを出しておけば大丈夫なレベルかな。
確認は手持ちの端末(Xperia Z)で行いました。2年前の端末なので、これで使えれば最近の端末でも大丈夫でしょう。さすがにゲームとかは厳しそうですが、とりあえず、jiji2で必要な機能なら十分実用に耐えるのではないかと。
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は、アプリケーションアーキテクチャとしてはしっくりこないかな
ContainerJSをnpmに登録したメモ
ContainerJS をnpmに登録したので、手順をメモしておきます。
- ContainerJSは、JavaScript Webアプリケーション用のDependency Injection コンテナ です。機能は以下。
- チュートリアルはこちら → Knockout + ContainerJS でテスタブルにToDoリストを作るチュートリアル
- ※なお、AMDで書いてるので、普通に require して使ったりはできなかったり・・・。
- Webpackとかrequire.jsと組み合わせれば使えますが、node上で使う意味はあんまりないです。
- npmで簡単にインストールできるようにするのが公開の目的です。
公開の手順は、こちらを参考にしました。
1. npmのアカウントを作成する
まずは、npmにユーザーを作成します。以下のコマンドを実行して、ユーザー名やメールアドレスを入力すればOK
$ npm adduser
- 入力した内容は、 ~/.npmrc に保存されます。
- 「npm config ls」でも確認できます。
$ npm config ls ; cli configs user-agent = "npm/2.5.1 node/v0.12.0 linux x64" ; userconfig /home/xxxx/.npmrc //registry.npmjs.org/:always-auth = false //registry.npmjs.org/:email = "masaya.yamauchi@gmail.com" //registry.npmjs.org/:username = "unageanu"
2. package.json を用意する
次にモジュールの名前や説明を記入する package.json を用意します。
以下のコマンドで対話的に作成するのが簡単。もちろんテキストエディタで直接作っても大丈夫です(たぶん)。
$ npm init
各項目の詳しい設定内容は公式ドキュメントを参考。
3. 公開しないファイルを指定する
プロジェクト内に配信不要なファイル(テストケースなど)がある場合、プロジェクトのルートに「.npmignore」を用意して記載します。
- 「.gitignore」と同じ形式で、npmで配信しないファイルを指定できます。
- 使えるワイルドカードは.gitignoreのドキュメントを参考に。
4. npmページのTopに表示される説明を用意する
npmのモジュールのページ(例:ContainerJSのページ)には、 README.md の内容が表示されます。
プロジェクトルートに README.md を作成して使い方などを記入しておきます。
- ファイル名に注意
- npmでは、「README.*」 なファイルを探して使用するようで、最初「README.ja.md」「README.md」を用意していたら、「README.ja.md」が使われてしまいました。
- 「README_ja.md」にリネームすると解決しました。
5.パッケージを公開する
package.jsonができたら、package.jsonがあるディレクトリで「npm publish」すれば、公開できます。
$ npm publish
以下のURLにアクセスすれば、ページができているはず。
https://www.npmjs.com/package/<モジュール名>
6. パッケージをアップデートする
「npm version <更新タイプ>」でpackage.jsonのバージョンを上げてから、再度publishすればOK
$ npm version patch $ npm publish
- 更新タイプのところには、新しいバージョンか、アップしたいバージョンの種類(patch, minor, major ..etc..)を指定できます。
- 例えば、patch を指定すると、 1.0.0 → 1.0.1 に上がります。
- Git管理のプロジェクトで実行すると、Gitのタグ(v1.0.1)も同時に付与されます。
つぎは
npmでインストールして、Webpackと組み合わせて使うチュートリアルを書きます(たぶん)。
npmの基本的な使い方まとめ
npmの基本的な使い方のまとめです。忘れたときに見返す用。
- npmのインストールから、モジュールを取ってきて使うところまで。
- 環境は、CentOS7です。
インストール
node.jsをインストールすると、一緒に入ります。
ここを参考にソースからインストールしました。
$ sudo yum groupinstall 'Development tools' $ wget http://nodejs.org/dist/v0.12.0/node-v0.12.0.tar.gz $ tar xvf node-v0.12.0.tar.gz $ cd node-v0.12.0/ $ ./configure --prefix=/usr/local $ make $ sudo make install
バージョンを確認。
$ node -v v0.12.0 $ npm -v 2.5.1
モジュールのインストール
$ npm install <モジュール名> 例) $ npm install gulp
- モジュール名を指定しない場合、package.json に書かれた依存モジュールをインストールします。
- <モジュール名>@<バージョン> で任意のバージョンのモジュールをインストールできます。
- 参考:npm - CLI Commands - install
ローカルインストールとグローバルインストール
- ローカルインストール
- 開発中のアプリが依存するライブラリをインストールする場合に利用します。
- インストールしたモジュールは、プロジェクト内のみで使えます。
- 同じマシンで開発している他のプロジェクトからは使えないので、それぞれで別のバージョンを使用したりできます。
- モジュールは、
/node_modules にインストールされます。
- グローバルインストール
- grunt のような、マシン全体で共有できるツールやライブラリをインストールする場合はこちら。
- インストールしたモジュールは、マシン全体で共有されます。
- 試した環境では、 --prefix=/usr/local を指定してコンパイルしているので、モジュールは/usr/local/lib/node_modules/ にインストールされました。
- パーミッションに注意。EACCES エラーが起きる場合は、パーミッションか、インストール先を変更します。
試しに、gulp をインストールしてみます。
- -gをつけるとグローパルインストールになります。
- バージョンを明示して、それぞれ別バージョンをインストールしてみます。
$ mkdir -p test/app $ cd test/app $ sudo npm install -g gulp@3.8.10 $ gulp -v [09:55:35] CLI version 3.8.10 $ npm install gulp@3.8.11 $ gulp -v [10:02:46] CLI version 3.8.10 [10:02:46] Local version 3.8.11
インストールしたモジュールを使う
require して使います。
以下は、 uuid をインストールしてuuidを出力するサンプルです。
インストール
$ npm install uuid
uuidを使うプログラム test.js を書きます。
"use strict"; var uuid = require('uuid'); console.log(uuid.v1());
実行してみます。
$ node test.js 28bc71c0-c533-11e4-ad39-918ab091b1ed
モジュールのアンインストール
uninstallを実行すればOK
$ npm uninstall uuid
インストール済みモジュールを一覧表示する
ls でローカルにインストールしたモジュールの一覧を表示します。
$ npm ls test/app ├─┬ gulp@3.8.11 │ ├── archy@1.0.0 │ ├─┬ chalk@0.5.1 │ │ ├── ansi-styles@1.1.0 │ │ ├── escape-string-regexp@1.0.3 │ │ ├─┬ has-ansi@0.1.0 │ │ │ └── ansi-regex@0.2.1 │ │ ├─┬ strip-ansi@0.3.0 │ │ │ └── ansi-regex@0.2.1 │ │ └── supports-color@0.2.0 │ ├── deprecated@0.0.1 │ ├─┬ gulp-util@3.0.4 │ │ ├── array-differ@1.0.0 .. 略