Yuri’s Tech Note

技術系アウトプット

react-native-webを自社アプリに組み込んでみた

自社プロダクトのweb版を作ることを検討するため、以前react-native-domを自社アプリに組み込んでみたのに引き続き、今度はreact-native-webを既存のプロダクトに組み込んでみて得た知見をまとめます。

react-native-webを使ってやりたいこと

react-native-webを使う理由として同じプロダクトのwebサービスを作るにあたり、「既存のソースコードの共有ができること」が大きいです。
下図のようなディレクトリ構成にすることで今後の開発でも最小限でモバイルにあわせてwebも開発していくことが可能になると考えています。
ピンクの枠で囲まれた部分がソースコードを共有する部分です。
f:id:yuri_iOS:20181209195114p:plain

reduxのコードを全部共有

APIを叩く処理、受け取ったデータの更新などデータフローに関する処理は基本モバイルもwebも同じで問題ないと考えています。
裏側の処理の実装もロジックテストも先にモバイルで開発した時に担保できていれば、後から作るwebでは画面のみ作るだけで済みます。
更に言えばモバイルで実装した人が同じ部分をweb版も実装すれば大幅な時間の節約が期待できます。

Atomic Designの思想でスタイルの統一

コンポーネントはatoms と一部のmoleculesを共有し、それより大きい粒度ではモバイルとwebわけて画面を組みあげたいです。
これができるとモバイルとweb両方作る身としてはにスタイルの書き方が全く同じで良いので気持ちが良いし開発スピードもあがります。
なによりパーツのトンマナが揃い、メンテナンスの余地が減るのはとても素晴らしいことだと思っています。

開発体験

上記2つによって、開発者体験はとても良いものになります。
ブランチを切り替えるだけで、いつもと同じスタイル記法、reduxの思考でそのまま気軽にweb版も開発できるのはとても素敵です。
モバイルはどうなってたっけ?webはどうなってたっけ?もエディターを開き直す必要はありません。

組み込み作業

ここから実際に組み込む際に行なった作業手順を紹介します。
コード量が多いのと会社のプロダクトに実際に組み込んだためソースコードを見せることができませんが、必要だと思うポイントを絞って記載します。

1. Entry Point (index.js)

import { AppRegistry } from 'react-native';
import Root from './root';
import registerServiceWorker from './registerServiceWorker';

AppRegistry.registerComponent('TeamHub', () => Root);
// 下記2行をweb用に追加
AppRegistry.runApplication('TeamHub', { rootTag: document.getElementById('root') });
registerServiceWorker();

2. Root
ProviderのstoreにconfigureStoreで生成したstoreを渡してreduxを組み込みます。
I18nはブラウザで設定されている言語の取得方法がわからなかったので一旦強制的に日本語にしています。

import React from 'react';
import { Provider } from 'react-redux';
import I18n from 'react-native-i18n';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import App from './app';
import configureStore from '../redux/configureStore';
import loadTranslations from '../i18n/translations';

const store = configureStore();
loadTranslations();

// 強制的に日本語に設定
I18n.locale = 'ja-JP';
if (I18n.locale === 'ja-JP') {
  require('moment/locale/ja');
}

const Root = () => (
  <Provider store={store}>
    <Router>
      <Route exact path="/:filter?" component={App} />
    </Router>
  </Provider>
);

export default Root;

3. redux/modules
ここの作業ボリュームが多いです。
webではネイティブモジュールを読み込んでいるファイルをコンパイルすることができないため、ネイティブモジュールを使用するreduxのモジュールは全て別を用意しました。
例) auth.js / auth.web.js
こうすることでreducer.jsでモジュールをcombineReducersでまとめ上げる際にweb.jsの方を読んでくれるのでネイティブモジュールの読み込みによるコンパイルの失敗を回避します。

具体的には、ログインやサインインなどの認証周りのモジュールをまとめたauth.jsというファイルの中でreact-native-fabricなどのネィティブモジュールを使用していたところ、そのモジュールを実際に使う処理を実行しなくてもimportの時点でモジュールを読み込みに行くため、そこでネィティブにアクセスする前提のモジュールにとって色々なものがなくてコンパイルが失敗しました。
f:id:yuri_iOS:20181209014937p:plain

ネイティブモジュール読み込めないよ問題はreact-native-domでも同じでしたがreact-native-webではこの回避策としてimportした時点でモジュールの生成をしないようにできるwebpackのIgnorePluginが使えるのではないかと考えました。
IgnorePlugin

IgnorePluginを使ってネィティブモジュールを全て登録しておき、ネィティブモジュールを使う処理だけモバイル環境でのみ実行するようにする最低限の変更で済むことを期待しました。

f:id:yuri_iOS:20181208222626p:plain
上記が実際に出力されたエラーです。
./node_modules/react-native-fabric/Crashlytics.jsを読み込まないように下記記述でコンパイルを試みましたがエラーは変わりませんでした。

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/, /^\.\/Crashlytics$/, /react-native-fabric$/),

結果としてネィティブモジュールを読み込むファイルは重複するコードが多くなってしましますが全て別途用意することになりました。
react-native-domの時と違ってファイルの拡張子をweb.jsとすれば勝手に読み分けてくれるのは嬉しいです。

Webpack設定

webpackを使うために下記ファイルを用意しました。

  • webpack.config.dev.js
  • webpack.config.prod.js
  • env.js
  • paths.js
  • polyfills.js

公式ドキュメントにもありますが、webpack.configにてreact-nativeにエイリアスを貼ってreact-native-webを読み込むようにします。

module.exports = {
  resolve: {
    alias: {
      'react-native$': 'react-native-web'
    }
  }
}

reactn-native-webというのは基本react-nativeと同じ名前のパーツをweb用に用意しているので例えば

import { View } from 'react-native'

とあった場合エイリアスによってreact-native-webのViewを見に行きます。
React Native examples

webpackの設定は自分も初めてやったので確証はないのですが、多分このエイリアス以外特別なことはしてないと思うので他は割愛します。

devserver設定

同じくdevserverを使うために下記ファイルを用意しました。

  • webpackDevServer.config.js
  • registerServerWorker.js

Tips: PropTypes
PropTypesを使っているモジュールはコンパイルできないのでbabel-plugin-transform-react-remove-prop-typesを使うことで削除する必要があります。
またこの時babel-loaderの対象としてincludeにPropTypesを使うモジュールを指定する場合はpaths.jsでアプリのディレクトリからの相対パスに変換してくれるresolveAppを使った形でないとパスをうまく解釈できません。

//paths.js
module.exports = {
  ....
  appNodeModules: resolveApp('node_modules'),
  appRNNodeModules: resolveApp('node_modules/react-native-material-design'),
  appRNNodeModules2: resolveApp('node_modules/react-native-vector-icons'),
};
//webpack.config
{
  test: /\.(js|jsx|mjs)$/,
  loader: require.resolve('babel-loader'),
  include: [paths.appSrc, paths.appRNNodeModules, paths.appRNNodeModules2],
  options: {
    presets: ['react-native'],
    plugins: ['react-native-web', ['babel-plugin-transform-react-remove-prop-types', { mode: 'remove' }]],
    cacheDirectory: true,
    babelrc: true,
  },
},

react-native-webのプロダクション採用について

採用するにあたり懸念している点を列挙します。

web版でのスタイルの情報が曖昧

f:id:yuri_iOS:20181209053607p:plain
当たり前ですがToggle InspectorのようにViewにあてているスタイルの文言ががそのまま表示されません。
Elementsで該当箇所を指定すると遠からずなスタイルの表示も確かに出てはいますが、原因不明のレイアウトが崩れが起きた場合に原因を探すのは苦労しそうです。
特にブラウザによって異なる系問題はかなり苦労しそうです。

バージョン問題

モバイルでは最新バージョンがでたらすぐにアップデートしていたのですが、react-native-webはreact-native最新バージョンにすぐに追従してくれるわけではないのでバージョンが引っ張られる、もしくはwebの時だけ毎回バージョンを下げるという絶対めんどくさい作業が発生することになります。
バージョンで書き方が変わる箇所があるとなった場合にはこれはけっこうなネックになりえます。

エラーがわかりにくい

実際に他の人が触って見てもらった感想です。
基本問題があればコンパイルできないので開発最中は困ることはないと思いますが、バージョンアップ時や最初の0から構築する場合に苦労することになります。

共有できるコードが案外少ない

redux側はほとんどまんま使い回せるかと思いきや、アクションごとに数値を取るためにそこかしこにネィティブモジュールを使ってしまっていたのでけっこうredux側でも手を入れなきゃいけない箇所が多くなってしまいました。
表側に関してはそれこそほとんど別に組まなくてはならないのですが、感覚値完全に共有できるのは全コードのうち30%行かないくらいかな。。という感じです。

参考になる記事が少ない

日本だとなかざんさん(@Nkzn)のウォーターセルしか採用事例がなく、海外でも軽く作ってみた系はあってもがっつり使ってんでー!知見あるでー!というのは見つけられませんでした。

チームやプロダクトにあっていない

自分たちのチームにはwebに詳しい人がいないのでコンパイルできない時にwebpackの設定が悪いのかreact-native-webに原因があるのか自分たちのコードに原因があるのか判断するのが大変そうです。
またこのプロダクトは今後もたくさんの機能を開発予定で、既に多機能なアプリなので追加したい機能によってはいつかスタックする事案が出てきそうなのが怖いです。

最後に

react-native-webはTwitterUberThe Timesなども使っているので挑戦してもいうほど悪い賭けではないと思うのですが、やはり会社でやるには強くやろうぜ!と言えないです。
ここは強いパッションがないと採用するのは難しいのかな。
もっとシンプルなアプリケーションであれば、ReduxとAtomsを共有してモバイルとwebを作るという発想は最速プロダクトリリース手法として悪くないと思っています。