同一ソースツリー上でReactNativeでWeb&iOS&Androidのハイブリッドアプリを開発するために使った3つのテクニック

今回、弊社の開発・運営する[日報共有アプリ「gamba!」](http://www.getgamba.com)をReact Nativeで全面的に作り直しました。

gamba!は約2年前に、ちょうど話題になりかけていたReact(0.14ベース)で全面的に作り直していたのですが、当時はReduxも生まれておらず、技術的にはまだまだ未成熟な状況でした。

この2年間のうちに、さまざまな問題を抱えることになりました。

* コンポーネントの中にビューとロジックが混在
* プログラムの見通しが非常に悪い
* ロジックのテストが十分にできない

現状のコードのままではこれ以上のサービスの改善は難しいと判断し、今回全面的に作り直すことになりました。

今回の開発では、このような目標を立てて開発を進めてきました。

* ビューとロジックを完全に分離し(redux)、
* ロジックについてはすべてテストコードを用意する(jest)
* ReactによるWebアプリと、React Nativeによるスマホアプリ(iOS/Android)を提供し、
* Webアプリとスマホアプリでロジック部分のコードを完全に共有する

ようやく先日、React Native版のスマホアプリのリリースが完了したところですが、今回の開発を振り返ってみて、プロジェクト内で私たちが編み出した3つのテクニックについて紹介します。

# package.jsonをWebアプリ用とNativeアプリ用に分離する
## ReactとReact Nativeのバージョン問題
ReactのみのWebアプリとReact Nativeのスマホアプリのコードを一つのソースツリー上で開発する上で、まず最初につまずいたのが、本家ReactとReact Nativeのバージョンの違いでした。

今回の開発を始めた2017年7月ごろの状況では、このような依存関係になっていました。

* Webアプリで採用した本家Reactのバージョン: 15.6.0
* React Nativeのバージョン: 0.45.1
* React Nativeが要求するReactのバージョン: 16.0.0.alpha-12

React 16.0.0.alpha-12というバージョンは多くの不具合があり、そのまま採用するのは難しい代物です。ですが、React Nativeとコードを共通化するにはどうしても、Reactのバージョンを揃える必要があります。

## package.jsonを切り替える
そこでとったのが、React Nativeにバージョンを合わせたpackage.jsonと、Webアプリ用のpackage.jsonをプロジェクト内に両方用意し、シェルスクリプトで切り替える、という手法です。

この方法であれば、WebアプリとReact Nativeアプリで異なるバージョンのパッケージを混在させることができ、バージョンのやっかいなコンフリクトを回避することができます。

私たちが使ったのは、こんなスクリプトです。

```bash:switch-package.sh
#!/bin/sh

# package.jsonとnode_modulesをWeb版とNative版で切り替えて使用する
#  usage:
#    bin/switch-package native : Native版のpackage.jsonを有効にする
#    bin/switch-package web :    Web版のpackage.jsonを有効にする

CHECK_DIFF_FILE="package.json"
SWITCH_FILES="package.json yarn.lock package-lock.json"
SWITCH_DIRS="node_modules"

if [ $1 = 'native' ]; then
  FILE_LABEL="PACKAGE-NATIVE"
  OLD_FILE_LABEL="PACKAGE-WEB"
  if [ -f $FILE_LABEL ]; then
    echo 'すでにnative版になっています。'
    exit
  fi
  CURRENT="web"
  CHANGE_TO="native"
else
  FILE_LABEL="PACKAGE-WEB"
  OLD_FILE_LABEL="PACKAGE-NATIVE"
  if [ -f $FILE_LABEL ]; then
    echo 'すでにweb版になっています。'
    exit
  fi
  CURRENT="native"
  CHANGE_TO="web"
fi

if [ ! -f $CHECK_DIFF_FILE ]; then
  cp $CHECK_DIFF_FILE-$CURRENT $CHECK_DIFF_FILE
fi

if diff $CHECK_DIFF_FILE $CHECK_DIFF_FILE-$CURRENT > /dev/null; then
  rm -f $OLD_FILE_LABEL
  for f in $SWITCH_DIRS; do
    if [ -d $f-$CHANGE_TO ]; then
      mv $f $f-$CURRENT
      mv $f-$CHANGE_TO $f
    else
      if [ -d $f ]; then
        mv $f $f-$CURRENT
      fi
    fi
  done
  for f in $SWITCH_FILES; do
    if [ -f $f-$CHANGE_TO ]; then
      cp $f-$CHANGE_TO $f
    fi
  done
  yarn install
  touch $FILE_LABEL
  echo $CHANGE_TO 版の package.json に切り替えました。
else
  echo $CHECK_DIFF_FILE は変更されています。変更点を $CHECK_DIFF_FILE-$CURRENT に反映してから切り替えてください。
fi
```

あとは、開発対象をWebとReact Nativeの間で切り替える時に、以下のコマンドで`package.json`や`node_modules`を切り替えます。

```shell-session
$ switch-package web # Webに切り替え
```

今後、Reactはバージョン16が正式リリースされ、そうなればReact自体をWebとReact Nativeで共有することができるようになると思います。ただ、WebとReact Nativeでは使うパッケージがまったく異なるため、`package.json`をそれぞれに分離しておくのは得策だと思っています。

# ReduxでLifecycleメソッドを共通化する
Reactでのアプリ開発ではほぼ、Reduxがデファクトになりつつあり、Reduxを使うことでストアやアクションの処理を見通しよく実装することができます。

## Reduxだけでは不十分
しかし従来のReduxだけでは、コンポーネントのLifecycleメソッドのロジックを組むことができません。

たとえば、このような処理をWebアプリとReact Nativeアプリで同じロジックで実装する場合、どうしたらいいでしょうか。

* コンポーネントがMountされたら、APIを叩いてデータをフェッチする
* コンポーネントのpropsの特定の値が変化したらAPIで値をサーバに書き込む
* コンポーネントがUnmountするときに、ストアをクリアする

WebとReact Nativeアプリで同じコードをそれぞれのコンポーネントに実装するのは、コードの共通化にはなりません。また、コンポーネントの中に書かれたLifecycleメソッドの動作をJestでテストするのは、いろいろと面倒です。

## LifecycleメソッドをHoCでコンポーネントの外に出す
そこでとったのが、以下のようなHoC(Higher order Component)を使って、コンポーネントの外に出すという手法です。

```js:lifecycle.js
import React from 'react'

export default function(Component) {
  class LifecycleComponent extends React.Component {
    componentWillMount() {
      this.props.willMount && this.props.willMount(this.props)
    }
    componentWillUpdate(nextProps) {
      this.props.willUpdate && this.props.willUpdate(this.props, nextProps)
    }
    componentWillUnmount() {
      this.props.willUnmount && this.props.willUnmount(this.props)
    }
    render() {
      return <Component {...this.props} />
    }
  }
  return LifecycleComponent
}
```

これを使うことで、コンポーネントは以下のように定義することができます。

```js:some-component-connect.js
import { connect } from 'redux'

function mapStateToProps(state) {
  ...
}

function mapDispatchToProps(dispatch) {
  return {
    willMount: (props)=> {
      /* called when the component will mount */
      /*   props: component's this.props */
    },
    willUnmount: (props)=> {
      /* called when the component will un-mount */
      /*   props: component's this.props */
    },
    willUpdate: (props, nextProps)=> {
      /* called when the component will update */
      /*   props: component's this.props */
      /*   nextProps: up-coming props */
    },
  }
}

export default connect(mapStateToProps, mapDispatchToProps)
```

```js:some-component.jsx
import React from 'react'
import connect from './some-component-connect'
import lifecycle from './lifecycle'

class SomeComponent extends React.Component {
  render() {
    return (...) // ここにはJSXのみを記述する
  }
}

export default connect(lifecycle(SomeComponent))
```

## LifecycleメソッドをJestでテストする

このようにしておけば、WebアプリもReact Nativeのスマホアプリも同じコードを共有することができます。
また、lifecycleメソッドを、Jestで単体テストを行うのも簡単です。

```js:some-component-connect-test.js
import { mapDispatchToProps } from '../some-component-connect'

var dispatchProps
var dispatch

describe('mapDispatchToProps', ()=> {
  beforeEach(()=> {
    dispatch = jest.fn().mockImplementation((r)=> r)
    dispatchProps = mapDispatchProps(dispatch)
  })

  it('willMount', ()=> {
    expect(dispatchProps.willMount({ some props... })).toEqual({ ...results })
  })
})
```

こんな感じで、テストすることができます。

# コンポーネントのstateの代わりに、Redux-uiを使う
最後に紹介するテクニックがこちらです。

## コンポーネントのstateの問題点
従来のReactでは、UIの状態や内部の変数は、通常コンポーネントの`this.state`に保持していると思います。

しかし、この方法だと、stateの値に依存する処理はコンポーネントの内部に書く必要があり、WebとReact Nativeアプリでコードを共有することが難しくなります。

たとえば、`<input>`で入力した値をSubmitボタンでAPIに渡してサーバに送信するようなケースを考えてみてください。

* API呼び出しを行うSubmitボタンのイベントハンドラは、そのコンポーネントの`mapDispatchToProps`内で定義したい。
* Submitで送信するデータ自体はコンポーネントのstateの中に入っていて、`mapDispatchToProps`からは操作できない。

## Redux-uiはstateの代わりに使える
[Redux-ui](https://github.com/tonyhb/redux-ui)は、コンポーネントのstateとほぼ同一の機能をRedux上で実装したものです。

使い方は簡単。

```js:some-component.jsx
import React from 'react'
import ui from 'redux-ui'
import connect from './some-component-connect'

class SomeComponent extends React.Component {
  render() {
    return(
      <div>
        <input onChange={(ev)=> this.props.updateUI({ inputValue: ev.target.value })}
           value={this.props.ui.inputValue } />
        <Button onClick={()=> this.props.handleSubmit(this.props)}>
          Submit
        </Button>
      </div>
    )
  }
}

export default connect(ui({ state: { inputValue: undefined }})(SomeComponent))
```

このようにすることで、SomeComponentの`this.props`には以下のプロパティが渡されます。

* `this.props.updateUI`: 従来の`this.setState`相当のメソッド
* `this.props.ui`: 従来の`this.state`相当のオブジェクト

上記の例に書いたように、従来`this.setState`/`this.state`で書いていたもの置き換えれば、そのままRedux-uiになります。

## `mapDispatchToProps`からstateを操作する

`mapDispatchToProps`には、以下のようにsubmitのハンドラーを定義することができます。

```js:some-component-connect.js
import { connect } from 'redux'

function mapDispatchProps(dispatch) {
  return {
    handleSubmit: (props)=> {
      dispatch(action.submit(props.ui.inputValue))  // APIにinputValueを渡す
      props.updateUI({ inputValue: undefined }) // inputValueの値をハンドラから操作する
    }
  }
}

function mapStateToProps(state) {
  ...
}

export default connect(mapStateToProps, mapDispatchProps)
```

このようにすることで、コンポーネント内のstateを操作する部分を`mapDispatchToProps`の内部に持ってくることができます。よりコードの共通化を進めることができます。

# おまけ
React+Reduxで開発を進めると、HoCを多用することになります。
今回の私たちの開発の中では、このようなHoCが使われています。

* reduxの`connect()`
* redux-uiの`ui()`
* 先に紹介した`lifecycle()`
* I18n対応で使用した`react-i18next`の`translate()`
* などなど

これら複数のHoCを一つのコンポーネントに適用するのは、実は地味に面倒です。


```js
export default connect(mapStateToProps, mapDispatchToProps)(
  ui({ state: { someState: undefined })(
    lifecycle(
      translate()(Component)
    )
  )
)
```

カッコがたくさんありすぎて、結構、書くのが辛いです。

そこで、私たちはこんなコードを用意しました。

```js:combine.js
import _ from 'lodash'

export default function () {
  var outer_args = arguments

  return function(Component) {
    return _.reduceRight(outer_args, (result, func)=> {
      return func(result)
    }, Component)
  }
}
```

この関数にHoCを引数に渡すと、引数の右から順番にHoCを適用したHoCを戻り値として返してきます。

これを使えば、先ほどのコードはこのようになります。

```js
import combine from './combine'

export default combine(
  connect(mapStateToProps, mapDispatchToProps),
  ui({ state: { someState: undefined }),
  lifecycle,
  translate(),
)(Component)
```

少し、見やすくなりますよね。

# まとめ
同一ソースツリー上でReactNativeでWeb&iOS&Androidのハイブリッドアプリを開発するために、私たちが使ったテクニックはいかがだったでしょうか？

Webとスマホのアプリが一つのソースツリーで構築できるというのは、実際にやってみるととても効率的です。同じ機能を同じタイミングですべての端末にリリースすることができます。しかも、同じ一人のエンジニアで全ての面倒を見ることができます。iPhoneはSwift使いの彼に、AndroidはJava使いの彼女に相談する、みたいなことがないのはとても助かります。

React Nativeはまだまだ日本語の情報が少なく、使いこなせているエンジニアがとても少ないのが現状です。
これを機に、もっともっと多くのエンジニアがReactの資産を生かしつつ、React Nativeでの開発に参加してくれるとうれしいですね。

ちなみに、弊社では開発エンジニアを募集しています。もしこの記事を読んで弊社の開発に興味を持ってくださったら、ぜひ、[弊社お問い合わせページ](http://www.getgamba.com)よりご応募ください。

では。