React Native for WebとExpoを組み合わせてピコピコさせてみたよ

なかざんです。ウォーターセルという新潟の会社ででアグリノートという農業向けアプリを作っています。

業務で使う構成のPoCとしてサンプルプロジェクトを作ってみたので、そこで得られた知見をご紹介します。

つくったもの

私たちが愛してやまないJetBrains製IDEのアイコンとボタンを並べて、ボタンを押すと製品ページに行けるアプリです。押したときにはピコッと鳴ります。

ソースコードはこちら。
https://github.com/Nkzn/react-native-multi-target-sample

ネイティブ版はこんな感じです。

スクリーンショット 2017-12-12 21.32.02.png

Webアプリ版はこんな感じ。

スクリーンショット 2017-12-12 21.33.36.png

見てもらったほうが早い

今回は実物を触ってもらいやすい環境が作れたので、実際に触ってみたほうが雰囲気がつかみやすいと思います。

ネイティブ版

ネイティブ版はExpoで配布しています。Expoアプリ(AppStore, Google Play)をお手持ちのデバイスに入れていただき、↓のQRコードをパシャッと撮ってください。

expo.png

https://expo.io/@nkzn/react-native-multi-target-sample

Web版

Web版はFirebase Hostingにデプロイしておきました。

https://rn-multi-target-sample.firebaseapp.com/

モバイルWebではあまり動作確認していませんが、なんとなく動いてると思います。

何がしたかったのか

モバイルネイティブアプリとWebアプリの同時開発がしたかったのが一番大きなモチベーションです。

PoCのきっかけとなった現在進行中の開発プロジェクトは、次のような特色を持っています。

  • 初期開発分はAndroidタブレットアプリとWebアプリがあればよい
    • Androidスマホ版とiOS版は二次開発
  • 例によって開発人員は豊富ではない(とはいえ何人かで開発するのでコミュニケーションコストは下げたい)
  • OSごとに特色を出すよりは、ユーザーがどのプラットフォームで触っても戸惑わないことが優先
    • いっそUIの差異はほとんどないほうが望ましい

この状況でなんとかプロダクトを世に出すために、次のような特色を持ったプロジェクトを作ることにしました。

  • TypeScriptで静的型付けができる
  • React Nativeでモバイルネイティブアプリ向けのビルドができる
  • React Native for WebでWeb向けのビルドができる
  • モバイルとWebで別々のエントリーポイントを用意している(最悪の場合、完全にWebな画面を用意できる)
  • モバイルからもWebからも使える共通のコンポーネントを置く場所がある
  • 共通のリソース(アセット)ディレクトリを参照できる

そんなことをしているうちにできたのが今回のサンプルです。

コードの流れ

ビルドの流れは、こんな感じになっています。

図.png

大まかには上半分がネイティブ向けのビルドの流れ、下半分がWeb向けのビルドの流れです。

要点としては次の3つを抑えておけばよいかと思います。

  • /src/shared ディレクトリのtsxコードはネイティブとWebの両方から参照されている
  • React NativeのBundlerはTypeScriptのoutput先である /dist を参照しているだけで、元がTypeScriptだったことは知らない
  • ネイティブとWebで、TypeScriptコンパイルとアセットを拾うタイミングが微妙に違う
    • ネイティブではTypeScriptのコンパイルがtscにより行われた後で、Bundlerがjsコード内のrequireを見て/assetsディレクトリから直接アセットを拾っていく
    • Webではwebpackがts-loaderでコンパイルをかけつつ、file-loaderでrequire先のアセットを参照し、一緒くたに/webrootディレクトリに放り込んでいく

どうしてこんな構成になったのかは後述していきます。

Web対応についての話

React Nativeの話題のはずなのに、何故Webの話題が出せるのでしょうか。まずはWeb対応についてのあれこれについて書いていきます。

shared内のコードはWebなのネイティブなの

どちらと言われれば、 ネイティブです。少なくともコード上は import { ... } from "react-native"; と書いてありますし、<div><span> といったDOMは絶対に登場しない、React Native向けのコンポーネントが入っています。

ではWebのエントリーポイントからshared内のコンポーネントを読み込んだ場合に、何故問題が起きないのでしょうか。

ここでReact Native for Webです。React Nativeと同じ名前、同じProps、同じインターフェースを持ったWeb向けコンポーネントを使うことで、React Native向けのコードをWeb上でも動くようにできているのです。

とはいえ、本来のReact Native for Webのimport文は次のようになります。

import { View } from "react-native-web";

sharedディレクトリの中にこんなimport文を書いたら、今度はネイティブ側でエラーが起きてしまいますね。

ここで魔法をかけます。React Native for Webの公式ドキュメントではbabel-loaderにオプション設定を行うことで解決していましたが、今回はBabelではなくTypeScriptを使っているため、webpackで魔法をかけました。

webpack.config.js
  resolve: {
    alias: {
      'react-native': 'react-native-web'
    }
  }

はい、魔法がかかりました。webpackがrequireやimportを解決する際に react-native というモジュールを見つけたら、内部的に /node_modules/react-native-web を見に行くのだなと解釈してくれるようになったのです。

こんな具合に、ネイティブ向けに書かれたコンポーネントをWebで再利用する体制が作れています。

ネイティブ向けに書いたコンポーネントはすべてWebでも動くの?

さすがにそうはいきません。というより動かないコンポーネントもかなり多いです。

基本的にはReact Native for Webの公式Storybookにあるものしか使えないと思ってください。

実装を見に行くと FlatList らしきファイルが置いてあるのでFlatListが実装されているのかと思ってしまいますが、2017年12月現在、あれはハリボテです。Webの場合は大人しくScrollViewでお茶を濁しておくのがよいでしょう。膨大なデータをサクサク扱う羽目になってFlatList的なことを綺麗に実現したくなった場合には、react-virtualizedあたりを使って完全にWebな実装を作ってしまうのも手かもしれません。

そういえば、android.jsとかios.jsみたいなやつって使える?

Babel+webpackでReact Native for Webをビルドする分には .web.js という接尾詞をwebpack.config.jsに定義しておくことで、プラットフォームごとのコードの振り分けができるらしいです(参考)。

一方、TypeScriptはそういうのがめちゃくちゃ苦手です。Hoge.android.jsとHoge.ios.jsは別のファイルなので、 import Hoge from "./Hoge"; などと書こうものなら真っ赤になります。tsconfig.jsonの設定である程度誤魔化せるらしいという話は見かけましたが、筆者は諦めて Platform.select やif文でやれる範囲で何とかしようと考えています。幸い、React Native for Webの処理系では、Platform.OS"web" を返してきますので、割とどうにでもなります。

tsconfig.json周りの話

TypeScriptはES5などの古い仕様のブラウザに優しいJavaScriptを出力することができます。しかし、React Nativeで使う場合はそうは行きません。

今回使ったtsconfig.jsonは次のとおりです。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "module": "es2015",
    "jsx": "react-native",
    "outDir": "./dist",
    "noImplicitAny": true
  },
  "include": [
    "src/native",
    "src/shared"
  ]
}

targetについて

targetもmoduleもかなり高めに設定してあります。

というのも、私が大好きなasync/awaitをTypeScriptも独自実装で持っていまして、targetを低くしてしまうと独自実装のほうに変換されてしまうのです。React Native(というよりはbabel-preset-react-native)のasync/awaitを使うためには、targetを高くして、React NativeのBundlerに読ませるJSコードにasync/awaitを残す必要があったのでした。

jsxについて

実はこれまで、 "jsx": "react""jsx": "react-native" の区別がついていなかったのですが、両方を扱う今回の開発を通じて、ようやく違いが分かりました。

TypeScriptにはTSXを React.createElement(...) の形式に変換する機能があります。これが "jsx": "react" を設定した場合の挙動になるわけですが、やはりtargetの話題と同じように、React NativeのBundlerもJSXを解釈したがっているわけです。できるだけJSXのままBundlerに渡してあげたいところです。

そこで出てくるのが "jsx": "react-native" です。この設定にしておけば、tscはTSXをJSXに変換するだけの挙動となります。正直ほとんど見た目は変わりません。

TypeScriptさんは気遣いの効いているツールなのでした。

Web向けにその設定だとまずいよね?

はい。さすがにES2017でJSXがそのまま残ったJSコードをブラウザに読ませるわけにはいきません。

そのへんはwebpackのts-loaderに頑張ってもらっています。↓がts-loaderの設定です。

webpack.common.js
const tsLoaderConfiguration = {
  test: /\.(tsx?)$/,
  exclude: [
    "/node_modules/"
  ],
  use: {
    loader: 'ts-loader',
    options: {
      compilerOptions: { // overwrite tsconfig.json
        allowJs: true,
        target: "ES5",
        jsx: "react",
        outDir: "webroot",
        lib: ["dom", "ES2017"],
      }
    }
  }
}

tscの出力結果をネイティブ側に読み込ませる関係で、tsconfig.jsonはネイティブ向けに設定するしかありません。そのため、Web向けのTypeScriptはwebpackで設定を上書きする方向で逃げています。

この方式をとったデメリットとして、webpack側にしか設定がない /src/web ディレクトリ内ではvscodeのサポートが甘くなるという事態になっています。

アセット周りの話

おそらくReact Native + TypeScriptでトラブルを起こしやすいのがアセットです。というのも、TypeScript内で画像ファイルや音声ファイルをrequireした場合、tscを終えてもその相対パスは変化しないため、 src 内からの相対パスと dist からの相対パスにずれがあった場合にReact NativeのBundlerからアセットが見えなくなってしまうのです。

このへんの事情を再優先にして、React Native向けには次のようなフォルダ構成を取りました。

assets/
  images/
  sounds/
dist/
  native/
  shared/
src/
  native/
  shared/
  web/

srcとdistから見て、assetsの相対パスが同じになっています。これでネイティブ向けの実装は大丈夫です。

では、Web向けの実装はどうなのでしょうか。これは割と簡単というか、webpackの真骨頂というところで、file-loaderに画像ファイルや音声ファイルの存在を認識させてしまえば、あとはどんなにアセットを動かそうが必ずアセットが参照可能な状態を保ってくれます。

/webroot
  bundle.js
  index.html
  images/
  sounds/

最終的に /assets から諸々のファイルがコピーされて /webroot は上記のようなフォルダ構成になります。 /assets フォルダがなくなり、階層が変わってしまっていますが、特に問題なく動いてくれます。webpack様様です。

ところで、なんでサンプルに音声の再生なんか入れたの?

Web環境でのアセットの提供方法が正しいことを確認したかったからです。画像だけでもいいといえばいいのですが、ブラウザのデータロードの方法として<img>はかなり特殊な挙動をするので、もう少しJS主導でハンドリングするデータも扱いたいなと思ったのでした。

誤算もありました。

WebではShinpeim/NekogataDrumSequencerを見たことがあったので、ブラウザで音を鳴らすことについては問題なくできるだろうと思っていましたし、実際そこまでハマりどころはありませんでした。

予想外に困難だったのがReact Native側です。当初、ネイティブモジュールを使うつもりで調べてみたのですが、スターが多めなのは次のふたつだけでした。

音を鳴らす、という割とベーシックな内容の機能なので、1000スター超えのライブラリくらいはあると思っていたのですが……メンテの頻度も高くはないようで、ハマらずに使えるかというと怪しい気がしました。

そのときまでは普通のReact Nativeプロジェクトとして開発していたのですが、ふと思ったのです。 Expoならそういうの得意なのでは? と。案の定、Expoの独自実装によるAudioモジュールが実装されていました。create-react-native-appでプロジェクトを再構成し、ネイティブ側でも音を鳴らすことに成功したわけです。

なお、Web向けとネイティブ向けで音声再生のAPIがまったく実装が違うので、ちゃんとした実装は /src/web/src/native に置きつつ、 /src/shared 内のコンポーネントから使えるように抽象化したり雑に手動DIしたりする方式をとりました。興味がある人はサンプルの SoundInterface 周りを読んでみてください。

Storybook周りの話

実はこのサンプルにはこっそり、Storybookも整備されています。

https://rn-multi-target-sample.firebaseapp.com/storybook

スクリーンショット 2017-12-13 2.15.58.png

Webアプリ向けのwebpack設定のうち、ts-loaderとfile-loaderの設定は共有しているので、かなり近い環境でコンポーネントの動作確認ができます。

さすがにその性質上、 /src/web/src/shared の中身しか表示することはできませんが、 /src/shared のコンポーネントを表示できるだけでもかなり捗っています。

本物のコンポーネントじゃないのに動作確認に使っていいの?

意外と再現性が高くて、思いの外使い物になっています。会社のマークアップエンジニアさんにプロジェクトを手伝ってもらっていますが、React Nativeの作法に沿ってコンポーネントを作る分には、ネイティブ側で動かしてもほとんど同じ見た目になっています。レイアウトが崩れるのは大抵「React Nativeらしくない(≒Webのお作法による)」スタイルを書いた場合ばかりです。

こんな感じでした。一方で、flexboxを貫く分には、React Native for Webの再現性はそこそこ優秀という印象も持っています。normalize.cssの作者でもあるnecolas氏謹製は伊達ではないという感じですね。

その他どうでもいい話

  • /src/shared の中にReduxやMobXを入れておくと捗ります
  • 画面遷移ライブラリは /src/shared に入れづらいので、画面(いわゆるScreen)は /src/web/src/native に分けて運用すると上手く行きそうです(まだ研究中です)

まとめ

とりとめなく書いてみました。今回の構成は実プロジェクトに投入したばかりのものですので、今後また色々と改善点が出てくるかもしれません。また何かの機会に改善版を公開できればいいなと考えています。

Web屋さんとモバイルネイティブ屋さんが同じコードを触りながらユーザーエクスペリエンスを語り合う未来、かなり近づいてきたんじゃないでしょうか。

P.S.

しかしアレですね。AndroidとiOSの挙動の違いにも苦しめられていたはずなんですが、Webまで扱い始めるとモバイル同士の挙動の違いは些細なもののように思えてくるから不思議です(実際には些細ではないので何にせよ大変)。