React, React Native, Reduxを使って,web, android, iOSアプリのコードを出来る限り共通化します.
ReactとReduxを組み合わせて使う場合,見た目を司るPresentational componentとロジックを司るContainer componentにコードを分離します( http://qiita.com/tuttieee/items/a3ca7d9d415049d02d60 ).
そこで,Presentational componentは各デバイス固有のコードにし,Container componentは共通化することでロジックをデバイス依存のコードから切り離し一つにまとめます.actionやreducerなども共通化できるので,結果としてロジックはすべて共通化され,Viewのみがプラットフォーム固有のコードになります.
ここでは,よくあるTodoアプリの超劣化版として,Todoを「追加できるだけ」のアプリを作ってみます.
ロジックの共通化のための設計にフォーカスするので,アプリ自体は作りこみません.あしからず.
こんなしょうもないアプリですが,React nativeとReact両方で動きます.
Webで動くなら,ということでおまけでElectronにも載せてみました
コードはこちらです: https://github.com/tuttieee/ReactCrossDeviceTodoExample
READMEのとおりにやれば動かせるはずです.
Structure
React nativeの雛形からスタートし,src
ディレクトリを新たに作ってそこにコードを書いていきます.
ビルドにはgulpやwebpackを使っているので,そのファイルも追加されています.
.
├── android
├── ios
├── node_modules
├── public
├── src
├── gulpfile.js
├── index.android.js
├── index.electron.js
├── index.ios.js
├── npm-debug.log
├── package.json
└── webpack.config.js
src
以下はこんな感じです.
./src/
├── actions
│ └── index.js
├── connects
│ └── TodoApp.react.js
├── native
│ ├── components
│ │ ├── App.react.js
│ │ └── TodoApp.react.js
│ └── containers
│ └── TodoApp.react.js
├── store
│ ├── reducers
│ │ ├── index.js
│ │ └── todos.js
│ └── configureStore.js
└── web
├── components
│ ├── App.react.js
│ └── TodoApp.react.js
├── containers
│ └── TodoApp.react.js
├── app.js
└── index.html
このとおり,action, store, reducerはreduxアプリケーションのよくある形です.
一方,components, containersがnative
とweb
に分かれていて,普通は見ないconnects
というディレクトリがあります.
ポイント
Container components
コードをすべて解説するのは大変なので要点だけ書きます(action, store, reducerは普通にreduxでTodoアプリを作るのと同じ感じです).
ポイントは,containerを共通化する部分です.
connects/TodoApp.react.js
, native/containers/TodoApp.react.js
, web/containers/TodoApp.react.js
を見てみます.
import { connect } from 'react-redux'
import {
addTodo,
} from '../actions'
const mapStateToProps = (state) => {
return {
todos: state.todos,
}
}
const mapDispatchToProps = (dispatch) => {
return {
addTodo: text => dispatch(addTodo(text))
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)
import TodoApp from '../components/TodoApp.react'
import connect from '../../connects/TodoApp.react'
export default connect(TodoApp)
import TodoApp from '../components/TodoApp.react'
import connect from '../../connects/TodoApp.react'
export default connect(TodoApp)
このように,通常であれば一つのcontainerファイルにまとめられる部分が,
-
mapStateToProps
とmapDispatchToProps
を引数にしてconnect()
を呼び,その返り値をExportするモジュール - 上記を受け取って,Presentational componentに適用するモジュール
に分かれます.前者は共通ファイルで,connects
以下に置いておき,後者をnativeとwebの両方に用意してそれぞれのプラットフォーム用のPresentational componentを読み込むことで,各プラットフォームに対応します.
もちろん,container componentでも共通化できないものがでてきたら,native / webそれぞれの下のcontainers以下に固有のcontainer componentを作ってしまえば対応できます.
また,見てお分かりの通り,src/native/containers/TodoApp.react.js
とsrc/web/containers/TodoApp.react.js
は全く同じ内容です.
別ファイルとすることで,ReactでビルドするときとReact nativeでビルドするときで,お互い共通化できないモジュールを読み込むことを防いでいるのですが,同じ内容のファイルが2つあるというのはよろしくないですね…
ここはどうにかしたいところです.(シンボリックリンクにしてみたりしましたが,うまくいきませんでした)
Presentational components
参考までに,各プラットフォーム向けのPresentational componentは以下のようになっています.
import React from 'react'
import {
Component,
StyleSheet,
View,
Text,
TextInput,
ListView,
} from 'react-native'
const styles = StyleSheet.create({
container: {
marginTop: 38
}
})
export default class extends Component {
constructor(props) {
super(props)
let ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
})
this.state = {
ds: ds.cloneWithRows(props.todos || []),
addTodoText: '',
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.todos !== this.props.todos) {
this.setState({
ds: this.state.ds.cloneWithRows(nextProps.todos || [])
})
}
}
render() {
const {
addTodo,
} = this.props
return (
<View style={styles.container}>
<ListView
dataSource={this.state.ds}
renderRow={(rowData) => <Text>{ rowData.text }</Text>}
/>
<TextInput
style={{height: 40, borderColor: 'gray', borderWidth: 1}}
onChangeText={addTodoText => this.setState({addTodoText})}
value={this.state.addTodoText}
/>
<Text onPress={() => {
addTodo(this.state.addTodoText)
this.setState({addTodoText: ''})
}}>ADD</Text>
</View>
)
}
}
import React from 'react'
export default React.createClass({
render() {
const {
todos,
addTodo,
} = this.props
return (
<div>
<ul>
{todos.map((todo, i) =>
<li key={i}>{ todo.text }</li>
)}
</ul>
<button onClick={() => {
addTodo(this.refs.todo_text.value)
this.refs.todo_text.value = ''
}}>ADD</button>
<input type="text" ref='todo_text'/>
</div>
)
}
})
このように,Presentational componentは各プラットフォーム固有のUIパーツを使って,全然違うコードになります.
Electron
web版ができればelectronが使える!ということで,electronでも動かしてみました.
index.electron.js
がエントリポイントになっています.
まとめ
ロジックは共通化,Viewはプラットフォーム固有化することで,
web / iOS / android / PC (windows, OSX, linux) のクロスプラットフォーム開発ができそうです.
ReactやReact nativeの哲学はLearn once, write anywhere.
であってWrite once, run anywhere
ではないので,やり過ぎは良くないかもしれませんが…
リポジトリ(再掲):
https://github.com/tuttieee/ReactCrossDeviceTodoExample
参考
http://jkaufman.io/react-web-native-codesharing/
こちらも似たようなことしていますが,containerの共通化まではしていません.この辺の塩梅は好みですかね.
この記事は各プラットフォーム向けのビルドスクリプトが載ってるのでそこも参考になりそうです.