WebDesign Dackel

React.jsで消費税の計算アプリを作ってみた

React.jsで消費税の計算アプリを作ってみた

Hatena0
Google+0
Pocket0
Feedly0

はじめに

最近ちょこちょこと触ってきたReact.jsですが、まだ小さいコンポーネントをいくつか作ってみただけでした。
そろそろ実用的なサンプルを作ってみたいなと思ったので、シンプルな機能の消費税の計算アプリを作ってみました。

ちなみに実装について、世間的にはES6が流行っているみたいですが今回使用しているのはCoffeeScriptです…!

サンプル

計算機アプリの動作イメージ

DEMO

リポジトリ

コードは一応記事の下のほうに載せますが、下記リポジトリに全ファイルをあげていますので、興味のある方は下記よりご確認頂けます。

tsuyoshiwada/react-tax-calculator

実装した機能

実現する機能としてはシンプルなものを考えていましたが、ある程度の使い勝手を持たせてやりたかったので、下記の様な機能を実装してみました。

  • 入力した金額の税込み/税抜き価格を表示する
  • 入力値はカンマ区切りの数値でもOK
  • 税率や表記方法、計算方法が設定から変更できる
  • 設定した変更と入力した金額はブラウザのリロード後も保持される
  • タブの切り替えも同様

設定と入力金額が保持されるのが、主な機能かなと思います。

ブラウザをリロードしても値を保持する機能は、URLにパラメータとして持たせたり、Cookieを使ったりと色々方法はあると思いますが、今回は手軽に実装できるという点からlocalstorageを使用しました!

使用したライブラリと外部コンポーネント

package.jsondependenciesは以下のようになっています。

"dependencies": {
  "classnames": "^2.1.2",
  "numeral": "^1.5.3",
  "react": "^0.13.3",
  "react-github-fork-ribbon": "^0.3.0",
  "react-select": "^0.4.9",
  "react-tabs": "^0.2.0",
  "store": "^1.3.17"
}

以下、それぞれの使用用途です。
React本体は割愛します。

ライブラリ

  • classnames:状態を表すクラス名を生成する時に使っています
  • numeral:カンマ区切りなど数値のフォーマット
  • storelocalstorageの操作

外部コンポーネント

どうでもいいですが、よくみる「Fork me on GitHub」のボタンがGitHub Ribbonsと呼ばれることをはじめて知りました…。Reactのコンポーネントとして公開されているものがあったので試しに使ってみました。

コンポーネントの構成

コンポーネントのイメージ

.
└── App
    ├── About #アプリについて説明
    ├── GitHubForkRibbon #Fork me~
    ├── TaxCalculator #入力と出力
    └── TaxSetting #設定

TaxSettingAboutはそれぞれタブで切り替わるようになっています。

コード全体

HTML

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
  <title>Tax Calculator ~ 消費税の計算アプリ</title>
  <link rel="stylesheet" href="./css/style.css">
</head>
<body>
  <div id="app"></div>
  <script src="./js/app.bundle.js"></script>
</body>
</html>

とってもシンプルなHTMLにしました。
div#appにアプリケーションが入ってきます。CSSについては記事には書きません。

JavaScript (CoffeeScript)

app.cjsx

React = require("react/addons")
App = require("./components/App")


React.render(
  <App />,
  document.getElementById("app")
)

ルートコンポーネントとなるAppを実際のDOMにマウントしているだけです。

App.cjsx

store = require("store")
React = require("react")
ReactTabs = require("react-tabs")
GitHubForkRibbon = require("react-github-fork-ribbon")
TaxSetting = require("./TaxSetting")
TaxCalculator = require("./TaxCalculator")
About = require("./About")
FormatType = require("../constants/FormatType")
DefaultSetting = require("../constants/DefaultSetting")


# alias for ReactTabs components
Tab = ReactTabs.Tab
Tabs = ReactTabs.Tabs
TabList = ReactTabs.TabList
TabPanel = ReactTabs.TabPanel


App = React.createClass
  getInitialState: ->
    rate: store.get("rate") || DefaultSetting.rate
    rule: store.get("rule") || DefaultSetting.rule
    format: store.get("format") || DefaultSetting.format

  render: ->
    price = store.get("price") || DefaultSetting.price
    currentTab = store.get("currentTab") || 0

    <div>
      <GitHubForkRibbon
        href="https://github.com/tsuyoshiwada/react-tax-calculator"
        position="right"
        color="black">
        Fork me on GitHub
      </GitHubForkRibbon>

      <div className="header">
        <div className="container">
          <h1><i className="fa fa-dot-circle-o"></i> Tax Calculator</h1>
          <TaxCalculator
            price={price}
            rate={@state.rate}
            rule={@state.rule}
            format={FormatType[@state.format]}
            onPriceChange={@handlePriceChange} />
        </div>
      </div>

      <div className="container">
        <Tabs onSelect={@handleTabSelect} selectedIndex={currentTab}>
          <TabList>
            <Tab><i className="fa fa-cog"></i> Settings</Tab>
            <Tab><i className="fa fa-info-circle"></i> About</Tab>
          </TabList>
          <TabPanel>
            <TaxSetting
              rate={@state.rate}
              rule={@state.rule}
              format={@state.format}
              onRateChange={@handleRateChange}
              onRuleChange={@handleRuleChange}
              onFormatChange={@handleFormatChange} />
          </TabPanel>
          <TabPanel>
            <About />
          </TabPanel>
        </Tabs>
      </div>

      <p className="copyright">Copyright &copy; <a href="https://github.com/tsuyoshiwada">tsuyoshi wada</a> All Right Reserved.</p>
    </div>

  handlePriceChange: (price) ->
    store.set("price", price)

  handleRateChange: (rate) ->
    store.set("rate", rate)
    @setState(rate: rate)

  handleRuleChange: (rule) ->
    store.set("rule", rule)
    @setState(rule: rule)

  handleFormatChange: (format) ->
    store.set("format", format)
    @setState(format: format)

  handleTabSelect: (current, prev) ->
    store.set("currentTab", current)


module.exports = App

入力の受付+出力用のTaxCalculator、設定用のTaxSettingAboutコンポーネントをrenderしています。
出来るだけそれぞれのコンポーネント間で依存関係を作らないように、ここで各コンポーネントからの値を受け取り、他のコンポーネントに値を渡すようにしています。

ここらへんの実装について特に自信が無くて、もっと慣れていかないとなぁ〜って気がしています。

また、入力値や設定をlocalstorageへ保存する処理もここで行います。

TaxSetting.cjsx

React = require("react")
Select = require("react-select")
FormatType = require("../constants/FormatType")
DefaultSetting = require("../constants/DefaultSetting")


TaxSetting = React.createClass
  propTypes:
    rate: React.PropTypes.number
    rule: React.PropTypes.string
    format: React.PropTypes.string
    onRateChange: React.PropTypes.func
    onRuleChange: React.PropTypes.func
    onFormatChange: React.PropTypes.func

  render: ->
    ruleOptions = [
      {value: "floor", label: "切り捨て"}
      {value: "ceil",  label: "切り上げ"}
      {value: "round", label: "四捨五入"}
    ]

    # Selectコンポーネントに`0,0`などのvalueを与えると挙動が変わってしまうため
    # FormatTypeとして外部化して、そのキーを渡すようにする
    formatOptions = [
      {value: "TYPE_1", label: "¥12000"}
      {value: "TYPE_2", label: "¥12,000"}
      {value: "TYPE_3", label: "¥+12,000"}
    ]


    <div className="tax-setting">

      <div className="tax-setting__row">
        <div className="tax-setting__col">
          <div className="input-group">
            <span className="input-group__addon">税率</span>
            <input
              type="number"
              pattern="[0-9]*"
              className="input-group__control"
              placeholder="00"
              value={@props.rate}
              onChange={@handleRateChange} />
            <span className="input-group__addon">%</span>
          </div>
        </div>

        <div className="tax-setting__col">
          <div className="input-group">
            <span className="input-group__addon">計算方法</span>
            <Select
              value={@props.rule}
              className="input-group__control"
              clearable={false}
              searchable={false}
              options={ruleOptions}
              onChange={@handleRuleChange} />
          </div>
        </div>

        <div className="tax-setting__col">
          <div className="input-group">
            <span className="input-group__addon">表記</span>
            <Select
              value={@props.format}
              className="input-group__control"
              clearable={false}
              searchable={false}
              options={formatOptions}
              onChange={@handleFormatChange} />
          </div>
        </div>
      </div>

      <button type="button" className="tax-setting__clear" onClick={@handleClearClick}>設定を初期化</button>
    </div>

  handleRateChange: (e) ->
    value = e.target.value.trim()
    value = parseInt(e.target.value) if value != ""
    value = "" if isNaN(value)
    @props.onRateChange?(value)

  handleRuleChange: (value) ->
    @props.onRuleChange?(value)

  handleFormatChange: (value) ->
    @props.onFormatChange?(value)

  handleClearClick: (e) ->
    e.preventDefault()

    {rate, rule, format} = DefaultSetting
    @props.onRateChange?(rate)
    @props.onRuleChange?(rule)
    @props.onFormatChange?(format)



module.exports = TaxSetting

各設定を親コンポーネントへ値を渡すようになっています。
コメントにもちょっと書きましたが、react-selectを使った時、optionsvalue0,0など数値のみの値を渡すと、上手く処理されないことがありました。(バグですかね??)

実装としてちょっと微妙な感じもしましたが、外部のオブジェクト(FormatType)として値を持たせ、そのキーを渡すことで解決しています。

TaxCalculator.cjsx

numeral = require("numeral")
classNames = require("classnames")
React = require("react")


TaxCalculator = React.createClass
  propTypes:
    price: React.PropTypes.any
    rate: React.PropTypes.number.isRequired
    rule: React.PropTypes.string.isRequired
    format: React.PropTypes.string.isRequired
    onPriceChange: React.PropTypes.func

  getInitialState: ->
    price: @props.price || 0

  render: ->
    valueLink = 
      value: @state.price
      requestChange: @handlePriceChange

    priceClearClasses = classNames(
      "tax-calculator__price__clear": true
      "is-show": @state.price.toString().length > 0
    )

    <div className="tax-calculator">

      <div className="input-group input-group--x-lg tax-calculator__price">
        <span className="input-group__addon">&yen;</span>
        <input
          type="text"
          pattern="[0-9]*"
          className="input-group__control"
          placeholder="計算する金額"
          valueLink={valueLink}/>
        <button
          type="button"
          className={priceClearClasses}
          onClick={@handlePriceClearClick}>
          &times;
        </button>
      </div>

      <div className="tax-calculator__results">
        <div className="input-group input-group--lg">
          <span className="input-group__addon">&yen;</span>
          <input
            type="text"
            readOnly={true}
            className="input-group__control"
            value={@calcPrice(true)}
            onClick={@handleResultClick} />
          <span className="input-group__addon">税込</span>
        </div>
        <div className="input-group input-group--lg">
          <span className="input-group__addon">&yen;</span>
          <input
            type="text"
            readOnly={true}
            className="input-group__control"
            value={@calcPrice(false)}
            onClick={@handleResultClick} />
          <span className="input-group__addon">税抜</span>
        </div>
      </div>

    </div>

  handlePriceChange: (newValue) ->
    @setState(price: newValue)
    @props.onPriceChange?(newValue)

  handlePriceClearClick: (e) ->
    e.preventDefault()
    @setState(price: "")
    @props.onPriceChange?("")

  handleResultClick: (e) ->
    e.target.select()


  # 整形済みの計算金額を返す
  # @param int 
  # @return string
  calcPrice: (includeTax=true) ->
    price = numeral().unformat(@state.price)
    rate = @props.rate / 100 + 1
    mathMethod = Math[@props.rule]

    if isNaN(price)
      return 0
    else
      val = if includeTax then price * rate else price / rate
      val = mathMethod(val)
      return numeral(val).format(@props.format)


module.exports = TaxCalculator

ここでは金額の入力と、出力を担当します。
出力の設定は親コンポーネント(App)から受け取るようになっています。

About.cjsx

React = require("react")


About = React.createClass
  render: ->
    <div>
      <h2><i className="fa fa-paragraph"></i> 使い方</h2>
      <p>
        「Tax Calculator」は税込み、税抜きの計算をそれぞれ行うアプリケーションです。<br />
        使い方は至ってシンプルで、税込み、税抜きが知りたい金額を画面上部にある入力欄に入力するだけです。
      </p>
      <p>
        デフォルトでは税率8%、切り捨てを使った計算を行いますが、Settingタブより変更していただくことが可能になっています。<br />
        また、お好みに合わせて表示される金額の表記方法を変更することができます。
      </p>
      <h2><i className="fa fa-paragraph"></i> Tax Calculatorについて</h2>
      <p>
        このアプリケーションは<a href="http://facebook.github.io/react/">react.js</a>と<a href="http://coffeescript.org/">CoffeeScript</a>を使って制作しているオープンソースソフトウェアです。
      </p>
    </div>


module.exports = About

機能は持たず、単純にテキストコンテンツを持ったものです。

FormatType.coffee

module.exports = 
  TYPE_1: "0"
  TYPE_2: "0,0"
  TYPE_3: "+0,0"

TYPE_1~3react-selectvalueとして持たせるようにしています。

DefaultSetting.coffee

module.exports = 
  price: 10000
  rate: 8
  rule: "floor"
  format: "TYPE_2"

設定と入力値のデフォルト値はここで定義しています。

React.jsを使って実際にアプリを作ってみた感想

今回作成したアプリはとてもシンプルなものだったので、まだまだReactの本領発揮!とはいかないと思いますが、下記のような点においてイイネ!と思いました。

  • コンポーネント単位で開発を進めることができる
  • そうすることでCSSのコンポーネント化もしやすい
  • 渡ってくるデータによって出力されるDOMが常に一定
  • そのためデータがどんな状態か(あまり)知らなくても良い

とにかく、DOMがどうこうとか、パフォーマンスを考慮して…みたいなところをあまり気にせず、実現する機能に集中できるという点がReactの良さなんだなぁと改めて感じました。


本当は計算結果をメモ付きで保存しておく、みたいな機能を付けたかったのですが、とりあえずシンプルなもの、ということで作り始めたものだったのでここらへんで実装をやめました。笑

今度はぜひFluxにチャレンジしてみたいと思っています。

こうしたほうがいいよ〜という点などありましたらコメント頂けたら嬉しいです!