今回、弊社の開発・運営する日報共有アプリ「gamba!」を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アプリで異なるバージョンのパッケージを混在させることができ、バージョンのやっかいなコンフリクトを回避することができます。
私たちが使ったのは、こんなスクリプトです。
#!/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
を切り替えます。
$ 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)を使って、コンポーネントの外に出すという手法です。
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
}
これを使うことで、コンポーネントは以下のように定義することができます。
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)
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で単体テストを行うのも簡単です。
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は、コンポーネントのstateとほぼ同一の機能をRedux上で実装したものです。
使い方は簡単。
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のハンドラーを定義することができます。
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を一つのコンポーネントに適用するのは、実は地味に面倒です。
export default connect(mapStateToProps, mapDispatchToProps)(
ui({ state: { someState: undefined })(
lifecycle(
translate()(Component)
)
)
)
カッコがたくさんありすぎて、結構、書くのが辛いです。
そこで、私たちはこんなコードを用意しました。
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を戻り値として返してきます。
これを使えば、先ほどのコードはこのようになります。
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での開発に参加してくれるとうれしいですね。
ちなみに、弊社では開発エンジニアを募集しています。もしこの記事を読んで弊社の開発に興味を持ってくださったら、ぜひ、弊社お問い合わせページよりご応募ください。
では。