1. はじめに
a. なぜハイブリッド構成なのか
ReactのWeb開発者が初めてReact Nativeに触れるとき、最初に直面する悩みは「既存のWebをどこまで再利用できるか」です。モバイルアプリを作るからといって、すべての画面とビジネスロジックをReact Nativeで書き直す必要はありません。すでにReact Webアプリケーションにルーティング、権限処理、状態管理、API連携のフローが整っているのであれば、それをReact Nativeアプリ内部のWebViewで実行するハイブリッド構成も現実的な選択肢になります。
この構成は、Webとアプリの役割を分担する方式です。Webアプリケーションは既存の画面とドメインロジックを維持し、React NativeアプリはWebViewを包むネイティブシェルの役割を担います。ネイティブシェルは、認証情報の保存、スプラッシュ画面、ネットワーク状態の検知、Androidのハードウェア戻るボタン、安全領域の処理など、モバイル環境に近い機能を担当します。
ただし、WebViewを使うからといって、Webのアドレスだけをアプリ内に表示すればよいわけではありません。Webとネイティブは、それぞれ異なる実行環境で動作します。Webはブラウザランタイム上で実行され、React NativeはモバイルOSに近いネイティブランタイム上で実行されます。そのため、両者の間の状態同期、メッセージの受け渡し方法、初期化の順序、エラー時のフォールバックを別途設計する必要があります。
この記事は、React Nativeアプリの詳細な機能実装を解説するものではなく、ReactのWeb開発者が初めてWebViewベースのハイブリッドアプリ構成を設計するときに考慮すべきポイントを整理したものです。
b. この記事の範囲
この記事で扱う範囲は、ハイブリッドアプリの初期構成設計です。具体的には、次の項目を中心に見ていきます。
-
React WebアプリケーションとReact Nativeアプリの役割分担
-
WebViewシェルの構成
-
Webとネイティブ間のブリッジ通信契約
-
初期認証情報の同期
-
スプラッシュ画面とWebの準備状態の連携
-
Androidの戻るボタンへの対応
-
ネットワーク状態の伝達
-
運用段階で補完すべきセキュリティおよび検証ポイント
一方、ネイティブアプリのすべての詳細画面の実装、ストアへの配布、プッシュ通知、アルバムへのアクセス、カメラ権限の処理といった機能は、この記事の中心的な範囲ではありません。これらは、ブリッジ構成が安定した後に段階的に拡張できる領域として扱います。
2. 基本構成
a. WebViewシェル
WebViewベースのハイブリッドアプリの出発点は、単一のWebViewシェルです。React Nativeアプリはユーザーがインストールするアプリの外殻として機能し、実際のサービス画面はWebView内で実行されるReact Webアプリケーションが担当します。
この構成において、React Nativeアプリは次の責任を担います。
-
WebViewの読み込み
-
アプリ上部・下部の安全領域の処理
-
ネイティブスプラッシュの制御
-
SecureStoreなどのネイティブストレージへのアクセス
-
Androidのハードウェア戻るボタンの処理
-
NetInfoに基づくネットワーク状態の確認
-
Webとネイティブ間のメッセージ中継
Webアプリケーションは従来どおりReact、React Router、React Query、状態管理ツールなどを使って、画面とビジネスロジックを処理します。ユーザーは1つのアプリを使っているように感じますが、内部ではネイティブシェルとWebアプリケーションが役割を分担して動作しています。
最初に構成を設計するときに重要なのは、React Nativeを「Webに取って代わる新しいフロントエンド」と捉えるのではなく、「Webをモバイルアプリ環境で実行するためのシェル」と捉えることです。このように考えることで、既存のWebロジックを維持しながら、モバイルアプリに必要な最小限のネイティブ機能を接続できます。
b. 役割分担
ハイブリッド構成で最も避けるべきなのは、Webとネイティブの責任が混在することです。WebプロジェクトにReact Nativeの依存関係を直接追加すると、Web単体での実行やテストが難しくなります。反対に、ネイティブアプリにWebのドメインロジックを過度に組み込むと、既存のWebアーキテクチャの利点を失うことになります。
したがって、基本方針は次のように定めるのがよいでしょう。
-
WebはWebの技術スタックとドメインロジックを所有します。
-
ネイティブアプリはExpo、React Native、WebView、SecureStore、ネイティブ設定を所有します。
-
両者が共通して認識する通信規格は、共通ブリッジパッケージとして分離します。
この分離は、初期設計の段階で特に重要です。アプリの詳細機能がすべて完成していなくても、先に責任の境界を定めておけば、その後に機能を追加するときの影響範囲を予測しやすくなります。
たとえば、Webからネイティブヘッダーのタイトルを変更する必要がある場合、WebがReact Nativeコンポーネントを直接知る必要はありません。WebはUPDATE_HEADER_TITLEのようなメッセージだけをブリッジ経由で送信します。ネイティブはそのメッセージを受け取り、自身のheader状態を変更します。このようにすれば、Webは「ネイティブにタイトル変更を要求する」という契約だけを認識し、実際のネイティブUI実装には依存しません。
c. モノレポと共通パッケージ
Webとネイティブアプリが同じリポジトリ内にある場合、共通パッケージを用意しやすくなります。特にブリッジパッケージは、Webとネイティブが共通して参照するアクション名、ペイロード型、ストレージキーなどを管理するのに適しています。
構成例は次のとおりです。
packages/
브리지/
apps(episodes)/
web-app/
mobile-shell/
ブリッジパッケージは、特定の画面やドメインロジックを認識しない純粋な通信層として構成するのがよいでしょう。このパッケージがビジネスロジックまで把握するようになると、共通パッケージが次第に肥大化し、Webとネイティブの結合度も高まります。
共通パッケージに含めるとよい項目は次のとおりです。
-
ブリッジアクション名
-
アクションごとのペイロード型
-
メッセージ生成関数
-
メッセージ解析関数
-
Webからネイティブへ送信する関数
-
ネイティブからWebへ送信する関数の生成器
-
Webとネイティブで共有するストレージキー
この程度まで共通化するだけでも、Webとネイティブ間の文字列の不一致、ペイロードの欠落、保存キーのタイプミスを減らせます。
3. ブリッジ設計
a. Webとネイティブの通信方式
WebView内のWebとReact Nativeアプリは、互いの状態を直接参照できません。両者の間には、明示的なメッセージ経路が必要です。この経路がブリッジです。
Webからネイティブへメッセージを送る場合は、WebView環境に注入されるwindow.ReactNativeWebView.postMessage()を使用します。ネイティブはWebViewのonMessageを通じてこのメッセージを受信します。
反対に、ネイティブからWebへメッセージを送る場合は、WebView.injectJavaScript()を使用します。ネイティブがWebView内部にJavaScriptコードを注入し、Webはmessageイベントリスナーを通じてこの値を受け取ります。
フローを簡略化すると、次のようになります。
Web → Native
window.ReactNativeWebView.postMessage(JSON.stringify(message))
Native → Web
webView.injectJavaScript(...)
window.dispatchEvent(new MessageEvent('message', { data }))
この通信は基本的にJSON文字列ベースです。そのため、メッセージを送る側と受け取る側が同じアクション名とペイロード構造を共有する必要があります。
b. 型ベースのメッセージ契約
ブリッジメッセージは文字列ベースであるため、ミスが起こる可能性があります。たとえば、WebではREQUEST_USER_CONTEXTを送信したのに、ネイティブではREQUEST_CONTEXTとして処理している場合、メッセージは届いても機能は動作しません。ペイロード構造が変更されたのに、片方だけが修正される問題も起こり得ます。
これを減らすには、ブリッジを単なるユーティリティ関数ではなく、通信契約として管理する必要があります。方向ごとのイベントマップを定義し、アクション名を選択するとペイロード型が自動的に決まるように構成できます。
例は次のとおりです。
interface NativeToWebEventMap {
SYNC_USER_CONTEXT: SyncUserContext페이로드;
NETWORK_STATUS_CHANGED: {
isConnected: boolean;
isInternetReachable?: boolean | null;
type?: string;
};
}
interface WebToNativeEventMap {
REQUEST_USER_CONTEXT: void;
SYNC_USER_CONTEXT: SyncUserContext페이로드;
CLEAR_USER_CONTEXT: void;
WEB_APP_READY: void;
UPDATE_HEADER_TITLE: { title: string };
REQUEST_NETWORK_STATUS: void;
}
この構造では、ペイロードが必要なアクションと不要なアクションを型で区別できます。ペイロードがないアクションはvoidとして定義し、条件付き型を使用すれば、誤った引数の受け渡しをコンパイル段階で防止できます。
また、イベントマップを基にdiscriminated union形式のメッセージ型を作成すれば、受信側でもアクションごとのペイロード型を安全に扱えます。
ただし、TypeScriptの型が保証するのはコンパイル段階の安全性です。実際のランタイムでは、外部から入力されたJSON文字列をパースする構造であるため、運用段階ではZodのようなスキーマ検証ツールを追加することも検討できます。
4. 初期化フロー
a. 認証ハンドシェイク
WebViewアプリで特に注意すべき点の一つが、初期認証情報の受け渡しです。ネイティブアプリにはSecureStoreに保存されたtokenとroleがあり、WebView内のReact Webアプリケーションは、この情報を受け取って初期画面を決定する必要があります。
最も単純な方法は、WebViewのonLoadEndのタイミングでネイティブからWebへトークンを送ることです。しかし、この方法は安定していません。onLoadEndは、WebViewがHTMLの読み込みを終えたことに近い意味を持ちます。Reactアプリケーションがマウントされ、ブリッジメッセージを受け取るlistenerが登録されたことを意味するわけではありません。
つまり、ネイティブは「Webが読み込まれた」と判断してメッセージを送っても、Webはまだメッセージを受け取る準備ができていない可能性があります。この場合、トークンメッセージが失われ、ユーザーはトークンがあるにもかかわらず、未ログイン状態のような画面を見ることになります。
この問題は、push方式よりもハンドシェイク方式で設計するほうが安全です。ポイントは、ネイティブが先に認証情報を送り込むのではなく、Webがlistenerを登録した後に自らリクエストするようにすることです。
フローは次のとおりです。
-
Webの最上位コンポーネントがSYNC_USER_CONTEXT listenerを登録します。
-
listenerの登録後、WebがREQUEST_USER_CONTEXTを送信します。
-
ネイティブはSecureStoreからtokenとroleを取得します。
-
ネイティブはSYNC_USER_CONTEXTで応答します。
-
Webはtokenとroleをストレージと状態に反映します。
-
同期が完了した後、実際の画面をレンダリングします。
この方式のポイントは、WebViewの読み込み完了時点とReact listenerの準備完了時点を同じものと見なさないことです。Webが受信できる状態になってからリクエストし、ネイティブが応答する構造にすることで、初期メッセージが失われる可能性を減らせます。
b. 初期画面の保護
ハンドシェイク方式で認証情報を取得しても、リクエストとレスポンスの間には短い非同期区間が存在します。このときWebアプリケーションが先にレンダリングされると、まだtokenが反映されていない状態で未ログイン画面を表示する可能性があります。その後tokenが届くと、メイン画面へ切り替わるため、画面がちらつきます。
モバイルアプリでは、このようなちらつきが特に不自然に感じられます。ユーザーはWebView内部の構造を知らず、一つのアプリとして認識するためです。そのため、初期認証の同期が完了するまでは、実際のルーティング画面をレンダリングしないように保護する必要があります。
Webの最上位領域にisSyncedのような状態を置き、同期が完了するまでは白い背景やローディング領域だけを表示する方式が適しています。この状態では、ログインページ、役割選択ページ、メインページのいずれも先に描画しません。ネイティブから認証情報を受け取るか、認証情報がないという応答を受け取った後に画面を決定します。
ただし、ブリッジから応答が返ってこない状況にも備える必要があります。ネイティブのエラー、メッセージのパースエラー、WebView環境の違いなどによって応答が失われると、アプリが無限に空白画面のままになる可能性があります。これを防ぐため、一定時間後に強制的に準備完了状態へ切り替えるフォールバックタイマーを設けることができます。
正常な経路はブリッジの応答です。異常な経路はtimeoutです。初期化設計では、この2つの経路を併せて用意するのが安全です。
c. スプラッシュとの連携
React Native/Expoアプリでは、スプラッシュ画面を設定できます。しかし、WebViewアプリでより重要なのは、スプラッシュをいつ閉じるかです。
WebViewのonLoadEndですぐにスプラッシュを閉じると、画面が早く表示されたように感じられるかもしれません。しかし、HTMLの読み込みが完了したからといって、Reactアプリの初期化、認証情報の同期、ルーティングの判断まで完了したわけではありません。この時点でスプラッシュを閉じると、ユーザーはまだ準備のできていない白い画面や、ログイン画面のちらつきを見ることになります。
そのため、スプラッシュを終了するタイミングは、Webの実際の準備状態と連携させるのがよいでしょう。Webアプリケーションが認証同期と初期画面の判断を終えた後、WEB_APP_READYメッセージをネイティブへ送り、ネイティブはこのメッセージを受け取った時点でスプラッシュを閉じます。
フローは次のとおりです。
-
アプリ起動
-
Expoの静的スプラッシュを表示
-
React NativeシェルがWebViewを読み込む
-
Webがブリッジlistenerを登録
-
Webが認証情報をリクエスト
-
ネイティブが認証情報を応答
-
Webが初期画面を決定
-
WebがWEB_APP_READYを送信
-
ネイティブがスプラッシュを終了
ここでもフォールバックが必要です。Webでエラーが発生したり、WEB_APP_READYメッセージが届かなかったりすると、スプラッシュが表示され続ける可能性があります。そのため、WebViewの読み込み後、一定時間が経過したらスプラッシュを強制的に閉じるタイマーを設定し、正常なシグナルを受信したらタイマーを解除する方式が安全です。
d. ストレージの同期
WebViewアプリでは、ストレージが複数の層に分かれています。WebではsessionStorage、localStorage、cookie、IndexedDB、Cache Storageなどを使用でき、ネイティブではSecureStoreのような別のストレージを使用できます。認証情報を扱う際は、これらのストレージ間で不整合が生じないように設計する必要があります。
初回起動時には、ネイティブのSecureStoreからtokenとroleを読み取り、Webに渡します。Webでは、これらを既存の認証フローで使用できるストレージと状態に反映します。反対に、Webでroleを変更した場合は、ネイティブにも選択されたroleを通知する必要があります。そうすることで、アプリを再起動したときに最後に選択した状態を復元できます。
ログアウトには特に注意が必要です。Webストレージからtokenだけを削除して終わりにすると、ネイティブのSecureStoreには認証情報が残っている可能性があります。その場合、アプリの再起動時にネイティブが再びtokenをWebに渡し、ログアウト状態が復元されない問題が発生することがあります。
したがって、ログアウト時にはWebとネイティブのストレージを同時に整理するフローを設計する必要があります。WebではsessionStorage、localStorage、cookie、Cache Storage、IndexedDBなどを整理し、ネイティブにはCLEAR_USER_CONTEXTメッセージを送信してSecureStoreを削除するように構成できます。
また、ストレージキーは共通定数として管理するのがよいでしょう。Webとネイティブがそれぞれ文字列キーを直接記述すると、入力ミスや変更漏れが発生しやすくなります。ブリッジパッケージでtoken keyとrole keyをまとめて管理すれば、双方のストレージ契約を一貫して維持できます。
5. モバイル環境での考慮事項
a. Androidの戻る操作
WebViewアプリでは、Androidのハードウェア戻るボタンを必ず考慮する必要があります。Webブラウザでは、戻る操作が自然にhistoryと連動します。しかしReact Nativeアプリでは、Androidの戻るイベントがまずネイティブレイヤーに渡されます。
ネイティブアプリは、WebView内部のReact Routerの状態を自動的には把握できません。ユーザーが詳細画面から一覧に戻ろうとして戻るボタンを押したのに、アプリがすぐ終了してしまうと、非常に不自然な体験になります。
これを防ぐには、WebViewのnavigation stateからcanGoBackを追跡する構成が必要です。Androidのハードウェア戻るイベントが発生したとき、canGoBackがtrueであればwebView.goBack()を呼び出してアプリの終了を防ぎます。反対に、WebView内部にこれ以上履歴がない場合は、Androidのデフォルト動作を許可します。
この処理は単純ですが、アプリの完成度に大きな影響を与えます。ユーザーは、このアプリがWebViewベースかどうかに関係なく、「戻るボタンを押せば前の画面に戻る」という期待を持っています。ハイブリッド構成でも、この期待に応えることが重要です。
b. ネットワーク状態
モバイル環境では、ネットワーク状態が頻繁に変化します。地下鉄、エレベーター、移動中のWi-Fi切り替え、一時的な通信圏外などでは、リクエストが失敗したり遅延したりする可能性があります。Webでは通常、navigator.onLineやブラウザのonline、offlineイベントを使用しますが、WebViewアプリではこれだけでは不十分な場合があります。
React Nativeでは、NetInfoを通じてデバイスのネットワーク接続状態をより直接的に確認できます。これをブリッジ経由でWebに渡せば、Webアプリケーションでもネイティブ基準のネットワーク状態を利用できます。
たとえば、ネイティブがNETWORK_STATUS_CHANGEDメッセージを送信した場合、Webではこの値をReact QueryのonlineManagerに接続できます。接続が切れたときは、queryやmutationが無理に失敗しないようにし、接続が復旧したら中断されたmutationを再開したり、queryをinvalidateして最新データを再取得したりするように構成できます。
この方法は、単にオフライン案内画面を表示するよりも、さらに安定した構成です。ネットワークが復旧した後、ユーザーが手動で更新しなくても、アプリが通常のフローに戻れるためです。
ただし、ブラウザのonline/offlineイベントを完全に排除する必要はありません。ネイティブブリッジが動作しない環境や、Web単独で実行する環境も考慮し、フォールバックとして併用するのがよいでしょう。
c. 運用面での補完
WebViewブリッジは便利ですが、運用環境ではセキュリティ面も併せて考慮する必要があります。開発中は迅速な確認のためにoriginを広く許可したり、iframeテストでpostMessage('*')を使用したりすることがあります。しかし運用環境では、メッセージを送受信するoriginを明確に制限するのがよいでしょう。
また、ブリッジメッセージはJSON文字列であるため、パースに失敗する可能性があります。TypeScriptの型は開発段階での安全性を高めますが、実行時に受け取る値が実際にその型を満たしている保証はありません。したがって、重要なアクションには実行時スキーマ検証を追加するほうが安全です。
特に、認証情報、ユーザーコンテキスト、ネットワーク状態、ネイティブ機能のリクエストのように、アプリの動作に直接影響するメッセージについては、次の項目を考慮できます。
-
許可されたoriginから送信されたメッセージかどうかを確認します。
-
アクションが定義済みの一覧に含まれているかを確認します。
-
ペイロードの構造が想定どおりかを検証します。
-
パースエラーを単に無視せず、ログに記録したり追跡したりします。
-
機密情報は必要な範囲に限って渡します。
最初にWebViewアプリの構成を設計するときは、機能の接続に集中しがちですが、ブリッジはWebとアプリの間をつなぐ通路です。そのため、初期設計の段階から検証と制限を考慮しておくことが、後の運用安定性につながります。
まとめ
React開発者が初めてReact Native WebViewベースのハイブリッドアプリに取り組む際に重要なのは、ネイティブ画面をどれだけ多く実装するかではありません。より重要なのは、Webとネイティブがどの責任を分担し、どのような規格で通信し、どのタイミングで初期状態を同期するかを決めることです。
WebViewシェル構成は、既存のReact Webアプリケーションの利点を維持しながら、モバイルアプリの形でサービスを提供できる実用的な方法です。しかし、この構成の核心はWebViewにWebアドレスを設定することではなく、Webとネイティブの間の境界を設計することにあります。
初期構成を設計する段階では、次の項目を先に決めておくのがよいでしょう。
-
Webとネイティブの責任分担
-
ブリッジアクションとペイロードの型契約
-
認証情報のハンドシェイクフロー
-
初期画面のレンダリング保留とフォールバック
-
スプラッシュ終了のタイミング
-
SecureStoreとWebストレージの同期
-
Androidの戻る操作への対応
-
ネットワーク状態の伝達
-
運用環境でのorigin制限と実行時検証
このような骨格を先に整えておけば、その後に詳細な機能を拡張する際にも、構造が大きく揺らぐことはありません。React Nativeに初めて触れるReact開発者にとって、WebViewハイブリッドアプリは不慣れな領域かもしれません。しかし、Webとアプリの役割を分離し、ブリッジを明確な契約として管理すれば、既存のWeb経験を基盤に十分取り組める構成です。
Hazel