React NativeとWebが共存するもう一つの方法 (2)
React NativeとWebが共存するもう一つの方法(2)
FEConf2024で発表した<React NativeとWebが共存するもう一つの方法>をまとめた記事です。発表内容を2回に分けて公開します。1回目では、WebとReact Nativeが通信する際に発生した問題点とそれを解決するための方法について説明します。2回目では、実際の事例を解決する過程と、この過程を通じてType-SafeとWebとアプリの同期について説明します。本文に挿入された画像の出典は、すべてこのコンテンツと同じタイトルの発表資料からのもので、個別の出典は記載していません。
'React NativeとWebが共存するもう一つの方法'
カン・ソンギュ In-Edit開発者
前回の記事では、WebとReact Nativeが通信する際に発生した問題点とそれを解決するための方法について説明しました。今回の記事では、前回の内容を活用して実際の事例を解決する過程と、この過程で遭遇したType-Safe、Webとアプリの同期について紹介します。
実際の事例解決
実際の事例で発生した問題点を、今はある程度解決することができます。「WebViewの商品をクリックするとネイティブ画面が表示されるか、既存のWeb画面が表示される必要がある」という要求を受けた時、これまでは成功を保証するコードを書く必要がありましたが、今は様々なツールを用意しました。そのため、わざと失敗させてfailoverを行えばよいと考えました。
下の図は、先ほどの実際の事例に対するコードです。このブリッジには、WebからReact Nativeの画面を呼び出すnavigateをthrowOnErrorに入れています。navigateでエラーが発生すると、Webでも一緒にエラーが発生するようになっています。そのため、このブリッジのnavigateを通じてProductDetail画面に移動する時、存在する画面であればスムーズに移動し、そうでなければcatchを通じて旧バージョンのレガシーページに移動できます。
既存方式の問題を解決した方法をまとめます。
onMessageに複雑なロジックが集中している現象は、メソッド別管理を通じて解決できます。そして、これまでは機能追加時に両方に反復的な通信関数を作成する必要がありましたが、通信関数を自動生成するようにしてReact Nativeにのみ維持すればよくなりました。また、重要な問題である単方向通信はPromise構造に変更することで解決され、最後に下位互換性の問題については、すべてを解決することはできませんでしたが、failoverを支援するユーティリティを通じてある程度解消できました。
Type Safe
これから紹介する内容は、このような過程を経てライブラリを作りながら重要に考えた「Type Safe」に関するものです。TypeScript界隈でよく言及されるType Safeがなぜ必要なのかを簡単に見てみましょう。
Type Safeが必要な理由:型の不一致
Type Safeが必要な理由の一つは型の不一致です。フロントエンドとバックエンドプロジェクトが独立して存在する場合、一般的に互いについて知ることができません。フロントエンドがバックエンドにAPIを呼び出すと、このAPIのレスポンスに対する型を知ることができないため、型を別途定義する必要があります。
しかし、型を別々に定義していると間違いが発生し、当然型の不一致が発生します。React Nativeをバックエンドと考え、WebViewをフロントエンドと考えれば、同じように型の不一致が避けられません。
この型の不一致をコードで見てみましょう。下のように、edgesはnodeと該当nodeのidで構成されたオブジェクトを配列として持っています。そして、このレスポンスを通じて右側のサンプルコードのようにレスポンスに対する型を定義することになります。型が正常にあるので問題なくレンダリングされると予想されます。
しかし、もしid値にnullが来るとどうなるでしょうか?おそらくレンダリングが正常に行われず、下の左側のコードのようにランタイムエラーが発生するでしょう。このような型エラーが発生すると、まずオプショナルチェーンを通じてnullに対する例外処理をして問題を解決する必要があります。
型の不一致を解決するための努力
このような過程を経て、人の手で書かれた型なら100%信頼できないと思いました。このような型の不一致状況が繰り返し発生すると、TypeScriptの目的性を失う可能性があります。TypeScriptの最大の目的は、コンパイル段階でバグを事前に発見することだと思います。型の不一致をよく遭遇すると、型を無視して開発する状況がよく生じ、これはTypeScriptの目的性を失わせます。
しかし、それだけTypeScriptエコシステムは非常に大きいため、このような型の不一致を解決するための努力が多く存在します。REST APIでは、openapi-generatorを通じてSwaggerをTypeScriptに変換してくれます。そして、GraphQLでは、graphql-codegenを通じてスキーマをTypeScriptに変換できます。
これらのツールも十分に優秀なツールですが、それでもSwaggerも人の手を経ることになり、これは結局型の不一致のような間違いが発生する可能性があります。Swaggerに間違って作成された型がTypeScriptとして作られると、これも型の不一致につながる可能性があります。
型を直接定義しない:型推論
この問題を解決するために、私は型を直接定義しないことにしました。正確に言うと、型推論を積極的に活用することにしました。私がライブラリを作りながらType Safeのために適用した型推論コンセプトを簡単な例を通じて見てみましょう。
typeof
ブリッジでネイティブメソッドを宣言する時、インターフェースを作成しませんでしたが、該当ブリッジはtypeofを通じて型を推論できます。このようにコンパイラの助けを借りてすべての型を推論でき、これをWebにうまく送ってくれれば、Webで受け取った型はネイティブで意図した正常なコードとして反映されます。
keyof
次に、keyofのような簡単なキーワードを活用しても開発者体験を向上させることができます。下図のhasMethodのように、keyofを通じて型を推論できます。keyofの助けを借りて使用可能な型かを判別して使用できます。
generic
私が型推論で最も重要だと思うのはgenericです。下のブリッジ関数は、subscribeとgetStateという関数を返します。そして、ブリッジの型としてgenericオブジェクトを宣言しました。そのため、このブリッジに1234という値が入ると、オブジェクトではないため型エラーが発生します。一方、ここにオブジェクトが入ると、すべての型を完成させることができます。
先ほどの内容で最も重要な部分は、inputを通じて型を完成させることです。下のように、Tanstack QueryのuseQueryを使用する時、クエリ関数でリターンを受け取ると、リターン値を基にデータを完成させることができます。簡単なコードのように見えますが、実際に内部的には型推論過程を経てinputを通じてすべての型が完成された状況です。
下の画像の文章は、Tanstack Queryのメンテナーが言った言葉です。これを見ると、型推論をうまく活用すれば、コードだけを見た時はJavaScriptを使用しているようですが、実際にはすべての型が安全な状態で使用できるとのことです。useQueryのインターフェースが別々に存在しませんが、inputを基にすべての型が完成されるためです。
型推論について深く入ってみると、型定義も非常に複雑で、メンテナンスも困難に見えます。しかし、このようなことはライブラリの責任であり、ユーザーの責任ではないという彼の言葉を見て大いに共感しました。
最終的な型推論に対する成果物は以下の通りです。ブリッジにネイティブメソッドが宣言されており、これに該当する型をtypeofを経て出力しています。その次にlinkBridgeでgenericとしてこの型を入れてくれると、ブリッジではこれを基点にすべての型が完成されます。
そのため、このブリッジはopenInAppBrowserのように使用可能なメソッドを推論でき、使用可能なメソッドが推薦される様子を確認できます。
これを少し応用すると、React Navigationとの統合も可能です。下図のように、React NativeのStackRootParamsには移動可能なすべての画面が定義されています。Webでブリッジのnavigateを使用すると、先ほど定義したネームとパラメータをすべて推論できます。
つまり、React Nativeで定義した画面リストを、Webでの追加的な型定義なしに使用できます。このような体験は開発者体験を大きく向上させ、開発者がタイポを書く状況もなくすことができます。これはWebで移動する画面に対するミスを減らす効果につながります。
React NativeとWebの同期
WebViewでネイティブアプリの認証情報を取得する
このようにライブラリに型推論をうまく実装して最初のバージョンをデプロイしました。すべてが完璧だと思いましたが、またもう一つの問題点に遭遇しました。まさに認証情報に関するイシューでした。
Webとアプリの通信が必要な理由の一つが、まさに認証情報を送信して使用することです。現在の構造では、まずブリッジにgetTokenを宣言してトークンを返し、WebではこのgetTokenで値を取り出して使用できます。
しかし、もしネイティブアプリでこのトークンが期限切れになり、トークンが変更されるとどうなるでしょうか?React Nativeは最新のトークンを持っていますが、Webでは期限切れのトークンを持っているため、よくない状況が発生しそうです。
統合のためのWebコアロジック分離:Shared State
この状況を解決するために、React NativeとWebの状態同期が必要だと思いました。このような考えを基に作った概念が、まさにShared Stateです。
この概念は状態に関する概念であるため、他のモダンフレームワークとも簡単に統合が可能である必要がありました。そのため、私はWebコアから分離し、Webコアロジックから始めて状態に関するライブラリを作ることになりました。
Shared Stateも使用法中心で設計を始めました。このブリッジは元々ネイティブメソッドのみ宣言できる状態でした。そのため、Promise関数のみ受け取ることができるのですが、トークン値のようなものを保存するために、nullや文字列のようなPrimitive型も入力可能にしました。
そして、上図のReact Native宣言部を見ると、getとsetを公開して現在の状態と値も設定できるようになっていますが、これはZustandと非常に似ています。状態を管理するライブラリである分、Zustandから多くのインスピレーションを受けて開発したためです。
Webでは、既存のReact Nativeメソッドを公開することに加えて、ストアもブリッジから公開します。このストアには購読機能があり、この購読機能を通じてReact Nativeの状態変化を感知できます。このようにWebで同期が可能になるよう実装しました。
Reactとの統合
先ほどの例はバニラJavaScriptになっています。そのおかげでReactとの統合が簡単に可能です。特にReact 18では、useSyncExternalStoreというフックを提供しており、バニラストアをReactでレンダリングするのを助けてくれるフックです。私はこのフックをラップしてwebview-bridge/reactというReact状態ライブラリとして拡張することができました。
useBridgeにストアを入れてくれると、stateでトークンを取得して使用できます。このトークンはWebに存在しますが、React Nativeと同期されて一緒に反応する状態になります。
最終使用法:Shared State
それでは、最終使用法について見てみましょう。下のReact Nativeでcountを0と宣言し、increaseを通じてこのcountを1ずつ増やしてくれます。React NativeもReactであるため、useBridgeにappBridgeを入れてくれると、countという状態とincreaseというメソッドを使用できます。
Webでは、linkBridgeとAppBridgeを通じてブリッジを宣言し、このブリッジのストアとuseBridgeを通じてネイティブコアロジックのcountとincreaseを取り出して使用できます。これを通じて、React Nativeの状態と同期されて一緒に反応する状態として使用できます。
最終使用法:Native Method
また、下図のようにブリッジではgreetingにinputを入れ、msgというリターン値を返してくれます。これを外部に送ると、このブリッジはgenericを通じてすべての型が完成されるため、ブリッジに存在しない関数はエラーが発生します。また、inputが間違った時は型エラーが発生し、正常に入力を受け取ると、レスポンス値に対する型が正しく表示されることが分かります。
おわりに:ライブラリを作って得た教訓
結局、Type SafeなWebView通信ライブラリの開発を成功裏に完了し、型を手動で定義することを減らし、最大限推論を活用してinputベースですべての型が完成されるようにしました。このようにライブラリを作りながら様々な教訓を得ました。
最高の開発者体験は使用法から生まれると思うため、開発過程では、すぐに機能を開発したわけではなく、使用法から開発しました。その結果、満足のいく抽象化及び成果物を得ることができました。また、私のライブラリはtRPCとZustandと非常に似ています。開発をしながら様々なライブラリを使ってみることができ、それ自体で多くの学習をすることができました。さらに、学習した概念を直接開発に適用する貴重な経験をすることができました。
最後に、最初からWebコアロジックを始めとして開発したため、他のモダンReactフレームワークとも統合が簡単に可能でしたが、もしVue.jsで状態ライブラリを作っていたら、他のフレームワークとの統合は簡単ではなかったと思います。これを通じて、拡張性の高い構造がどのようなものかも学ぶことができました。
今日説明したライブラリは、webview-bridgeという名前で公開されています。説明した内容以外にも、より多くの機能が実装されているので、興味が湧いたら下記のアドレスを訪問してより詳しく調べ、関連ドキュメントも確認していただければと思います。気に入っていただけたら、Starも押してください。ありがとうございます。
Discussion