haripo.com

React Native Web を導入した話

React Native Web は React Native のコンポーネントや API を Web で動作させるためのライブラリです。 React Native は国内での採用事例も増え1資料も充実しつつありますが、React Native Web についてはサンプルレベルを超える開発資料は未だ多くありません。 本稿では React Native で開発された Android アプリである puyosim を Web に移植した経験を元に、 React Native Web を採用した理由や移植の手順について解説します。

React Native Web とは

React Native Web は React Native のコンポーネントや API をブラウザで動作させるライブラリです。 そもそも React 自体が Web アプリを開発する技術なので、何が嬉しいのかわからない方もいるかもしれません。 React Native Web は <div><a> などの HTML を記述するのではなく、 <View><Button> のような React Native のコンポーネントをブラウザ上で動作させます。 つまり、React Native で開発したコンポーネントをそのまま Web で提供することができます。

React Native Web は React Native の API を Web 用のものにすり替えることで動作します。 これは webpack の以下のような設定によって実現しています。

// webpack.config.js
module.exports = {
  resolve: {
    alias: {
      'react-native$': 'react-native-web'
    }
  }
}

Web 向けにビルドするときは react-native の代わりに react-native-web をインポートさせる力技です。 なので import { View } from 'react-native' のように普段通りインポートすれば View がネイティブでも Web でも動作します。

なぜ React Native Web を採用したか

開発している puyosim は「ぷよぷよ」の練習用・検討用 Android アプリです。 これの Web 版を開発するにあたって、フィールドを描画するコンポーネントを再実装したくないという思いがありました。 画像を並べて表示するだけのコンポーネントに見えますが、実際は連鎖のアニメーションを実現するため複雑な実装になっています。 また、将来的に操作履歴のツリー表示を実装する予定があり(執筆時点では実装済み)、 こちらもネイティブで開発したものを Web で実装し直すのにコストがかかりそうでした2

alt
左がフィールドを描画するコンポーネント、右側が操作履歴のツリー表示 

共通化したいコンポーネントはスマートフォン独特の UI に依存していません。 たとえばネイティブのカード UI でスワイプが主要な操作になっている場合、PC ブラウザに対応するには別の UI が求められるでしょう。 puyosim の場合は基本的にフィールドの表示のみを扱うコンポーネントですので、ネイティブ・ブラウザで全く同じ機能を実現することになります。 デザインの面でも Android 固有のものを採用しておらず、そのまま Web に移動しても違和感がありません。 これらの点はコンポーネント共通化に向いているといえそうです。

React Native Web は React Native かなりの部分をサポートしており3、基本的なコンポーネントだけでなく Animated API もサポートしています。 puyosim は Animated にかなり依存していたので、この部分のサポートが導入の決め手でした。

どのような形で React Native Web を導入したか

導入方針

まず、コードどの範囲を共通化するかについて検討しなければなりません。 今回は、Container Component4 や全体のレイアウトを決定するコンポーネントはプラットフォームごとに分離し、 それ以外のコンポーネント(フィールドや操作履歴の描画など)は全て共通としました。 また、今回の場合はネイティブと Web で実現したい機能はほとんど変わらないので、コンポーネント以外の部分(Redux の Action や Reducer)も共通としました。

alt
おおまかなコンポーネントの包含関係の上で共通化戦略を図にするとこうなる

<Simulator/><History/> が「大まかなレイアウトを決定するコンポーネント」にあたります。 これらをプラットフォーム固有にすることで、画面サイズの差によるレイアウトの違いを吸収しています。 たとえば Android ではシミュレータと操作履歴は別の画面に表示させていますが、Web では1つの画面に同時に表示させています。

導入手順

実際に React Native Web を導入したときの手順について説明します。 puyosim は React Native Web 導入前にすでに Android アプリとしてリリースしていますので、まっさらな状態からの導入ではありません。 普通の React Native プロジェクトから段階的に移行しています。

移行の手順としては、次のように行いました。

  1. webpack.config.js や index.web.js を設置し、最小限の Web ビルドができるようにする
  2. src/native/ , src/shared/ , src/web/ のディレクトリを作成し、全てのソースコードを src/native/ に移動させる
  3. ネイティブで動作する状態を担保しながら、コード 1 つづつ src/shared/ に移して Web での動作を確認する

全体を一気に移行するのではなく、コンポーネントを 1 つづつ Web に移行して動作確認を繰り返しました。 これは Web 向けに動作させるために細かな修正がたくさん必要になるかと考えたためです。 しかし、想像以上に React Native Web は優秀で、多くのコンポーネントはコードを変えることなく Web で動作させることができました。 また、コンポーネント以外の部分はほとんどネイティブに依存しておらず、そのまま shared/ に移動して問題ありませんでした。

ディレクトリ構成

React Native Web 導入後の最終的なディレクトリ構成を以下に示します。

.
├── android
│   └── ...
├── assets
│   └── ...
├── index.android.js # これは react-native-navigation の都合で必要
├── index.js
├── index.web.js
├── ios
│   └── ...
├── package-lock.json
├── package.json
├── rn-cli.config.js
├── src
│   ├── native
│   │   ├── components
│   │   └── containers
│   ├── shared
│   │   ├── actions
│   │   ├── components
│   │   ├── containers
│   │   ├── models
│   │   ├── reducers
│   │   ├── sagas
│   │   ├── selectors
│   │   ├── store
│   │   └── utils
│   └── web
│       ├── components
│       └── containers
├── tsconfig.json
└── web
    ├── build
    │   └── ...
    ├── public
    │   └── ...
    └── webpack.config.js

プラットフォーム固有コードの呼び分け

puyosim では上記のように、プラットフォーム固有のモジュールを src/web/, src/native/ に、共通モジュールを src/shared/ に配置しています。 プラットフォーム固有のモジュールから共通モジュールを利用する場合は import SomeComponent from '../../shared/component/SomeComponent' のようにインポートできます。 逆に、共通モジュールからプラットフォーム固有のものを利用する場合ではモジュールを呼び分ける必要があります。 ネイティブ向けにビルドするときは native/ のモジュールを、Web 向けのときは web/ のモジュールを参照しなければなりません。

実は、puyosim では native/web/ を切り替える処理を追加していません。 React Native では index.js が、React Native Web では index.web.js が最初にロードされますので、 index.js から native/ のコンポーネントを使い、index.web.js からは web/ のコンポーネントを使うことで対応できます。 上図で説明したように、ルート要素に近いコンポーネントはプラットフォーム固有、末端のコンポーネントは共通化としているので、 基本的にはプラットフォーム固有のコンポーネントから共通コンポーネントを利用する状況しかありません。

しかし、shared 内のコードからプラットフォーム固有のモジュールを利用したいシチュエーションもわずかながらあります。 たとえば多言語対応のためのライブラリ react-native-i18n は Android 端末の言語設定をみるためネイティブ機能に依存しており、Web では使えません。 以下のようなコードで対応できそうですが、react-native-18n を import した時点で bundler がエラーを吐きます。

import I18n from 'react-native-i18n'
if (Platform.OS === 'web') {
  return 'hoge';
} else {
  return I18n.t('hoge');
}

このような場合にはファイル名による呼び分けが便利です。 filename.android.jsfilename.ios.js で Android 実装と iOS 実装を切り替えられるように、 filename.native.jsfilename.web.js でネイティブと Web を切り替えることができます。 react-native-i18n の呼び分けなど、1ファイルで完結する場合に重宝します。以下が多言語対応の例です。

// translations.ts
const translations = {
  'ja': {
    chain: '連鎖',
    points: '点'
  },
  'en': {
    chain: 'chain',
    points: 'points'
  }
};
export default translations;

上記コードのように各言語の文言を保持しておき、ネイティブの場合は react-native-i18n に渡します。

// i18n.native.ts
import I18n from 'react-native-i18n';
import translations from './translations';

I18n.fallbacks = true;
I18n.translations = translations;

export default (key: string): string => I18n.t(key);

Web の場合は navigator.language をみてどの文言を使うか決めます。

// i18n.web.ts
import translations from './translations';

function detectLanguage() {
  try {
    return (navigator.browserLanguage || navigator.language || navigator.userLanguage).substr(0, 2);
  }
  catch (e) {
    return undefined;
  }
}

const lang = detectLanguage() || 'ja';
export default (key: string): string => translations[lang][key];

上記のようにしておけば import translate from '../shared/i18n' のようにして、共有モジュールからそれぞれのプラットフォーム固有のコードを利用することができます。

ライブラリ

ライブラリについてはそれぞれのライブラリが React Native Web に対応しているかが重要になります。 今回のプロジェクトで利用しているライブラリは運良く Web に対応していました。 react-native-vector-icons は公式でサポートされていますし、 react-native-svg については非公式ながら react-native-svg-web というライブラリを導入することで対応可能です。 React Native Web に対応していなくて困ったライブラリは react-native-i18n くらいでしょうか5。 これも上で説明したように、薄いラッパを書けば対応できます。

まとめ

React Native Web を導入してみて、想定よりも簡単にコンポーネントを Web で動作させることができて驚きました。 レイアウトの修正が多少必要でしたが、ほとんどネイティブ同様の挙動が Web で期待できます。

また、React Native Web を導入した副次的な効果として、コンポーネントをブラウザでデバッグできるようになりました。 実機デバッグだとリロードがうまくいかなかったりすることがあるので、慣れたブラウザと developer tools の上で開発できるのはありがたかったです。

React Native Web を初めて目にしたときは絶対うまくいかないと思い込んでいたのですが、今回のプロジェクトでは最高の効率を得ることができました。 React Native をお使いのみなさまは検討されてみてはいかがでしょうか。