React NativeとWebが共存するもう一つの方法 (1)
React NativeとWebが共存するもう一つの方法(1)
FEConf2024で発表した<React NativeとWebが共存するもう一つの方法>をまとめた記事です。発表内容を2回に分けて公開します。1回目では、WebとReact Nativeが通信する際に発生した問題点とそれを解決するための方法について説明します。2回目では、実際の事例を解決する過程と、この過程を通じてType-SafeとWebとアプリの同期について説明します。本文に挿入された画像の出典は、すべてこのコンテンツと同じタイトルの発表資料からのもので、個別の出典は記載していません。
'React NativeとWebが共存するもう一つの方法'
カン・ソンギュ In-Edit開発者
こんにちは。「React NativeとWebが共存するもう一つの方法」というタイトルで発表を行うカン・ソンギュです。私はIn-Editで、ブランドとクリエイターを繋ぐプラットフォーム「ブランダージン」というサービスを開発しています。普段からオープンソースに関心があり、開発者体験を向上させる方法について多くの考察をしています。
私はReact Native WebViewとWeb間の通信インターフェースを作成するライブラリを開発しました。今回の発表では、このライブラリを基にWebView開発に必要な通信について紹介します。
下記のアドレスにアクセスすると、このライブラリに関するドキュメントが用意されているので、一緒に見ながら進めると理解がしやすいと思います。
CordovaからReact Nativeへの移行
本格的な内容に入る前に、サービス開発中に起こったことを先に紹介します。既存のブランダージンサービスはCordovaで開発されており、これをReact Nativeに移行することになりました。Cordovaで書かれたコードには多くのレガシーコードが残っており、これを段階的にReact Nativeに移行しながら、レガシーから発生する様々な問題を解決したかったのです。
Cordova
まずCordovaについて簡単に説明します。CordovaとはWebアプリとして開発されたサービスをアプリとしてパッケージングして配布するツールです。会社の既存サービスはVue2ベースの100% Webアプリとして作られていたため、アプリストアにリリースするためにはWebアプリをアプリとしてラップするツールが必要でした。このラップ部分をCordovaを活用して開発し、アプリストアにリリースした状況でした。
レガシーコード
下の画像は既存サービスのLighthouseスコアです。思ったより悲惨なスコアを示していますが、この原因は既存のレガシーコードにあります。先述したVue2ベースでレガシーに管理していたため、だんだんレガシーコードしか書けない状況になっていました。
そのため、レガシーコードを清算し、段階的にReact Nativeに移行しようという決定を下しました。結果的に、新しく作られる画面はReact Nativeで開発し、既存のVue.js部分はWebViewでラップしながら、React NativeとWebViewが共存する状態のアプリになりました。
WebView通信
React Nativeへの移行を通じて、本格的にWebViewを開発することになりました。WebViewを活用した開発をしていると、通信の問題に遭遇します。
WebView通信はなぜ必要で、私が経験した通信問題はどのように解決したのでしょうか?この記事では、以下の3つの状況について説明します。
- インアプリブラウザ
- ネイティブナビゲーション
- 共有データ
インアプリブラウザとは、ネイティブアプリでインアプリブラウザを実行する機能です。この機能はWebでは使用できないため、ネイティブアプリの力を借りる必要があります。また、React Nativeに段階的に移行していたため、WebからもReact Nativeの画面に移動できる必要がありました。最後に、認証情報の場合、ほとんどネイティブアプリに保存されるため、Webでネイティブアプリの共有データを取得して使用できる必要があります。
WebView通信についても理解が必要です。下のコードはReact Nativeで提供される基本的な通信方法です。WebからReact Nativeに通信する際は、WebのReactNativeWebViewのpostMessageを通じて文字列を送信できます。WebViewではonMessageというpropsを通じて送信された文字列を処理します。
React NativeからWebに通信する際は、injectJavascriptというpropsを通じてJavaScriptを注入できます。また、リファレンス(ref)を取り出して同様の方法でJavaScriptを注入する方法も存在します。
最初はこのような方法を活用して開発を進め、通信インターフェースを作成しました。
このまま進めていたら、開発を成功裏に完了できたでしょうか?この方法は様々な問題を引き起こしました。
WebView通信で経験した問題点
1. onMessageの分岐
最初の問題点は、onMessageでの無数の分岐です。下のコードのように、すべてのイベントベースのロジックをonMessageで分岐処理しなければならない問題があります。下は3つのケースにのみ対応するコードですが、実際の状況では無数の通信状況が要求されるため、だんだん複雑で重いコードになると思われます。そのため、メンテナンスも困難になる問題が発生します。
2. 非効率的な関数再定義
2つ目の問題は、通信関数を下のようにWebとReact Native両方に宣言しなければならない点です。例えば、インアプリブラウザという機能が追加された時、Webでも「インアプリブラウザを開いて」というメッセージを伝える必要があり、React Nativeでもこれをハンドリングできるコードを作成する必要があります。そのため、機能が一つ追加されるたびに開発に非効率が発生すると考えました。
3. 単方向問題
3つ目の問題は単方向の問題です。WebでPostMessageをReact Nativeに送った時、React Nativeでは結果値を返すことができません。この構造の最大の問題は、成功と失敗を知ることができない点です。そのため、Webでは常に成功を保証するコードを書く負担が生じます。私はこの単方向問題が最も大きな問題だと思いました。
4. 下位互換性
最後の問題点は下位互換性にあります。Webとは異なり、アプリケーションは配布しても即座に最新を維持しません。アプリストアで審査し、審査が承認された後、実際にアプリストアにアップデートされる過程まで終わる必要があります。一方、Webは配布すると即座に最新を維持する性質があります。
ユーザーが過去のアプリケーションを持っている状況で、Webで新しいネイティブ機能を呼び出すとどうなるでしょうか?過去バージョンのアプリケーションでは、該当する最新機能を持つコードをハンドリングできないため、何の反応もないかエラーが発生するでしょう。
実際の事例
先ほど紹介した問題点の実際の事例を見てみましょう。私が作っていたサービスはReact Nativeに段階的に移行しており、私は商品詳細画面の改善を始めました。ただし、そうなるとWebView画面で商品をクリックした時、新しいネイティブ画面に移動する場合と既存のレガシーWeb画面に移動しなければならない場合が一緒に存在しました。
つまり、ユーザーが使用するアプリのバージョンを確認して、状況に応じて異なるページ移動を実装する必要がある状況でした。そのため、成功を保証するコードを書く必要があり、ユーザーエージェントを通じてユーザーのアプリバージョンを確認し、確実に成功できるイベントを呼び出すように開発されました。
これまで説明した既存方式の問題について簡単にまとめると次の通りです。まず、すべてのイベントがonMessageを通じてハンドリングされるため、メンテナンスが困難な問題があります。また、機能が追加されるたびにWebとアプリ両方に通信関数を作成する必要があるため、やや非効率的です。次に、単方向通信の限界により、機能の成功と失敗を知ることができないため、必ず成功を保証するコードを書く必要があります。最後に、アプリのバージョンによって下位互換性を判別するのに困難があります。
問題点解決のための観点の変更
このような問題点を最初から考え直すことにしました。Web開発者にとても馴染みのあるクライアント・サーバー構造を見ると、クライアントはサーバーにリクエストを送り、サーバーはこのリクエストを受けて処理した後、クライアントにレスポンスを送ります。
この構造でクライアントは適切な成功と失敗を知ることができます。この構造を借用したシンプルなインターフェースについて紹介したいと思います。
下はtRPCのコードです。まず、tRPCとはサーバーとクライアント間のtype safetyを保証し、別途のスキーマ定義なしにAPIを構築できるようにするフレームワークです。
下の図の左側のコードがサーバーで、右側がクライアントコードです。サーバーでプロシージャを宣言し、inputと結果値を渡しています。クライアントでは該当プロシージャをそのまま使用可能な形でコードを書くことができます。tRPCでは別途の通信コードが存在しないことが分かります。
このtRPCからインスピレーションを受けて、類似した構造を作ることができると思いました。先ほどのサーバー・クライアント構造で少し観点を変えると、大きく変わらないことが分かります。例えば、React Nativeをサーバーと考え、WebViewをクライアントと考えるとどうでしょうか?
WebViewがReact Nativeにリクエストを送り、React Nativeで適切なレスポンスを渡すなら、フロントエンド・バックエンド構造と大きく変わらずtRPCの構造も使用できるでしょう。
使用法中心設計:Usage
このような様々な考慮の末に決定した使用法を紹介してみます。下の図のReact Nativeでは、ブリッジにネイティブメソッドを宣言し、getMessageでリターン値を送っています。そして、このような構造をブリッジと称し、createWebViewにこのブリッジを注入させます。
Webでは、linkBridgeという関数を実行すると、このブリッジ内に含まれたopenInAppBrowserをそのまま使用できるべきで、単方向問題を解決するためにPromise構造で返され、thenとcatchを通じて成功と失敗を推測できます。また、React Nativeから送られたリターン値を受け取って出力できるべきで、ブリッジに宣言していないasdのような変な関数を実行するとエラーが発生すればよいと思いました。
使用法中心設計:Initialization
使用法を設計したので、実際の機能を開発する番です。機能を開発しながら確立された概念がいくつかあります。
最初の概念はInitializationです。最初にネイティブメソッドが宣言された時、該当ネイティブメソッドの名前がWebに注入されます。そのため、Webはネイティブメソッドの名前を持っている状態です。
使用法中心設計:Hydration
2つ目の概念はHydrationです。Next.jsやRemixを使ったことがあれば、Hydrationの概念を知っているでしょうが、ここからインスピレーションを受けて確立した概念です。Webはネイティブメソッドの名前を持っているので、この名前を利用して自動的に通信コードを生成できるようにしました。
つまり、openInAppBrowserのようなものが注入された時、下のpostMessageのようにランタイムで自動的に通信コードを作ってくれる機能を実装しました。
使用法中心設計:Event to Promise
3つ目の概念は、イベント構造をPromise構造に変更することです。Webで自動的に作られたopenInAppBrowserを実行した時、React Nativeにイベントを送信します。openInAppBrowserは同時にEventEmitterが設置され、React Nativeではレスポンスイベントを送信します。openInAppBrowserで該当レスポンスイベントを受け取ると、その時Promiseをresolveしながら結果値をWebに送ってくれます。この構造を適用すると、単方向問題をある程度解決できます。
使用法中心設計:存在しないメソッドの例外処理
先ほど言ったように、通信コードを自動的に作るため、存在しないメソッドを使用するミスが生じる可能性があります。例えば、下のようにブリッジが宣言されてそのまま使用できる状態なのに、このブリッジに存在しないメソッドを実行すると、下のようにランタイムでエラーが発生する可能性があります。
このように発生したエラーは、Proxyを通じて簡単に解決できます。下のように元のオブジェクトをhookして、既存に存在するキーならそのまま返してくれる一方、存在しないキーなら無名関数を返すように設計しました。そのため、ブリッジに存在しないメソッドを実行すると、実行はされますがエラーハンドリングが可能な状態になります。
先ほどの設計及び機能開発を基にまとめると、下のように実装が可能であることが分かります。
ブリッジでネイティブメソッドを宣言し、ネイティブメソッドはcreateWebViewを通じて注入されます。この過程でinitialization過程を通じてメソッドの名前がWebに注入されます。
WebでlinkBridgeを実行すると、Hydration過程を経て自動的に通信コードが生成され、bridgeのopenInAppBrowserのようにそのまま使用できる形の関数が生成されます。Promise構造に変更されたため、thenとcatchを通じて成功と失敗を知ることができ、asdのような変な関数を実行してもProxyを通じてエラーハンドリングができます。
下位互換性:使用可能なメソッドチェック
次に、下位互換性関連の問題を解決した方式について紹介します。Webは常に最新を保証するため、Webで使用可能なメソッドをチェックすれば下位互換性問題をある程度解決できると考えました。
Initialization過程を通じてopenInAppBrowserとgetMessageが注入された時、下のようなユーティリティ関数を簡単に作ることができます。そのため、現在使用できるメソッドかを判別し、使用可能なら実行し、そうでなければ代替コードを実行できます。
下位互換性:throwOnError
2つ目として、throwOnErrorというオプションも導入しました。throwOnErrorにopenInAppBrowserを入ると、このopenInAppBrowserメソッドが存在しないか失敗すると、Webでも一緒に失敗するように誘導する装置です。Webでもエラーが一緒に出るため、catchを通じてエラーハンドリングを簡単にできます。
下位互換性:onFallback
最後はonFallbackというオプションです。このオプションは、ブリッジに対するエラーを一括処理可能にするツールです。Sentryのようなエラー追跡ツールと一緒に活用すると、より有用に使用できると思います。
これまで、WebとReact Nativeが通信する際に生じた問題点とそれを解決するための方法について説明しました。次の記事では、このような概念を活用して実際の状況で問題を解決した過程について紹介します。
Discussion