React Native for Webをプロダクションで使ってみました
こんにちは、モバイルチームの中川[twitter:@nkzn]です。
5月22日にプレスリリースがあった提携で紹介されていたアプリでは、React Native及びReact Native for Webを採用しています。こちらについて技術的な側面から(当たり障りのない範囲で*1)事例を紹介します。
経緯
5/22に、農業総合研究所さんとの業務提携契約が公開されました。
農業総合研究所さんは、7000件以上の農家さんから野菜を集荷し、全国各地のスーパーなどに設置された直売コーナー「農家の直売所」に野菜を出荷している、農産物の流通・販売・コンサルティングを手がける農業ベンチャーです。
(上記のスクリーンショットは2018年7月18日現在のものです)
今回の業務提携により、共同でシステム開発を行っていくことになりました。プレスリリースにある通り、第一弾として農薬使用履歴を管理するアプリを開発しています。
技術選択
様々な要件や今後のウォーターセルとしての方針を加味したところ*2、Android, iOS, Webに向けてマルチプラットフォームで開発することになりました。
今回は特にAndroid, iOS, Webでプラットフォームごとに要件が違うということもなく、むしろ農総研さんの中での教育コストや、農家さんへの指導のコストを考えると、UIは揃っていたほうがよい、ということで、UIレベルでのソースコード共有を行う下地が整っていました。
AndroidとiOSについてはReact Nativeを使えばよいのですが、Webも、となると話が違ってきます。React NativeにはWeb向けのターゲットが用意されていません*3。
React Native for Webとの出会い
ここで目をつけたのがReact Native for Webです。
React Native for Webは「React Nativeと同じ名前のコンポーネントに同じ名前のpropsが生えていて、スタイルの再現性が極めて高い、Web向けのUIコンポーネントライブラリ」です。次のように使います。
import { View, Text, StyleSheet } from "react-native-web";
このままでは「コンポーネントの種類とスタイルのクセがReact Nativeとよく似ているUIライブラリ」でしかありません。
実際に使う場合は、BabelやWebpackを使ってパッケージ名にエイリアスを付けることで、React Native向けに書かれたコードに対してパッケージを誤認させます。
import { View, Text, StyleSheet } from "react-native"; // Web環境向けにビルドした場合はWeb向けのViewが出てくる
このへんのやりかたについては公式チュートリアルで紹介されていますので、興味がある方はご覧ください。
React Native for Webは眉唾かどうか
パッと見ではかなり眉唾感のあるライブラリだと思います。が、検討していくうちに、外堀を見ている限りでは信頼に足るものなのでは?と思えるようになりました。
- コードを読んでも特に怪しいところがなかった
- 作者のnecolasがnormalize.cssを作った人(これだけでスタイルへの信頼性が爆上げ)
- 作者のnecolasがTwitter Liteのテックリード(当時)
- Twitter Liteでプロダクション投入されている*4
眉唾物のネタプロダクトと切って捨てるには、あまりにも素性が良かったのでした。
プロトタイピング
というわけで、触ってみることにしました。ExpoとReact Native for WebでUIコードを共有しつつ、画像や音声などのリソースも管理できるプロトタイプを作成しました。
React Native for WebとExpoを組み合わせてピコピコさせてみたよ - Qiita
感想としては次のようなものになりました。
- スタイルについてはかなり再現性が高い
- よく使うのに足りないコンポーネントがある(FlatListとか)ので代替実装を用意する必要がある
- React NativeそのものではないのでNativeBaseやreact-native-elementsなどのUIライブラリは導入しづらそう
- ネイティブモジュールを使う場合はWeb版にしっかりと代替実装を用意する必要がありそう*5
チームについて
技術選択は事業とチームにフィットするものであるべきです。ということで、どういったチームがあったお陰でこの選択をする気になったのか、という話もしなければなりません。
以前にも記事にしたことがありましたが、弊社モバイルチームはiOSアプリをCordova+webpack+Reactで運用しており、Webアプリの開発については多少の経験があります。また、もともとAndroidエンジニアとしてゴリゴリ書いてきた人たちなので、Android側の挙動についてもトラブルシューティングは容易です。
iOSネイティブのトラブルシューティングだけは不安が残りましたが、こちらは外部の方にお手伝いしていただくことで解決できました。ここは今後の課題です。
各プラットフォームでネイティブな手段でも開発が可能なチームを作って、トラブルシューティングができる体制を整えられたのも、Webとネイティブの同時開発という選択ができた要因になりました。
結論
React Native for Web、けっこう行けるやん。ということで、採用と相成りました。*6
採用してみて良かったこと、困ったこと
実際に開発をしてみて、React Native for Webを採用して良かったこと、困ったことを挙げていきます。環境は次のとおりです。
モジュール | バージョン |
---|---|
react / react-dom | 16.0.0 |
react-native-web | 0.5.4 |
react-native | 0.51.1 |
良かったこと:ブラウザとStorybookでコンポーネント開発ができる
最も開発効率に寄与してくれたのが、Storybookの採用でした。React Native向けのコンポーネントをブラウザ上で動作確認するイテレーションを高速に回すことができました。
Storybookは前述のプロトタイプにも組み込んでありますので、興味がある方は御覧ください。
良かったこと:Webのマークアップエンジニアをアサインできた
Storybookを使ったことによるもう一つのメリットとして、普段WebフロントエンドのチームでReactコンポーネントの作成を担当しているマークアップエンジニアをアサインするのが、容易に実現できました。
- HTML+CSSが主戦場
- JSは得意ではない
- Reactコンポーネントもレイアウトだけで良ければ作れる
- CSS in JSは最近aphroditeで覚えた
くらいのスペックです。
実はStorybookにはReact Native向け実装がありまして、Androidエミュレータを立ててネイティブUIによる動作確認を行う方法もあります。しかし、今回彼をアサインするにあたり「今回のためだけにAndroid StudioやXcodeを入れてもらうのは申し訳ないな〜」という気持ちがありました*7。
というわけで、今回は思い切って、彼にはブラウザのStorybookだけでコンポーネントの動作確認をしてもらうことにしました。
色々ありましたが(後述)、概ね上手くいったと思っています。当人も「 <View>
と <Text>
はdivとspanみたいなものだし、この2つのスタイルのクセを把握すれば、あとはその派生だったから問題なかった。React Nativeのコンポーネントづくりは、十分にマークアップエンジニアの土俵だった」という主旨の話をしており、手応えはあったようです。
困ったこと:本当にWebのノリでスタイルを書くとネイティブが壊れる
というわけで、マークアップエンジニア氏にはかなりの量のコンポーネントを作ってもらいました。そんな中で初期に困ったのが、「Webでしか使えないスタイル」の存在です。
React NativeのレイアウトシステムはYogaが使われているわけですが、これはCSSを再現するものではなく、Flexboxを再現するものです。そのため、Webでは一般的な記法でも、React Nativeでは使えないものがあります。当時に遭遇したものは width
や margin
の値でした。Webでは良くてもReact Nativeではダメな例を挙げます。
// 値に単位をつけるのはNG { width: '100px', marginLeft: '16px' } // 値を直接指定するのはOK { width: 100, marginLeft: 16 }
// %やautoを使うのはNG(だった。現在では使えるらしい。後述) { width: '100%', } { marginLeft: 'auto', marginRight: 'auto' } // Flexboxを使うのはOK { flexDirection: 'row', flexGrow: 1 } { justifyContent: 'center' }
// ViewにText系のスタイルをつけるのはNG <View style={{ color: "#F00" }}> <Text>ここには色がつかない</Text> </View> // 直接指定するのはOK <View> <Text style={{ color: "#F00" }}>ここには色がつく</Text> </View>
最後のTextスタイルの話は、違いを如実に表しています。CSSは内側に向かって再帰的に効きますが、React Nativeの装飾系のスタイルは適用したコンポーネントそのものにしか効きません。
当初、この問題は発覚しませんでした。ブラウザ上で動かしている分には普通のReactとReactDOMによる挙動に準拠するため、ちゃんと動いてしまっていたのです。その後、「そろそろAndroidでも動作確認するかー」と言い出したときにレイアウト崩れが起きて発覚した次第です。
発覚して以降は「Flexboxしか使えないならそれはそれで」ということで納得して書いてもらうことで、ほとんど問題は起きなくなりました。React Native公式ドキュメントでViewとTextのスタイルを見ながらマークアップしていく様はとても頼もしいものでした。
アップデート:margin: autoや%表記について
検証した当時は marginLeft: 'auto'
や %
表記をするとエラーが出ていた記憶があるのですが、本記事を書くにあたって再度検証してみたところ、問題なく使えました。私たちは幻を見ていたのか……
v0.52.0でYogaのmarginLeft: auto
や%
の扱いに手が入ったようなので、このときに直ったのかもしれません。
先程、マークアップエンジニア氏に報告したところ、強くガッツポーズをしておられました。
困ったこと?:flex-directionのデフォルトがcolumn
ネイティブとWebで挙動が逆で戸惑ったパターンとして、flex-direction
のデフォルト値が違う、というものがありました。
プラットフォーム | flex-direction |
---|---|
React Native (for Web) | column |
Web | row |
これに大きくハマったのがマークアップエンジニア氏で、align-items
やjustify-content
の効き方が逆になって、当初は大混乱していました(すぐに慣れました)。
普通のReact Nativeに慣れていた筆者にとってはいつもどおりだったので、まったく気になりませんでした・・・(逆に、たまに普通のWeb開発をするときにブラウザのデフォルトがrowなことに戸惑っています)
困ったこと:画面遷移は共通化できない(しづらい)
React Nativeの頻出課題として、画面遷移をどうやって実現するかという問題があります。AndroidとiOSで画面遷移の考え方が違っていて、どちらかというとiOSよりの考え方で作られていることが多いので、Android出身の筆者はよく混乱しています。
さて、Webの、しかもシングルページアプリケーションの画面遷移となると、ネイティブのそれとは輪をかけて違います。内部的にURIを定義したり、History APIでpushStateするくらいなら、ネイティブもWebも大差ない動きをするのですが、決定的に違う点として、Webでは直リンクやURL削りがカジュアルに行われます。どんなURLで来られても対応できるような対策がWebでの画面遷移には求められます。
画面遷移に関する考え方が違いすぎるので、画面遷移ライブラリはネイティブとWebで別々に用意して、Screenコンポーネントもネイティブ用とWeb用を用意し、Screenの中に配置するコンポーネント(or コンテナ)だけをネイティブとWebで共有することにしました。
それぞれのプラットフォーム向けに採用したライブラリは次のとおりです。
プラットフォーム | ライブラリ |
---|---|
ネイティブ | React Navigation |
Web | React Router |
実はReact NavigationはWebで使えないこともないですし、React RouterはReact Nativeに正式対応しているのですが、下手に共通化するとまずそうな気配を感じたので避けました。もし今後、1つのライブラリで済ませる日が来るとしても、Screenコンポーネントはプラットフォームごとに分けると思います。
困ったこと:FlatListがない
React Native for Webのコンポーネントは、順次拡充されています。そのため、たまにコンポーネントが実装されていないことがあります。
一番困ったのはFlatListが未実装だったことです。リスト表示のパフォーマンスを向上させるためにも、できればあってほしかったのですが・・・
無いものは仕方ないので、内部実装を分岐することにしました。
// ChemicalList.tsx export function ChemicalList(props: { chemicals: Chemical[] }) { return Platform.OS === "web" ? <ChemicalListWeb chemicals={props.chemicals} /> : <ChemicalListNative chemicals={props.chemicals} />; } function ChemicalListNative(props: { chemicals: Chemical[] }) { return <FlatList ... /> } function ChemicalListWeb(props: { chemicals: Chemical[] }) { return <ScrollView> { chemicals.map(chemical => ...) } </ScrollView>; }
Web版の実装はデータ数が少ないので何とかなっているのですが、データが増えるとパフォーマンスに影響が出るので、時間を見つけてreact-virtualizedあたりに置き換えたいなあという気持ちはあります。
また、ChemicalList.native.tsx
と ChemicalList.web.tsx
にそれぞれ実装すれば勝手に切り替わる、くらいまでビルド環境が整備できればよかったのですが、時間切れで諦めてしまいました。そもそも native.ts
や native.js
という接尾辞をReact Nativeそのままでは認識できないはずなので、難易度は高そうです。サードパーティバンドラーのHaulにts-loaderを食わせる例ではnative.tsxを認識させようとしているので、今度はこの方向でチャレンジしてみたいです。
困ったこと:UIライブラリが使えなかった
前述のとおり、NativeBaseやreact-native-elements*8やreact-native-paperなどのUIライブラリがWeb側では使えませんでした。ボタンやヘッダーなどの共通コンポーネントはすべて自作しています(ここでもマークアップエンジニア氏にはめちゃくちゃ活躍してもらいました)。
困ったといえば困ったのですが、UIライブラリが使えたとしてもかなりのカスタマイズが入っていたことが予想されるので、もしかすると自作で良かった部分もあるのかもしれません。
こちらについても、.(android|ios|native).tsx
と.web.tsx
の切り替えによって、各プラットフォーム向けのUIライブラリを採用できるビルド環境の整備を研究しているところです。
良かったこと:かなりの部分のソースコードが共有できた
結果的に、ソースコードのかなりの部分が共有できました。実際のファイル数を見ていきましょう。
今回のアプリケーションでは、Lernaでmonorepo化して依存性の方向を整理した、レイヤードアーキテクチャライクな構造になっています。プレゼンテーション層、アプリケーション層、ドメイン層、インフラストラクチャ層の4層になっています。ディレクトリ構造は次のとおりです。
packages/
presentation/src
: UI周り。アプリとしての本体。native/
: ネイティブにしか関心がない。shared/
: 共有するコンポーネントやコンテナ。⭐web/
: Webにしか関心がない。
application/src
: 処理の交通整理を担当。⭐domain/src
: 仕様を直訳した型定義やアルゴリズムを担当。⭐infrastructure/src
: 外界との通信を担当。⭐
これらのうち、⭐が付いているディレクトリはネイティブからもWebからも参照される共有コードになっています。手元の最新リリースバージョンでファイル数をカウントしてみます。*9
$ find packages/presentation/src/native -type f | wc -l 49 $ find packages/presentation/src/shared -type f | wc -l 251 $ find packages/presentation/src/web -type f | wc -l 65 $ find packages/application/src -type f | wc -l 21 $ find packages/domain/src -type f | wc -l 59 $ find packages/infrastructure/src -type f | wc -l 87
(251+21+59+87)/(49+251+65+21+59+87) = 0.78571...
で、約78.6%が共有できているようです。
共有したことに由来する問題は今のところチーム内でも聞いていませんし、まずまず共通化については大きな成果が出ているといえそうです。
良かったこと:普通に便利なコンポーネントがあった
ネイティブ開発の文脈だと「Flutterに比べて充実してないなあ」という気持ちになるReact Nativeのコンポーネントのラインナップですが、Webで使ってみると普通に嬉しいものがいくつかありました。
- ScrollView
- 何も考えなくても慣性スクロールがついてるの便利
- スクロールを始めるとソフトウェアキーボードが隠れてくれる機能(
keyboardDismissMode
)が便利
- ActivityIndicator
- とりあえず置いとこう、みたいな気持ちでサクサク使える
- ライブラリを見つけてきてもいいけど、ライブラリを探さなくても使えるのが重要
- Switch
- ライブラリを見つけてきてもいいけど、ライブラリを探さなくても使えるのが重要
- TouchableOpacity
- 指タッチに対応した、押すと透明度が変わる領域を簡単に作れる
- これもCSSで弄りだすと手間がかかるので標準搭載されてるのが地味に嬉しい
今回の開発をしながら気付いたのですが、任意のUIフレームワークを自作していく際の材料としては、React Nativeのコンポーネント群は良いバランスをしているんじゃないかと思いました。Webしか開発しない場合にも使ってみたいです。
良かったこと?:contentContainerStyle問題がWebでもネイティブでも起きる
ScrollViewのスクロール部の内部にパディングをつけたい場合は、styleに指定するのではなく、 contentContainerStyle
に付ける、というReact NativeのTIPSがあります。
なんとこのTIPS、React Native for WebでScrollViewを使っている場合にも有効です。
この挙動に気付いたときは「nicolasさんはどこまで再現しとるんや・・・」と戦慄したものでした。
まとめ
あまりReact Native for Webの導入事例を聞かないので、プレスリリースが出た記念にこれまでの開発を振り返ってみました。
一昔前までは眉唾または相当の無茶をしないと得られないと思っていた「Webとネイティブのコード共有」というテーマでしたが、そこまで無理をせずに実現できてしまい、戸惑いつつも嬉しく思っています。
「画面のサイズが同じなら、Webとネイティブでまったく同じ動きをしていい」という要件があったからこそ成り立ったものだったので、他所でも同じような選択ができるとは限らないと思いますが、参考になれば幸いです。
今後の保守や機能改善を行なっていく中で、また課題が見つかることもあるかと思いますが、各個撃破していきます。
We are Hiring!
ウォーターセル株式会社では、一緒にクロスプラットフォームなアプリケーションを作ってくれる、ReactエンジニアやAndroidエンジニアやiOSエンジニアを募集しています。
*1:弊社・農総研さんともに広報チェック済みです
*2:諸事情によりふわっとした言い方にしています
*3:などと言っていたら本当にそれっぽいものをシマンテックの人が作ってしまったのが5月半ばの話なのですが、これについては、個人ブログで紹介しましたので、興味のある方は御覧ください https://blog.nkzn.info/entry/2018/05/26/020312
*4:https://twitter.com/necolas/status/913877194199359488
*5:なお、ピコピコサンプルで音を出したときは、予想に反してWebのほうが実装は楽でした
*6:内容的に本記事と被るところも多いですが、導入の意義については個人ブログにもまとめさせてもらいました https://blog.nkzn.info/entry/2018/05/29/210030
*7:ディスク容量を結構持っていかれるのです
*8:Webへの対応を進めてはいるそうです
*9:本来であればandroid.js, ios.js, web.jsがshared内にある可能性を考慮すべきですが、今回は存在しないことを確認できているので、このカウント方法を採用しています