Reactでストップウォッチを作ってみた

Reactで簡単なストップウォッチを作ってみました。(サンプル

ストップウォッチの概要

ごく一般的なストップウォッチの機能の他にラップタイムも記録できます。

react-stopwatch-sample01

以下がコードです。内容について以降で解説します。

<html>
  <head>
    <title>React StopWatch sample</title>
    <meta name="viewport" content="width=device-width">
    <script src="//fb.me/react-0.13.1.js"></script>
    <script src="//fb.me/JSXTransformer-0.13.1.js"></script>
  </head>
  <body>
  <script type="text/jsx">
    var StopWatch = React.createClass({
      getInitialState: function() {
        return {
          isStart: false,
          elapsed: 0,
          diff: 0,
          laps: [],
        };
      },
      componentWillUnmount: function() { // clear timer
        clearInterval(this.state.timer);
        this.setState({timer: undefined});
      },
      tick: function() {
        var elapsed = Date.now() - this.state.start + this.state.diff;
        this.setState({elapsed: elapsed});
      },
      getTimeSpan: function(elapsed) { // 754567(ms) -> "12:34.567"
        var m = String(Math.floor(elapsed/1000/60)+100).substring(1);
        var s = String(Math.floor((elapsed%(1000*60))/1000)+100).substring(1);
        var ms = String(elapsed % 1000 + 1000).substring(1);
        return m+":"+s+"."+ms;
      },
      onClick: function() {
        if(!this.state.isStart) { // start
          var timer = setInterval(this.tick, 33);
          this.setState({
            isStart: true,
            timer: timer,
            start: new Date(),
          });
        } else { // pause
          clearInterval(this.state.timer);
          this.setState({
            timer: undefined,
            isStart: false,
            diff: this.state.elapsed,
          });
        }
      },
      setLap: function() {
        var lapElapsed = this.state.laps.length ? this.state.laps[0].elapsed : 0;
        var lapTitle = "Lap"+(this.state.laps.length+1);
        var lapTime = lapTitle+": "+this.getTimeSpan(this.state.elapsed - lapElapsed)
        var lapElem = { label: lapTime, elapsed: this.state.elapsed };
        this.setState({laps: [lapElem].concat(this.state.laps)});
      },
      reset: function() {
        clearInterval(this.state.timer);
        this.setState({
          timer: undefined,
          isStart: false,
          elapsed: 0,
          diff: 0,
          laps: [],
        });
      },
      render: function() {
        return (
          <div>
            <h1>{this.getTimeSpan(this.state.elapsed)}</h1>
            <button onClick={this.onClick} style={style.button}>
              {this.state.isStart ? "pause" : "start"}
            </button>
            <button onClick={this.setLap} style={style.button}>lap</button>
            <button onClick={this.reset} style={style.button}>reset</button>
            <ul style={style.lap}>
              {this.state.laps.map(function(lap) {
                return <li>{lap.label}</li>;
              })}
            </ul>
          </div>
        );
      }
    });

    var style = {
      button: {
        fontSize: 20,
        height: 44,
        width: 88,
        margin: 5,
      },
      lap: {
        fontSize: 28,
        padding: 5,
        listStyleType: 'none',
      }
    };

    React.render( <StopWatch />, document.body );
  </script>
  </body>
</html>

実行方法

上記のコードを適当な名前(index.html等)で保存し、ブラウザで開くと動作します。こちらで動作サンプルが見れます。
コードは通常のJavaScriptではなく、JSXというJavaScriptを拡張してReactコンポーネントを書きやすくした言語で記述しています。(※1)

htmlのヘッダにある以下のコード

<script src="http://fb.me/react-0.13.1.js"></script>
<script src="http://fb.me/JSXTransformer-0.13.1.js"></script>

は、React本体およびJSXTransformer(html内のJSXをJavaScriptに変換する)を読み込んでおり、これによって、htmlに直接JSXのコードを書いてもブラウザ上で動作させることができます。(※2)

this.setState

Reactの特徴の1つは、アプリの状態が変化するごとに、アプリの全要素を再描画するというものです。
このストップウォッチでは、start/pause/lap/clearボタンをクリックすることで状態が変化します。また、ストップウォッチ特有の状態変化として、時間経過(このアプリでは33ms周期)でも状態が変化します。
プログラムにおいては、これらの状態変化のタイミングで、this.setStateメソッドを経由して状態を保持する変数を変更するように記述しています。this.setStateを経由することで、React側に状態変化があったことを通知でき、Reactはすべてのコンポーネントをその時点の状態をもとに再描画します。

VirtualDOM

状態が変化する都度すべてのコンポーネントを再描画していると、コンポーネントが多い場合に性能が低下するため、ReactではVirtualDOM(仮想DOM)という技術で性能低下を回避しています。
イメージ的には、まずメモリ上に仮想のDOMとしてコンポーネントを構築し、その後、仮想のDOMと実際のDOMとの差分を計算し、最終的に実際のDOMに対して必要な差分変更だけを適用します。
こうすることで、プログラマは複雑な状態遷移や処理の分岐をあまり考慮しなくてもよくなります。

Reactにおけるタイマーイベントの利用

ユーザの操作が無くても時間の経過が表示されるように、ストップウォッチ動作中は、タイマー(setInterval)により33ms(だいたい30fps)ごとに、tickメソッドを呼び出して経過時間(elapsed)を更新し、ストップウォッチのコンポーネントを再描画しています。

tickメソッドでは以下のように経過時間を更新します。(ストップウォッチをpauseした状態から再開できるように、pauseした時点の経過時間をthis.state.diffに保持しておき、経過時間に加えるようにしています)

tick: function() {
  var elapsed = Date.now() - this.state.start + this.state.diff;
  this.setState({elapsed: elapsed});
},

また、タイマーを停止できるように、計測開始時にsetIntervalの戻り値(タイマーID)をstateとして保持しておきます。(isStartやstart等、他のstateもあわせて更新しています)

var timer = setInterval(this.tick, 33);
this.setState({
  isStart: true,
  timer: timer,
  start: new Date(),
});

ストップウォッチを一時停止した際には、clearIntervalによりタイマーを停止しstateを更新します。

clearInterval(this.state.timer);
this.setState({
  timer: undefined,
  isStart: false,
  diff: this.state.elapsed,
});

さらに、このアプリではあまり意味はありませんが、コンポーネントがDOMから削除された際に呼び出されるcomponentWillUnmountイベントにおいても、タイマーが動きっぱなしにならないようにclearIntervalによりタイマーを停止しstateを更新しておきます。

componentWillUnmount: function() { // clear timer
  clearInterval(this.state.timer);
  this.setState({timer: undefined});
},

動的なデータの追加(ラップタイム記録)

ラップタイムはthis.state.lapsにラップタイムを配列として保持することで実現しています。
lapボタンがクリックされた際にはstateの変更(配列の先頭へラップライムを追加)をしているだけです。
stateの変更をトリガに画面が再描画され、意図したとおりにラップタイムのリストが表示されます。

ラップタイムのリストの描画にはmapメソッドを使って、配列の要素に対して繰返し同じ処理を実行しています。

{this.state.laps.map(function(lap) {
  return <li key={lap.id}>{lap.label}</li>;
})}

スタイルの適用

コンポーネントの要素にスタイルを指定することもできます。
font-sizelist-style-typeのようなハイフンで連結している属性名は、fontSizelistStyleTypeのようにキャメルケースで指定します。(おそらく、JavaScriptとして解釈すると変数の減算(-)と区別できないため)

var style = {
  button: {
    fontSize: 20,
    height: 44,
    width: 88,
    margin: 5,
  },
  lap: {
    fontSize: 28,
    padding: 5,
    listStyleType: 'none',
  }
};

JSXのタグへの指定は以下のように行います。

<button onClick={this.reset} style={style.button}>reset</button>

経過時間(ミリ秒)の整形

プログラム内部では経過時間をミリ秒で保持していますが、ストップウォッチっぽくするために、
ミリ秒から整形するために、getTimeSpanメソッドを定義しています。
コードのコメントにも書いていますが、754567msを12:34.567という文字列に変換します。

getTimeSpan: function(elapsed) { // 754567(ms) -> "12:34.567"
  var m = String(Math.floor(elapsed/1000/60)+100).substring(1);
  var s = String(Math.floor((elapsed%(1000*60))/1000)+100).substring(1);
  var ms = String(elapsed % 1000 + 1000).substring(1);
  return m+":"+s+"."+ms;
},

(100や1000を足してsubstring(1)をしているのは、0パディングのため)

おわりに

簡単なストップウォッチでも、start/pasue時の処理だけでなく、clearボタンが押された場合には経過時間をリセットし、さらにラップタイムをクリアしたりと意外と考慮する要素があります。
まだ、ごく簡単なアプリを作っただけですが、Reactを使うと、基本的に状態変化があった場合はすべて再描画されることを念頭にコードを記述するため、アプリを作る際の考慮漏れを防ぎやすくなるように感じました。
また先日、ネイティブアプリをReactで書けるという、React Nativeのリリースがあったので試してみようかと思っています。

補足

※1. JSXという名前のDeNAが開発したAltJSがありますが、それとは別物です。
※2. プロダクション環境ではブラウザ上でJSXからJavaScriptへ変換せずに事前にJavaScriptへコンパイルすることが推奨されています。

参考

以下のサイトを参考にさせて頂きました。
一人React.js Advent Calendar 2014
Ajaxを劇的に簡単にするReact.js

2015/03/30 ラップタイム機能を修正。

スポンサーリンク
レクタングル(大)

シェアする

  • このエントリーをはてなブックマークに追加

フォローする

スポンサーリンク
レクタングル(大)