この記事は、何を思ってかアドベントカレンダーに登録しちゃったので書き殴ってできた記事です。RxJS について詳しく知りた場合は、【わかりやすい】RxJSで始める関数リアクティブ・プログラミング が参考になります。あと、RxJS Advent Calendar 2015 もやっているみたいなので、そちらも参考に!
やぁ、みんな FRP しているかい?俺はしてないぜ。ぐへへへ。
さて、巷で流行っているという FRP ですけど、説明しろ言われてもぶっちゃけよくわかりません。でも、RxJS なる奴を使うと FRP になるという話を風の噂で聞きつけました。ということで FRP がよくわかってない私が RxJS で React.js を使ってみようという無謀な試みです。
RxJS って何?
Microsoft が開発した FRP ライブラリ ReactiveX(Reactive Extensions) の JavaScript バージョンです。他の言語用もあるので覚えておいて損はないと思います。なので、詳しいことは私に聞かないでください。
そもそも FRP とは繊維強化プラスチック(Fiber Reinforced Plastics)関数型リアクティブプログラミング(Functional Reactive Programing)のことで、関数型プログラミングで有りながら、リアクティブなプログラミングらしいです。端的に言うと単なる無限に流れる配列の処理です。
初めての RxJS + React.js
習うより慣れよ。と言うことで早速作ってみましょう。なんかカップラーメン食べたいので3分間待ってやるタイマーを作りたいと思います。
が、タダ作るだけでは面白くないので、今回は FRP と言うことで下記制限を付けたいと思います。
- 全て定数、つまり、
const
にします。つまり、var
とlet
は使用を禁止します。 - ES2015 の
class
は使用を禁止します。。React.createClass()
も実質クラスを作っているような物なので、使用を禁止します。 - ついでなので
function
も使用を禁止します。
普通の JavaScript では3.の時点で詰んでいるので、Babel を使って ES2015 + JSX で作成していきたいと思います。
準備
今回は babelify を使おうと思います。必要なライブラリをインストールしておきます。
npm install -g browserify
npm install --save-dev babelify
npm install --save-dev babel-preset-es2015
npm install --save-dev babel-preset-react
npm install --save-dev react
npm install --save-dev react-dom
npm install --save-dev rx
今回は現在の最新ライブラリを用いてますけど、バージョンが異なると動作が変わることがあるのでご注意ください。入れたバージョンを明記しておきます。
ライブラリ | バージョン |
---|---|
babelify | 7.2.0 |
babel-preset-es2015 | 6.1.18 |
babel-preset-react | 6.1.18 |
react | 0.14.3 |
react-dom | 0.14.3 |
rx | 4.0.7 |
babel-core 自体は6.3.2です。
例では timer.jsx を作成して、
browserify timer.jsx -o timer.js -t [ babelify --presets [ es2015 react ] ]
でコンパイルします。HTML は適当に下記のような物を用意しています。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>タイマー</title>
</head>
<body>
<h2>タイマー</h2>
<div id="content"></div>
<script src="timer.js"></script>
</body>
</html>
他のビルドツールを使うときは色々調べてください。ファイルはすべて UTF-8 にすることを忘れずに。
ではさっそく
3分間だけのタイマーを作ってみましょう。
import React from 'react';
import ReactDOM from 'react-dom';
import Rx from 'rx';
const Content = props => {
const remainMinutes = Math.floor(props.remain / 60);
const remainSeconds = props.remain % 60;
return (
<div>
{remainMinutes}:{("0" + remainSeconds).slice(-2)}
</div>
);
};
const intervalTime = 1000;
const countDown = 3 * 60;
Rx.Observable.timer(0, intervalTime)
.map(x => countDown - x)
.takeWhile(x => x > 0)
.subscribe(
x => ReactDOM.render(<Content remain={x}/>, document.getElementById('content')),
err => console.log('Error: ' + err),
() => ReactDOM.render(<Content remain={0}/>, document.getElementById('content'))
);
うお、何やってんだこれ?って感じですけど、解説していきます。
import React from 'react';
import ReactDOM from 'react-dom';
import Rx from 'rx';
ECAMScript2015 での外部モジュールの書き方です。var React = require('react');
とか書いた場合と同じになります。React.js では 0.14.0 からモジュールがいくつかに分かれるようになりました。今回は React と ReactDOM 両方が必要になりますので、両方読み込みます。Rx はまんま RxJS のライブラリです。
const Content = props => {
const remainMinutes = Math.floor(props.remain / 60);
const remainSeconds = props.remain % 60;
return (
<div>
{remainMinutes}:{("0" + remainSeconds).slice(-2)}
</div>
);
};
React.js 0.14.0 からできるようになった関数としての React コンポーネントです。render()
しかない React.createClass()
のような物です。細かい操作はできませんが、ただ render するだけなら十分な物になります。
const intervalTime = 1000;
const countDown = 3 * 60;
ただの定数定義です。Babel で変換後はただの var
になっちゃうんですけどね。
Rx.Observable.timer(0, intervalTime)
.map(x => countDown - x)
.takeWhile(x => x > 0)
.subscribe(
x => ReactDOM.render(<Content remain={x}/>, document.getElementById('content')),
err => console.log('Error: ' + err),
() => ReactDOM.render(<Content remain={0}/>, document.getElementById('content'))
);
やっとメインに来ました。Rx は最初に Observable を作ります。Observable は色んな種類が有り、1回だけの物から、繰りかえし、一定の範囲などなどいくつか用意されています。今回は Rx.Observable.timer
を使うことにしました。この Observable は第一引数ミリ秒後、第二引数ミリ秒毎に繰り返すという動作をします。今回は0ミリ秒後に一番目のイベントが、そして1000ミリ秒=1秒毎に次のイベントが起きるようになります。これは無限に流れる配列のような物になっています。
次に、この配列を変換していきます。map
はまぁ説明不要でしょう、普通の配列の map
と同じです。最初の値は回数(0から数える)になっているので、そのまま経過時間の秒数です。3分間から経過時間を引けば残り時間になります。takeWhile
は条件を満たす間まで続けると言うことです。条件を満たさない、つまり残り0秒になったら完了です。
最後に subscribe
します。subscribe
は三つの関数を引数を取ります。一つ目は通常時に実行される関数で、引数に変換してきた値がそのまま入ります。二つ目はエラーが起きたときに実行される関数です。最後はもう続く物がない、つまり、完了したときに呼び出される関数です。
ここで ReactDOM.render(...)
ってそう何度も呼び出していいの?って思った人がいるかも知れません。0.14.0 からはこういう風に何度もやってもいいようになったようです。setState()
を使って更新したときと同じような処理をしてくれるそうです。でも、詳しいことはよくわからないので私には聞かないでください。
発展させよう
タイマーはできましたけど、ブラウザで開いたら即実行です。開始ボタンとか再開ボタンとかつけたいですねー。あと、3分間だけだとどん兵衛が食べれません。これじゃウルトラマンとムスカ大佐しか使ってくれません。ということで、発展させてみました。
import React from 'react';
import ReactDOM from 'react-dom';
import Rx from 'rx';
const Content = props => {
return (
<div>
<Timer remain={props.remain}/>
<SelectTime state={props.state} time={props.time} onSetTime={props.onSetTime}/>
<Operation state={props.state} onStart={props.onStart}/>
</div>
);
};
const Timer = props => {
const remainMinutes = Math.floor(props.remain / 60);
const remainSeconds = props.remain % 60;
return (
<div>
{remainMinutes}:{("0" + remainSeconds).slice(-2)}
</div>
);
};
const Operation = props => {
if (props.state === 'init') {
return <button onClick={props.onStart}>開始</button>;
} else if (props.state === 'running') {
return <button disabled>実行中</button>;
} else if (props.state === 'finished') {
return <button onClick={props.onStart}>再開</button>;
} else {
return <button disabled>???</button>;
}
}
const SelectTime = props => {
const options = [3, 4, 5].map(min => <option key={min} value={min * 60}>{min}分間</option>);
if (props.state === 'init' || props.state === 'finished') {
return <select defaultValue={props.time} onChange={props.onSetTime}>
{options}
</select>;
} else {
return <select defaultValue={props.time} disabled>
{options}
</select>;
}
}
const intervalTime = 1000;
const defaultTime = 3 * 60;
const contentElement = document.getElementById('content');
const subject = new Rx.Subject();
const onSetTime = event => {
subject.onNext(parseInt(event.target.value));
}
subject.subscribe(time => {
const startSubject = new Rx.Subject();
const onStart = event => {
startSubject.onNext(time);
}
ReactDOM.render(
<Content remain={time} state="init" time={time} onStart={onStart} onSetTime={onSetTime}/>,
contentElement);
startSubject.subscribe(countDown => {
Rx.Observable.timer(0, intervalTime)
.map(x => countDown - x)
.takeWhile(x => x > 0)
.subscribe(
x => ReactDOM.render(
<Content remain={x} state="running" time={time} onStart={onStart} onSetTime={onSetTime}/>,
contentElement),
err => console.log('Error: ' + err),
() => ReactDOM.render(
<Content remain={0} state="finished" time={time} onStart={onStart} onSetTime={onSetTime}/>,
contentElement)
);
});
});
subject.onNext(defaultTime);
なにこれ、どうなってんの?と言われてもわかりません。というか、私自身もこの書き方でいいのかわかりません。あかんじゃん。
とりあえず、new Rx.Subject()
で subject を作って、onNext()
を呼び出せばいいらしいです。で、次々イベント発火としているんですけど、入れ子じゃなくてたぶんもっとうまいやり方があるんだと思います。なんか関数だけでコンポーネント作る例が少なくてよくわからないんだな。うん、誰かがきっとやってくれるはず。
まとめ
ぶっちゃけ、Rx がよくわかってないです。ちょっと無謀すぎたっす。あと Timer のインターバルは全然信用できないので3分間とは限らないです。きっともっといい記事を誰かがそのうち書いてくれるでしょう。というか書いて、俺が読むから。
期日までに間に合わなかったおまけ
import React from 'react';
import ReactDOM from 'react-dom';
import Rx from 'rx';
import 'babel-polyfill';
// 便利なメソッド
const formatInt = (i, digit) => ("0".repeat(digit - 1) + i).slice(-digit);
const divInt = (x, y) => Math.floor(x / y);
const remInt = (x, y) => Math.floor(x % y);
const createTime = (min, sec = 0, msec = 0) => ({min: min, sec: sec, msec: msec});
const msecToTime = m => createTime(divInt(m, (60 * 1000)), remInt(divInt(m, 1000), 60), remInt(m, 1000));
const timeToMsec = t => (t.min * 60 + t.sec) * 1000 + t.msec;
const timeSubMsec = (t, msec) => msecToTime(timeToMsec(t) - msec);
const timeIsZero = t => timeToMsec(t) === 0;
const timePositive = t => timeToMsec(t) > 0 ? t : createTime(0);
const mergeObject = (a, b) => Object.assign({}, a, b);
// 定数
const STATE_MAP = new Map([
'init',
'run',
'finished',
'sleep'
].map(name => [name, Symbol(name)])
);
const INTERVAL_TIME = 10;
const DEFAULT_COUNT_TIME = createTime(3);
const CONTENT_ELEMENT = document.getElementById('content');
// React コンポーネント
const Content = props => {
return (
<div>
<Timer time={props.remainTime}/>
<Operation state={props.state} countTime={props.countTime}
onStartTimer={props.onStartTimer} onStopTimer={props.onStopTimer}
onResetTimer={props.onResetTimer} onSetTime={props.onSetTime}/>
</div>
);
};
const Timer = props => {
return (
<div>
<span style={{fontSize: 'x-large'}}>{props.time.min}:{formatInt(props.time.sec, 2)}</span>
<span style={{fontSize: 'small'}}>.{formatInt(divInt(props.time.msec, 10), 2)}</span>
</div>
);
};
const Operation = props => {
switch (props.state) {
case STATE_MAP.get('init'):
return (
<div>
<SelectTime enabled={true} countTime={props.countTime} onSetTime={props.onSetTime}/>
<button onClick={props.onStartTimer}>開始</button>
<button disabled>リセット</button>
</div>
);
break;
case STATE_MAP.get('run'):
return (
<div>
<SelectTime enabled={false} countTime={props.countTime} onSetTime={props.onSetTime}/>
<button onClick={props.onStopTimer}>停止</button>
<button disabled>リセット</button>
</div>
);
break;
case STATE_MAP.get('finished'):
return (
<div>
<SelectTime enabled={true} countTime={props.countTime} onSetTime={props.onSetTime}/>
<button disabled>完了</button>
<button onClick={props.onResetTimer}>リセット</button>
</div>
);
break;
case STATE_MAP.get('sleep'):
return (
<div>
<SelectTime enabled={true} countTime={props.countTime} onSetTime={props.onSetTime}/>
<button onClick={props.onStartTimer}>再開</button>
<button onClick={props.onResetTimer}>リセット</button>
</div>
);
break;
default:
console.warn('不明なステータス');
return (
<div>
<SelectTime enabled={true} countTime={props.countTime} onSetTime={props.onSetTime}/>
<button onClick={props.onStartTimer}>開始</button>
<button onClick={props.onResetTimer}>リセット</button>
</div>
);
break;
}
}
const SelectTime = props => {
const onSetTimeMin = min => props.onSetTime(createTime(min, props.countTime.sec));
const onSetTimeSec = sec => props.onSetTime(createTime(props.countTime.min, sec));
return (
<div>
カウント時間:
<InputTimeFragment enabled={props.enabled} value={props.countTime.min} onSetValue={onSetTimeMin}/>
分
<InputTimeFragment enabled={props.enabled} value={props.countTime.sec} onSetValue={onSetTimeSec}/>
秒
</div>
);
};
const InputTimeFragment = props => {
if (props.enabled) {
const onInput = event => {
const num = Number.parseInt(event.target.value);
if ([...Array(60).keys()].indexOf(num) >= 0) {
props.onSetValue(num);
}
};
return <input type="number" defaultValue={props.value} min="0" max="59" onInput={onInput}/>
} else {
return <input type="number" defaultValue={props.value} min="0" max="59" disabled/>
}
};
// Subject と Observable
const countTimeSubject = new Rx.BehaviorSubject(DEFAULT_COUNT_TIME);
const stateSubject = new Rx.BehaviorSubject(STATE_MAP.get('init'));
const timerSource = Rx.Observable.interval(INTERVAL_TIME);
// Subject に対する onNext のフック
const setTime = time => countTimeSubject.onNext(time);
const startTimer = () => stateSubject.onNext(STATE_MAP.get('run'));
const stopTimer = () => stateSubject.onNext(STATE_MAP.get('sleep'));
const resetTimer = () => stateSubject.onNext(STATE_MAP.get('init'));
const finishTimer = () => stateSubject.onNext(STATE_MAP.get('finished'));
// React レンダリング
const reactRender = (state, remainTime, countTime) => {
ReactDOM.render(
<Content state={state}
remainTime={remainTime} countTime={countTime}
onStartTimer={startTimer} onStopTimer={stopTimer} onResetTimer={resetTimer}
onSetTime={setTime}/>,
CONTENT_ELEMENT);
if (state !== STATE_MAP.get('finished') && timeIsZero(remainTime)) {
finishTimer();
}
};
// くっつけて購読
Rx.Observable.just(-1).merge(timerSource)
.combineLatest(countTimeSubject, stateSubject, (s, cts, ss) => ({countTime: cts, state: ss}))
.timeInterval()
.map(x => mergeObject(x.value, {interval: x.interval}))
.scan((acc, x) => {
if (x.state === STATE_MAP.get('init')) {
return mergeObject(x, {remainTime: x.countTime});
} else if (acc.state === STATE_MAP.get('run')) {
return mergeObject(x, {remainTime: timePositive(timeSubMsec(acc.remainTime, x.interval))});
} else {
return mergeObject(x, {remainTime: acc.remainTime});
}
}, {state: STATE_MAP.get('init'), remainTime: DEFAULT_COUNT_TIME})
.subscribe(x => reactRender(x.state, x.remainTime, x.countTime));
subscribe の入れ子を頑張って解消してみました。本当は interval を published にして connect したり dispose したりしたいんだけど、connect したときの Disposable の渡し方がよくわからないです。あと関数形式の React コンポーネントって shouldComponentUpdate みたいなので効率化できないのかな?何回も同じもの作りまくりでパフォーマンスが・・・。